mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-31 01:40:42 +00:00
Esiur CLI
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
using Esiur.CLI.Authentication;
|
||||
using Esiur.CLI.Configuration;
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Resource;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Authority.Providers;
|
||||
|
||||
namespace Esiur.CLI.Client;
|
||||
|
||||
public interface IEsiurSessionFactory
|
||||
{
|
||||
Task<EsiurSession> ConnectAsync(
|
||||
ResolvedConnection settings,
|
||||
bool passwordFromStandardInput,
|
||||
TextReader input,
|
||||
TextWriter error,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class EsiurSessionFactory(ICredentialService credentials) : IEsiurSessionFactory
|
||||
{
|
||||
public async Task<EsiurSession> ConnectAsync(
|
||||
ResolvedConnection settings,
|
||||
bool passwordFromStandardInput,
|
||||
TextReader input,
|
||||
TextWriter error,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
byte[]? password = null;
|
||||
try
|
||||
{
|
||||
var profile = settings.Profile ?? new ConnectionProfile
|
||||
{
|
||||
Name = settings.DisplayName,
|
||||
Endpoint = settings.Endpoint,
|
||||
Provider = settings.Provider,
|
||||
Identity = settings.Identity,
|
||||
Domain = settings.Domain ?? string.Empty,
|
||||
};
|
||||
var context = new EpConnectionContext
|
||||
{
|
||||
AutoReconnect = false,
|
||||
AuthenticationTimeout = settings.Timeout,
|
||||
Domain = settings.Domain ?? string.Empty,
|
||||
};
|
||||
|
||||
if (PromptCredentialService.IsPasswordProvider(settings.Provider))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(settings.Identity))
|
||||
throw new CliException("Password authentication requires an identity.", ExitCodes.InvalidArguments);
|
||||
password = await credentials.GetPasswordAsync(
|
||||
profile, passwordFromStandardInput, input, error, cancellationToken);
|
||||
warehouse.RegisterAuthenticationProvider(
|
||||
new PasswordClientProvider(settings.Identity, password!));
|
||||
context.AuthenticationMode = AuthenticationMode.InitializerIdentity;
|
||||
context.AuthenticationProtocol = PasswordAuthenticationProvider.ProtocolName;
|
||||
context.Identity = settings.Identity;
|
||||
}
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
if (settings.Timeout > TimeSpan.Zero) timeout.CancelAfter(settings.Timeout);
|
||||
var endpoint = EndpointParser.ConnectionEndpoint(settings.Endpoint);
|
||||
var connectTask = AwaitReply(warehouse.Get<EpConnection>(endpoint, context), timeout.Token);
|
||||
var connection = await connectTask;
|
||||
if (connection is null)
|
||||
throw new CliException($"Could not connect to {endpoint}.", ExitCodes.ConnectionFailed);
|
||||
return new EsiurSession(warehouse, connection, settings);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try { await warehouse.Close(); } catch { }
|
||||
throw new CliException("The connection timed out.", ExitCodes.Timeout);
|
||||
}
|
||||
catch (CliException)
|
||||
{
|
||||
try { await warehouse.Close(); } catch { }
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
try { await warehouse.Close(); } catch { }
|
||||
var authentication = PromptCredentialService.IsPasswordProvider(settings.Provider)
|
||||
&& exception.Message.Contains("auth", StringComparison.OrdinalIgnoreCase);
|
||||
throw new CliException(
|
||||
authentication ? "Authentication failed." : $"Connection failed: {exception.Message}",
|
||||
authentication ? ExitCodes.AuthenticationFailed : ExitCodes.ConnectionFailed,
|
||||
exception);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (password is not null) Array.Clear(password, 0, password.Length);
|
||||
}
|
||||
}
|
||||
|
||||
internal static Task<T> AwaitReply<T>(Esiur.Core.AsyncReply<T> reply, CancellationToken cancellationToken)
|
||||
{
|
||||
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
reply.Then(value => completion.TrySetResult((T)value))
|
||||
.Error(exception => completion.TrySetException(exception));
|
||||
var registration = cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
|
||||
_ = completion.Task.ContinueWith(_ => registration.Dispose(), TaskScheduler.Default);
|
||||
return completion.Task;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EsiurSession : IAsyncDisposable
|
||||
{
|
||||
internal EsiurSession(Warehouse warehouse, EpConnection connection, ResolvedConnection settings)
|
||||
{
|
||||
Warehouse = warehouse;
|
||||
Connection = connection;
|
||||
Settings = settings;
|
||||
}
|
||||
|
||||
public Warehouse Warehouse { get; }
|
||||
public EpConnection Connection { get; }
|
||||
public ResolvedConnection Settings { get; }
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try { Connection.Destroy(); } catch { }
|
||||
try { await Warehouse.Close(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
public static class EndpointParser
|
||||
{
|
||||
public static string ConnectionEndpoint(string endpoint)
|
||||
{
|
||||
ConfigurationResolver.ValidateEndpoint(endpoint);
|
||||
var uri = new Uri(endpoint);
|
||||
return $"ep://{uri.Authority}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Esiur.Core;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Authority.Providers;
|
||||
|
||||
namespace Esiur.CLI.Client;
|
||||
|
||||
internal sealed class PasswordClientProvider(string identity, byte[] password) : PasswordAuthenticationProvider
|
||||
{
|
||||
public override byte[] GetSelfCredential(string requestedIdentity, string domain, string hostname) =>
|
||||
string.Equals(requestedIdentity, identity, StringComparison.Ordinal) ? password : null!;
|
||||
|
||||
public override IdentityPassword GetSelfIdentityAndCredential(string domain, string hostname) =>
|
||||
new() { Identity = identity, Password = password };
|
||||
|
||||
public override AsyncReply<bool> Login(Session session) => new(true);
|
||||
public override AsyncReply<bool> Logout(Session session) => new(true);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using Esiur.CLI.Rendering;
|
||||
using Esiur.Data.Types;
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Resource;
|
||||
|
||||
namespace Esiur.CLI.Client;
|
||||
|
||||
public sealed record ResourceSummary(
|
||||
string Name,
|
||||
string Path,
|
||||
uint SessionId,
|
||||
string Type,
|
||||
string TypeId,
|
||||
int ChildCount);
|
||||
|
||||
public sealed record PropertyDescription(
|
||||
string Name,
|
||||
byte Index,
|
||||
string Type,
|
||||
bool ReadOnly,
|
||||
bool Constant,
|
||||
bool Historical,
|
||||
bool Inherited,
|
||||
object? Value,
|
||||
IReadOnlyDictionary<string, string> Annotations);
|
||||
|
||||
public sealed record FunctionDescription(
|
||||
string Name,
|
||||
byte Index,
|
||||
string Arguments,
|
||||
string ReturnType,
|
||||
string Flags,
|
||||
bool Inherited,
|
||||
IReadOnlyDictionary<string, string> Annotations);
|
||||
|
||||
public sealed record EventDescription(
|
||||
string Name,
|
||||
byte Index,
|
||||
string ArgumentType,
|
||||
bool Subscribable,
|
||||
bool Inherited,
|
||||
IReadOnlyDictionary<string, string> Annotations);
|
||||
|
||||
public sealed record ConstantDescription(
|
||||
string Name,
|
||||
byte Index,
|
||||
string Type,
|
||||
object? Value,
|
||||
bool Inherited,
|
||||
IReadOnlyDictionary<string, string> Annotations);
|
||||
|
||||
public sealed record ResourceDescription(
|
||||
string Path,
|
||||
string Type,
|
||||
string TypeId,
|
||||
uint SessionId,
|
||||
ulong Age,
|
||||
string? ParentTypeId,
|
||||
IReadOnlyDictionary<string, string> Annotations,
|
||||
IReadOnlyList<PropertyDescription> Properties,
|
||||
IReadOnlyList<FunctionDescription> Functions,
|
||||
IReadOnlyList<EventDescription> Events,
|
||||
IReadOnlyList<ConstantDescription> Constants);
|
||||
|
||||
public sealed record PropertyResult(string Resource, string Property, byte Index, object? Value);
|
||||
|
||||
public sealed class ResourceInspectionService
|
||||
{
|
||||
public async Task<IReadOnlyList<ResourceSummary>> QueryAsync(
|
||||
EsiurSession session,
|
||||
string path,
|
||||
int depth,
|
||||
string? typeFilter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (depth < 1) throw new CliException("Query depth must be at least 1.", ExitCodes.InvalidArguments);
|
||||
var output = new List<ResourceSummary>();
|
||||
var queue = new Queue<(string Path, int Level)>();
|
||||
var seen = new HashSet<uint>();
|
||||
queue.Enqueue((NormalizePath(path), 1));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var current = queue.Dequeue();
|
||||
IResource[] children;
|
||||
try
|
||||
{
|
||||
children = await EsiurSessionFactory.AwaitReply(
|
||||
session.Connection.Query(current.Path), cancellationToken) ?? [];
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw MapResourceException(current.Path, exception);
|
||||
}
|
||||
|
||||
foreach (var child in children.OfType<EpResource>())
|
||||
{
|
||||
if (!seen.Add(child.ResourceInstanceId)) continue;
|
||||
var childPath = NormalizePath(child.ResourceLink ?? child.Instance.Link);
|
||||
var definition = child.Instance.Definition;
|
||||
var childCount = 0;
|
||||
try
|
||||
{
|
||||
var descendants = await EsiurSessionFactory.AwaitReply(
|
||||
session.Connection.Query(childPath), cancellationToken);
|
||||
childCount = descendants?.Length ?? 0;
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(typeFilter)
|
||||
|| definition.Name.Contains(typeFilter, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
output.Add(new ResourceSummary(
|
||||
child.Instance.Name,
|
||||
childPath,
|
||||
child.ResourceInstanceId,
|
||||
definition.Name,
|
||||
TypeIdFormatter.Format(definition.Id),
|
||||
childCount));
|
||||
}
|
||||
|
||||
if (current.Level < depth)
|
||||
queue.Enqueue((childPath, current.Level + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public async Task<ResourceDescription> DescribeAsync(
|
||||
EsiurSession session,
|
||||
string path,
|
||||
bool includeValues,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resource = await ResolveAsync(session, path, cancellationToken);
|
||||
var definition = resource.Instance.Definition;
|
||||
return new ResourceDescription(
|
||||
NormalizePath(resource.ResourceLink ?? path),
|
||||
definition.Name,
|
||||
TypeIdFormatter.Format(definition.Id),
|
||||
resource.ResourceInstanceId,
|
||||
resource.Instance.Age,
|
||||
definition.ParentTypeId is ulong parent ? TypeIdFormatter.Format(parent) : null,
|
||||
ToDictionary(definition.Annotations),
|
||||
definition.Properties.OrderBy(x => x.Index).Select(property =>
|
||||
new PropertyDescription(
|
||||
property.Name,
|
||||
property.Index,
|
||||
TypeFormatter.Format(property.ValueType),
|
||||
property.ReadOnly,
|
||||
property.Constant,
|
||||
property.Historical,
|
||||
property.Inherited,
|
||||
includeValues && resource.TryGetPropertyValue(property.Index, out var value) ? value : null,
|
||||
ToDictionary(property.Annotations))).ToArray(),
|
||||
definition.Functions.OrderBy(x => x.Index).Select(function =>
|
||||
new FunctionDescription(
|
||||
function.Name,
|
||||
function.Index,
|
||||
string.Join(", ", (function.Arguments ?? []).Select(argument =>
|
||||
$"{argument.Name}: {TypeFormatter.Format(argument.Type)}{(argument.Optional ? " = optional" : "")}")),
|
||||
TypeFormatter.Format(function.ReturnType),
|
||||
FunctionFlags(function),
|
||||
function.Inherited,
|
||||
ToDictionary(function.Annotations))).ToArray(),
|
||||
definition.Events.OrderBy(x => x.Index).Select(@event =>
|
||||
new EventDescription(
|
||||
@event.Name,
|
||||
@event.Index,
|
||||
TypeFormatter.Format(@event.ArgumentType),
|
||||
@event.Subscribable,
|
||||
@event.Inherited,
|
||||
ToDictionary(@event.Annotations))).ToArray(),
|
||||
definition.Constants.OrderBy(x => x.Index).Select(constant =>
|
||||
new ConstantDescription(
|
||||
constant.Name,
|
||||
constant.Index,
|
||||
TypeFormatter.Format(constant.ValueType),
|
||||
constant.Value,
|
||||
constant.Inherited,
|
||||
ToDictionary(constant.Annotations))).ToArray());
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PropertyResult>> GetAsync(
|
||||
EsiurSession session,
|
||||
string path,
|
||||
IReadOnlyList<string> members,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resource = await ResolveAsync(session, path, cancellationToken);
|
||||
var results = new List<PropertyResult>();
|
||||
foreach (var member in members)
|
||||
{
|
||||
var property = ResolveProperty(resource.Instance.Definition, member)
|
||||
?? throw new CliException(
|
||||
$"Property \"{member}\" was not found on \"{path}\".", ExitCodes.MemberNotFound);
|
||||
if (!resource.TryGetPropertyValue(property.Index, out var value))
|
||||
throw new CliException(
|
||||
$"Property \"{property.Name}\" has no attached value.", ExitCodes.GeneralFailure);
|
||||
results.Add(new PropertyResult(NormalizePath(resource.ResourceLink ?? path), property.Name, property.Index, value));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<EpResource> ResolveAsync(
|
||||
EsiurSession session, string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resource = await EsiurSessionFactory.AwaitReply(
|
||||
session.Connection.Get(NormalizePath(path)), cancellationToken);
|
||||
return resource as EpResource
|
||||
?? throw new CliException($"Resource \"{path}\" was not found.", ExitCodes.ResourceNotFound);
|
||||
}
|
||||
catch (CliException) { throw; }
|
||||
catch (Exception exception) { throw MapResourceException(path, exception); }
|
||||
}
|
||||
|
||||
static PropertyDef? ResolveProperty(TypeDef definition, string member)
|
||||
{
|
||||
if (byte.TryParse(member, out var index)) return definition.GetPropertyDefByIndex(index);
|
||||
return definition.GetPropertyDefByName(member);
|
||||
}
|
||||
|
||||
static CliException MapResourceException(string path, Exception exception)
|
||||
{
|
||||
if (exception.Message.Contains("not found", StringComparison.OrdinalIgnoreCase))
|
||||
return new CliException($"Resource \"{path}\" was not found.", ExitCodes.ResourceNotFound, exception);
|
||||
if (exception.Message.Contains("allow", StringComparison.OrdinalIgnoreCase)
|
||||
|| exception.Message.Contains("permission", StringComparison.OrdinalIgnoreCase))
|
||||
return new CliException($"Access to resource \"{path}\" was denied.", ExitCodes.AccessDenied, exception);
|
||||
return new CliException($"Could not inspect resource \"{path}\": {exception.Message}", ExitCodes.GeneralFailure, exception);
|
||||
}
|
||||
|
||||
public static string NormalizePath(string path) => path.Trim().Trim('/');
|
||||
|
||||
static IReadOnlyDictionary<string, string> ToDictionary(Esiur.Data.Map<string, string>? map) =>
|
||||
map is null ? new Dictionary<string, string>() : map.ToDictionary(x => x.Key, x => x.Value);
|
||||
|
||||
static string FunctionFlags(FunctionDef function) => string.Join(", ", new[]
|
||||
{
|
||||
function.IsStatic ? "static" : null,
|
||||
function.ReadOnly ? "read-only" : null,
|
||||
function.Idempotent ? "idempotent" : null,
|
||||
function.Cancellable ? "cancellable" : null,
|
||||
function.StreamMode != StreamMode.None ? $"stream:{function.StreamMode}" : null,
|
||||
function.Pausable ? "pausable" : null,
|
||||
}.Where(x => x is not null));
|
||||
}
|
||||
Reference in New Issue
Block a user