diff --git a/.gitignore b/.gitignore index 9f3b9ff4..fcfa8504 100644 --- a/.gitignore +++ b/.gitignore @@ -290,3 +290,5 @@ __pycache__/ # ci.global.json is used in CI; local builds are unconstrained global.json + +.DS_Store/ diff --git a/build/Build.Windows.ps1 b/build/Build.Windows.ps1 index bc118297..93ebec75 100644 --- a/build/Build.Windows.ps1 +++ b/build/Build.Windows.ps1 @@ -39,7 +39,7 @@ function Create-ArtifactDir function Publish-Archives($version) { - $rids = $([xml](Get-Content .\src\SeqCli\SeqCli.csproj)).Project.PropertyGroup.RuntimeIdentifiers.Split(';') + $rids = $([xml](Get-Content .\src\SeqCli\SeqCli.csproj)).Project.PropertyGroup.RuntimeIdentifiers[0].Split(';') foreach ($rid in $rids) { $tfm = $framework if ($rid -eq "win-x64") { diff --git a/seqcli.sln b/seqcli.sln index 8f91a065..3d4abbf3 100644 --- a/seqcli.sln +++ b/seqcli.sln @@ -19,7 +19,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{3587B633-0 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "asset", "asset", "{438A0DA5-F3CF-4FCE-B43A-B6DA2981D4AE}" ProjectSection(SolutionItems) = preProject - asset\SeqCliLicense.rtf = asset\SeqCliLicense.rtf asset\SeqCli.ico = asset\SeqCli.ico asset\SeqCli.png = asset\SeqCli.png EndProjectSection diff --git a/seqcli.sln.DotSettings b/seqcli.sln.DotSettings index 6c5c917e..95d327ba 100644 --- a/seqcli.sln.DotSettings +++ b/seqcli.sln.DotSettings @@ -1,4 +1,6 @@  + IO + IP MS True True @@ -9,6 +11,7 @@ True True True + True True True True @@ -16,6 +19,7 @@ True True True + True True True True @@ -32,4 +36,5 @@ True True True + True True \ No newline at end of file diff --git a/src/Roastery/Fake/Person.cs b/src/Roastery/Fake/Person.cs index 53394561..56c0ae0c 100644 --- a/src/Roastery/Fake/Person.cs +++ b/src/Roastery/Fake/Person.cs @@ -14,7 +14,7 @@ public Person(string? name, string? address) } static readonly string[] Forenames = - { + [ "Akeem", "Alice", "Alok", @@ -40,10 +40,10 @@ public Person(string? name, string? address) "Yoshi", "Zach", "Zeynep" - }; + ]; static readonly string[] Surnames = - { + [ "Anderson", "Alvarez", "Brookes", @@ -60,10 +60,10 @@ public Person(string? name, string? address) "Smith", "Xia", "Zheng" - }; + ]; static readonly string[] Streets = - { + [ "Lilac Road", "Lilly Street", "Carnation Street", @@ -78,7 +78,7 @@ public Person(string? name, string? address) "Trillium Creek Parkway", "Grevillea Street", "Kurrajong Street" - }; + ]; public static Person Generate() { diff --git a/src/Roastery/Web/FaultInjectionMiddleware.cs b/src/Roastery/Web/FaultInjectionMiddleware.cs index 27dae6d8..0c6f472e 100644 --- a/src/Roastery/Web/FaultInjectionMiddleware.cs +++ b/src/Roastery/Web/FaultInjectionMiddleware.cs @@ -18,15 +18,15 @@ public FaultInjectionMiddleware(ILogger logger, HttpServer next) { _logger = logger.ForContext(); _next = next; - _faults = new Func>[] - { + _faults = + [ Unauthorized, Unauthorized, Unauthorized, Timeout, Timeout, Disposed - }; + ]; } Task Unauthorized(HttpRequest request) diff --git a/src/Roastery/Web/Router.cs b/src/Roastery/Web/Router.cs index 5df8d7ed..0c9f7be9 100644 --- a/src/Roastery/Web/Router.cs +++ b/src/Roastery/Web/Router.cs @@ -63,7 +63,7 @@ public Router(IEnumerable controllers, ILogger logger) { using var _ = LogContext.PushProperty("Controller", controllerName); using var __ = LogContext.PushProperty("Action", actionName); - return (Task) method.Invoke(controller, new object[] {r})!; + return (Task) method.Invoke(controller, [r])!; }); _logger.Debug("Binding route HTTP {HttpMethod} {RouteTemplate} to action method {Controller}.{Action}()", diff --git a/src/SeqCli/Apps/Definitions/AppMetadataReader.cs b/src/SeqCli/Apps/Definitions/AppMetadataReader.cs index 8abd7be0..58269661 100644 --- a/src/SeqCli/Apps/Definitions/AppMetadataReader.cs +++ b/src/SeqCli/Apps/Definitions/AppMetadataReader.cs @@ -79,21 +79,15 @@ static Dictionary GetAvailableSettings(Type mainRe }); } - static readonly HashSet IntegerTypes = new() - { + static readonly HashSet IntegerTypes = + [ typeof(short), typeof(ushort), typeof(int), typeof(uint), typeof(long), typeof(ulong) - }; + ]; - static readonly HashSet DecimalTypes = new() - { - typeof(float), typeof(double), typeof(decimal) - }; + static readonly HashSet DecimalTypes = [typeof(float), typeof(double), typeof(decimal)]; - static readonly HashSet BooleanTypes = new() - { - typeof(bool) - }; + static readonly HashSet BooleanTypes = [typeof(bool)]; internal static AppSettingType GetSettingType(Type type) { diff --git a/src/SeqCli/Apps/Hosting/AppContainer.cs b/src/SeqCli/Apps/Hosting/AppContainer.cs index fe23a843..72184492 100644 --- a/src/SeqCli/Apps/Hosting/AppContainer.cs +++ b/src/SeqCli/Apps/Hosting/AppContainer.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Cli/CommandAttribute.cs b/src/SeqCli/Cli/CommandAttribute.cs index 775e03a5..a41f5422 100644 --- a/src/SeqCli/Cli/CommandAttribute.cs +++ b/src/SeqCli/Cli/CommandAttribute.cs @@ -22,8 +22,8 @@ public class CommandAttribute : Attribute, ICommandMetadata public string Name { get; } public string? SubCommand { get; } public string HelpText { get; } - public string? Example { get; set; } + public bool IsPreview { get; set; } public CommandAttribute(string name, string helpText) { diff --git a/src/SeqCli/Cli/CommandLineHost.cs b/src/SeqCli/Cli/CommandLineHost.cs index e5c686fc..27d46fc8 100644 --- a/src/SeqCli/Cli/CommandLineHost.cs +++ b/src/SeqCli/Cli/CommandLineHost.cs @@ -39,30 +39,37 @@ public async Task Run(string[] args, LoggingLevelSwitch levelSwitch) if (args.Length > 0) { + const string prereleaseArg = "--pre", verboseArg = "--verbose"; + var norm = args[0].ToLowerInvariant(); var subCommandNorm = args.Length > 1 && !args[1].Contains('-') ? args[1].ToLowerInvariant() : null; - + + var pre = args.Any(a => a == prereleaseArg); + var cmd = _availableCommands.SingleOrDefault(c => - c.Metadata.Name == norm && (c.Metadata.SubCommand == subCommandNorm || c.Metadata.SubCommand == null)); + (!c.Metadata.IsPreview || pre) && + c.Metadata.Name == norm && + (c.Metadata.SubCommand == subCommandNorm || c.Metadata.SubCommand == null)); if (cmd != null) { var amountToSkip = cmd.Metadata.SubCommand == null ? 1 : 2; - var commandSpecificArgs = args.Skip(amountToSkip).ToArray(); + var commandSpecificArgs = args.Skip(amountToSkip).Where(arg => cmd.Metadata.Name == "help" || arg != prereleaseArg).ToArray(); - var verboseArg = commandSpecificArgs.FirstOrDefault(arg => arg == "--verbose"); - if (verboseArg != null) + var verbose = commandSpecificArgs.Any(arg => arg == verboseArg); + if (verbose) { levelSwitch.MinimumLevel = LogEventLevel.Information; commandSpecificArgs = commandSpecificArgs.Where(arg => arg != verboseArg).ToArray(); } - return await cmd.Value.Value.Invoke(commandSpecificArgs); + var impl = cmd.Value.Value; + return await impl.Invoke(commandSpecificArgs); } } Console.WriteLine($"Usage: {name} []"); Console.WriteLine($"Type `{name} help` for available commands"); - return -1; + return 1; } } \ No newline at end of file diff --git a/src/SeqCli/Cli/CommandMetadata.cs b/src/SeqCli/Cli/CommandMetadata.cs index 0feed60e..997c450a 100644 --- a/src/SeqCli/Cli/CommandMetadata.cs +++ b/src/SeqCli/Cli/CommandMetadata.cs @@ -20,4 +20,5 @@ public class CommandMetadata : ICommandMetadata public string? SubCommand { get; set; } public required string HelpText { get; set; } public string? Example { get; set; } -} \ No newline at end of file + public bool IsPreview { get; set; } +} diff --git a/src/SeqCli/Cli/Commands/ApiKey/CreateCommand.cs b/src/SeqCli/Cli/Commands/ApiKey/CreateCommand.cs index 68be490e..514daa0c 100644 --- a/src/SeqCli/Cli/Commands/ApiKey/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/ApiKey/CreateCommand.cs @@ -32,20 +32,17 @@ namespace SeqCli.Cli.Commands.ApiKey; Example = "seqcli apikey create -t 'Test API Key' -p Environment=Test")] class CreateCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly PropertiesFeature _properties; readonly OutputFormatFeature _output; + readonly StoragePathFeature _storagePath; string? _title, _token, _filter, _level, _connectUsername, _connectPassword; string[]? _permissions; bool _useServerTimestamps, _connectPasswordStdin; - public CreateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public CreateCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "t=|title=", "A title for the API key", @@ -94,12 +91,15 @@ public CreateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config _ => _connectPasswordStdin = true); _connection = Enable(); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); } protected override async Task Run() { - var connection = await TryConnectAsync(); + var config = RuntimeConfigurationLoader.Load(_storagePath); + + var connection = await TryConnectAsync(config); if (connection == null) return 1; @@ -149,19 +149,21 @@ protected override async Task Run() apiKey = await connection.ApiKeys.AddAsync(apiKey); - if (_token == null && !_output.Json) + var output = _output.GetOutputFormat(config); + + if (_token == null && !output.Json) { Console.WriteLine(apiKey.Token); } else { - _output.WriteEntity(apiKey); + output.WriteEntity(apiKey); } return 0; } - async Task TryConnectAsync() + async Task TryConnectAsync(SeqCliConfig config) { SeqConnection connection; if (_connectUsername != null) @@ -183,13 +185,13 @@ protected override async Task Run() _connectPassword = await Console.In.ReadLineAsync(); } - var (url, _) = _connectionFactory.GetConnectionDetails(_connection); + var (url, _) = SeqConnectionFactory.GetConnectionDetails(_connection, config); connection = new SeqConnection(url); await connection.Users.LoginAsync(_connectUsername, _connectPassword ?? ""); } else { - connection = _connectionFactory.Connect(_connection); + connection = SeqConnectionFactory.Connect(_connection, config); } return connection; diff --git a/src/SeqCli/Cli/Commands/ApiKey/ListCommand.cs b/src/SeqCli/Cli/Commands/ApiKey/ListCommand.cs index 5f07cb28..9a7d387a 100644 --- a/src/SeqCli/Cli/Commands/ApiKey/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/ApiKey/ListCommand.cs @@ -24,32 +24,30 @@ namespace SeqCli.Cli.Commands.ApiKey; [Command("apikey", "list", "List available API keys", Example="seqcli apikey list")] class ListCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly EntityIdentityFeature _entityIdentity; readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; + readonly StoragePathFeature _storagePath; - public ListCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public ListCommand() { - if (config == null) throw new ArgumentNullException(nameof(config)); - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - _entityIdentity = Enable(new EntityIdentityFeature("API key", "list")); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); _connection = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var list = _entityIdentity.Id != null ? new[] { await connection.ApiKeys.FindAsync(_entityIdentity.Id) } : (await connection.ApiKeys.ListAsync()) .Where(ak => _entityIdentity.Title == null || _entityIdentity.Title == ak.Title); - _output.ListEntities(list); + _output.GetOutputFormat(config).ListEntities(list); return 0; } diff --git a/src/SeqCli/Cli/Commands/ApiKey/RemoveCommand.cs b/src/SeqCli/Cli/Commands/ApiKey/RemoveCommand.cs index 2cac207d..d809ec7e 100644 --- a/src/SeqCli/Cli/Commands/ApiKey/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/ApiKey/RemoveCommand.cs @@ -16,6 +16,7 @@ using System.Linq; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using Serilog; @@ -25,17 +26,15 @@ namespace SeqCli.Cli.Commands.ApiKey; Example="seqcli apikey remove -t 'Test API Key'")] class RemoveCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly EntityIdentityFeature _entityIdentity; readonly ConnectionFeature _connection; + readonly StoragePathFeature _storagePath; - public RemoveCommand(SeqConnectionFactory connectionFactory) + public RemoveCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - _entityIdentity = Enable(new EntityIdentityFeature("API key", "remove")); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() @@ -46,10 +45,11 @@ protected override async Task Run() return 1; } - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var toRemove = _entityIdentity.Id != null ? - new[] {await connection.ApiKeys.FindAsync(_entityIdentity.Id)} : + var toRemove = _entityIdentity.Id != null ? [await connection.ApiKeys.FindAsync(_entityIdentity.Id)] + : (await connection.ApiKeys.ListAsync()) .Where(ak => _entityIdentity.Title == ak.Title) .ToArray(); diff --git a/src/SeqCli/Cli/Commands/ApiKey/UpdateCommand.cs b/src/SeqCli/Cli/Commands/ApiKey/UpdateCommand.cs index b8ecff35..46a77309 100644 --- a/src/SeqCli/Cli/Commands/ApiKey/UpdateCommand.cs +++ b/src/SeqCli/Cli/Commands/ApiKey/UpdateCommand.cs @@ -20,6 +20,6 @@ namespace SeqCli.Cli.Commands.ApiKey; [Command("apikey", "update", "Update an existing API key", Example="seqcli apikey update --json '{...}'")] -class UpdateCommand(SeqConnectionFactory connectionFactory): - Shared.UpdateCommand(connectionFactory, "apikey", nameof(SeqConnection.ApiKeys), "API key"); +class UpdateCommand(): + Shared.UpdateCommand("apikey", nameof(SeqConnection.ApiKeys), "API key"); \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/App/DefineCommand.cs b/src/SeqCli/Cli/Commands/App/DefineCommand.cs index 16214070..56909bbb 100644 --- a/src/SeqCli/Cli/Commands/App/DefineCommand.cs +++ b/src/SeqCli/Cli/Commands/App/DefineCommand.cs @@ -16,6 +16,7 @@ using System.Threading.Tasks; using SeqCli.Apps; using SeqCli.Apps.Definitions; +using SeqCli.Cli.Features; using SeqCli.Util; namespace SeqCli.Cli.Commands.App; diff --git a/src/SeqCli/Cli/Commands/App/InstallCommand.cs b/src/SeqCli/Cli/Commands/App/InstallCommand.cs index 94d98482..4d284b29 100644 --- a/src/SeqCli/Cli/Commands/App/InstallCommand.cs +++ b/src/SeqCli/Cli/Commands/App/InstallCommand.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -29,17 +29,14 @@ namespace SeqCli.Cli.Commands.App; // ReSharper disable once UnusedType.Global class InstallCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; - + readonly StoragePathFeature _storagePath; + string? _packageId, _version, _feedId; - public InstallCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public InstallCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "package-id=", "The package id of the app to install", @@ -56,7 +53,8 @@ public InstallCommand(SeqConnectionFactory connectionFactory, SeqCliConfig confi feedId => _feedId = ArgumentString.Normalize(feedId)); _connection = Enable(); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); } protected override async Task Run() @@ -67,7 +65,8 @@ protected override async Task Run() return 1; } - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var feedId = _feedId; if (feedId == null) @@ -83,7 +82,7 @@ protected override async Task Run() } var app = await connection.Apps.InstallPackageAsync(feedId, _packageId, _version); - _output.WriteEntity(app); + _output.GetOutputFormat(config).WriteEntity(app); return 0; } diff --git a/src/SeqCli/Cli/Commands/App/ListCommand.cs b/src/SeqCli/Cli/Commands/App/ListCommand.cs index 3bd47736..166f3bc5 100644 --- a/src/SeqCli/Cli/Commands/App/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/App/ListCommand.cs @@ -10,20 +10,16 @@ namespace SeqCli.Cli.Commands.App; [Command("app", "list", "List installed app packages", Example="seqcli app list")] class ListCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - string? _title, _id; readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; - + readonly StoragePathFeature _storagePath; + string? PackageId => string.IsNullOrWhiteSpace(_title) ? null : _title.Trim(); string? Id => string.IsNullOrWhiteSpace(_id) ? null : _id.Trim(); - public ListCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public ListCommand() { - if (config == null) throw new ArgumentNullException(nameof(config)); - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "package-id=", "The package id of the app(s) to list", @@ -34,7 +30,8 @@ public ListCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) "The id of a single app to list", t => _id = t); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); _connection = Enable(); } @@ -42,18 +39,19 @@ protected override async Task Run() { if (PackageId != null && Id != null) { - ShowUsageErrors(new[] {"Only one of either `package-id` or `id` can be specified"}); + ShowUsageErrors(["Only one of either `package-id` or `id` can be specified"]); return 1; } - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var list = Id != null ? - new[] { await connection.Apps.FindAsync(Id) } : + var list = Id != null ? [await connection.Apps.FindAsync(Id)] + : (await connection.Apps.ListAsync()) .Where(ak => PackageId == null || PackageId == ak.Package.PackageId); - _output.ListEntities(list); + _output.GetOutputFormat(config).ListEntities(list); return 0; } diff --git a/src/SeqCli/Cli/Commands/App/RunCommand.cs b/src/SeqCli/Cli/Commands/App/RunCommand.cs index a680fd67..a3590f46 100644 --- a/src/SeqCli/Cli/Commands/App/RunCommand.cs +++ b/src/SeqCli/Cli/Commands/App/RunCommand.cs @@ -16,6 +16,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using SeqCli.Apps.Hosting; +using SeqCli.Cli.Features; using SeqCli.Config; using SeqCli.Util; @@ -39,10 +40,10 @@ class RunCommand : Command readonly Dictionary _settings = new(); - public RunCommand(SeqCliConfig config) + public RunCommand() { - if (config == null) throw new ArgumentNullException(nameof(config)); - _serverUrl = config.Connection.ServerUrl; + // The usual `--storage` argument is not supported on this command (see notes on `--storage` arg below). + _serverUrl = RuntimeConfigurationLoader.Load(new StoragePathFeature()).Connection.ServerUrl; Options.Add( "d=|directory=", @@ -64,6 +65,8 @@ public RunCommand(SeqCliConfig config) _settings.Add(name, valueText ?? ""); }); + // Important note, this conflicts with the `--storage` argument accepted by the majority of other commands; changing + // this requires an update to Seq, which uses this command for hosting .NET apps. Options.Add( "storage=", "A directory in which app-specific data can be stored; defaults to the current directory", diff --git a/src/SeqCli/Cli/Commands/App/UninstallCommand.cs b/src/SeqCli/Cli/Commands/App/UninstallCommand.cs index c9d275a9..03f40b02 100644 --- a/src/SeqCli/Cli/Commands/App/UninstallCommand.cs +++ b/src/SeqCli/Cli/Commands/App/UninstallCommand.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using SeqCli.Util; using Serilog; @@ -13,15 +14,12 @@ namespace SeqCli.Cli.Commands.App; // ReSharper disable once UnusedType.Global class UninstallCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - string? _packageId, _id; readonly ConnectionFeature _connection; - - public UninstallCommand(SeqConnectionFactory connectionFactory) + readonly StoragePathFeature _storagePath; + + public UninstallCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "package-id=", "The package id of the app package to uninstall", @@ -33,6 +31,7 @@ public UninstallCommand(SeqConnectionFactory connectionFactory) t => _id = ArgumentString.Normalize(t)); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() @@ -43,7 +42,8 @@ protected override async Task Run() return 1; } - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var toRemove = _id != null ? [await connection.Apps.FindAsync(_id)] : (await connection.Apps.ListAsync()) diff --git a/src/SeqCli/Cli/Commands/App/UpdateCommand.cs b/src/SeqCli/Cli/Commands/App/UpdateCommand.cs index bf7fb40f..4c30d62b 100644 --- a/src/SeqCli/Cli/Commands/App/UpdateCommand.cs +++ b/src/SeqCli/Cli/Commands/App/UpdateCommand.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -28,18 +28,15 @@ namespace SeqCli.Cli.Commands.App; // ReSharper disable once UnusedType.Global class UpdateCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; - + readonly StoragePathFeature _storagePath; + string? _id, _name, _version; bool _all, _force; - public UpdateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public UpdateCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "i=|id=", "The id of a single installed app to update", @@ -67,7 +64,8 @@ public UpdateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config _ => _force = true); _connection = Enable(); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); } protected override async Task Run() @@ -90,8 +88,10 @@ protected override async Task Run() Log.Error("One of `id`, `name`, or `all` must be specified"); return 1; } - - var connection = _connectionFactory.Connect(_connection); + + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); + var output = _output.GetOutputFormat(config); var apps = await connection.Apps.ListAsync(); foreach (var app in apps) @@ -99,7 +99,7 @@ protected override async Task Run() if (_all || app.Id == _id || _name != null && _name.Equals(app.Name, StringComparison.OrdinalIgnoreCase)) { var updated = await connection.Apps.UpdatePackageAsync(app, _version, _force); - _output.WriteEntity(updated); + output.WriteEntity(updated); } } diff --git a/src/SeqCli/Cli/Commands/AppInstance/CreateCommand.cs b/src/SeqCli/Cli/Commands/AppInstance/CreateCommand.cs index 5b71c2cc..af5aa731 100644 --- a/src/SeqCli/Cli/Commands/AppInstance/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/AppInstance/CreateCommand.cs @@ -16,20 +16,17 @@ namespace SeqCli.Cli.Commands.AppInstance; Example = "seqcli appinstance create -t 'Email Ops' --app hostedapp-314159 -p To=ops@example.com")] class CreateCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; - + readonly StoragePathFeature _storagePath; + string? _title, _appId, _streamIncomingEventsSignal; readonly Dictionary _settings = new(); readonly List _overridable = new(); bool _streamIncomingEvents; - public CreateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public CreateCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "t=|title=", "A title for the app instance", @@ -70,12 +67,14 @@ public CreateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config s => _overridable.Add(s)); _connection = Enable(); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); AppInstanceEntity instance = await connection.AppInstances.TemplateAsync(_appId)!; @@ -112,7 +111,7 @@ bool ValidateSettingName(string settingName) instance = await connection.AppInstances.AddAsync(instance); - _output.WriteEntity(instance); + _output.GetOutputFormat(config).WriteEntity(instance); return 0; } diff --git a/src/SeqCli/Cli/Commands/AppInstance/ListCommand.cs b/src/SeqCli/Cli/Commands/AppInstance/ListCommand.cs index b9f9335e..2e9e2c45 100644 --- a/src/SeqCli/Cli/Commands/AppInstance/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/AppInstance/ListCommand.cs @@ -10,32 +10,30 @@ namespace SeqCli.Cli.Commands.AppInstance; [Command("appinstance", "list", "List instances of installed apps", Example="seqcli appinstance list")] class ListCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly EntityIdentityFeature _entityIdentity; readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; - - public ListCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + readonly StoragePathFeature _storagePath; + + public ListCommand() { - if (config == null) throw new ArgumentNullException(nameof(config)); - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - _entityIdentity = Enable(new EntityIdentityFeature("app instance", "list")); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); _connection = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var list = _entityIdentity.Id != null ? - new[] { await connection.AppInstances.FindAsync(_entityIdentity.Id) } : + var list = _entityIdentity.Id != null ? [await connection.AppInstances.FindAsync(_entityIdentity.Id)] + : (await connection.AppInstances.ListAsync()) .Where(d => _entityIdentity.Title == null || _entityIdentity.Title == d.Title); - _output.ListEntities(list); + _output.GetOutputFormat(config).ListEntities(list); return 0; } diff --git a/src/SeqCli/Cli/Commands/AppInstance/RemoveCommand.cs b/src/SeqCli/Cli/Commands/AppInstance/RemoveCommand.cs index 76e90915..33da86b2 100644 --- a/src/SeqCli/Cli/Commands/AppInstance/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/AppInstance/RemoveCommand.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using Serilog; @@ -11,17 +12,15 @@ namespace SeqCli.Cli.Commands.AppInstance; class RemoveCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly EntityIdentityFeature _entityIdentity; readonly ConnectionFeature _connection; - - public RemoveCommand(SeqConnectionFactory connectionFactory) + readonly StoragePathFeature _storagePath; + + public RemoveCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - _entityIdentity = Enable(new EntityIdentityFeature("app instance", "remove")); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() @@ -32,10 +31,11 @@ protected override async Task Run() return 1; } - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var toRemove = _entityIdentity.Id != null ? - new[] {await connection.AppInstances.FindAsync(_entityIdentity.Id)} : + var toRemove = _entityIdentity.Id != null ? [await connection.AppInstances.FindAsync(_entityIdentity.Id)] + : (await connection.AppInstances.ListAsync()) .Where(ak => _entityIdentity.Title == ak.Title) .ToArray(); diff --git a/src/SeqCli/Cli/Commands/AppInstance/UpdateCommand.cs b/src/SeqCli/Cli/Commands/AppInstance/UpdateCommand.cs index f46d7760..22e44af1 100644 --- a/src/SeqCli/Cli/Commands/AppInstance/UpdateCommand.cs +++ b/src/SeqCli/Cli/Commands/AppInstance/UpdateCommand.cs @@ -20,6 +20,6 @@ namespace SeqCli.Cli.Commands.AppInstance; [Command("appinstance", "update", "Update an existing app instance", Example="seqcli appinstance update --json '{...}'")] -class UpdateCommand(SeqConnectionFactory connectionFactory): - Shared.UpdateCommand(connectionFactory, "appinstance", nameof(SeqConnection.AppInstances), "app instance"); +class UpdateCommand(): + Shared.UpdateCommand("appinstance", nameof(SeqConnection.AppInstances), "app instance"); \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Bench/BenchCasesCollection.cs b/src/SeqCli/Cli/Commands/Bench/BenchCasesCollection.cs index 441b6ae0..a47c8206 100644 --- a/src/SeqCli/Cli/Commands/Bench/BenchCasesCollection.cs +++ b/src/SeqCli/Cli/Commands/Bench/BenchCasesCollection.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Cli/Commands/Bench/BenchCommand.cs b/src/SeqCli/Cli/Commands/Bench/BenchCommand.cs index 606ac7a2..596bbbe4 100644 --- a/src/SeqCli/Cli/Commands/Bench/BenchCommand.cs +++ b/src/SeqCli/Cli/Commands/Bench/BenchCommand.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ using Seq.Api.Model.Data; using Seq.Api.Model.Signals; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using SeqCli.Sample.Loader; using SeqCli.Util; @@ -65,11 +66,11 @@ namespace SeqCli.Cli.Commands.Bench; [Command("bench", @"Measure query performance")] class BenchCommand : Command { - readonly SeqConnectionFactory _connectionFactory; int _runs = 10; readonly ConnectionFeature _connection; readonly DateRangeFeature _range; readonly TimeoutFeature _timeout; + readonly StoragePathFeature _storagePath; string _cases = ""; string _reportingServerUrl = ""; string _reportingServerApiKey = ""; @@ -77,9 +78,8 @@ class BenchCommand : Command bool _withIngestion = false; bool _withQueries = false; - public BenchCommand(SeqConnectionFactory connectionFactory) + public BenchCommand() { - _connectionFactory = connectionFactory; Options.Add("r|runs=", "The number of runs to execute; the default is 10", r => _runs = int.Parse(r)); Options.Add( @@ -111,6 +111,8 @@ public BenchCommand(SeqConnectionFactory connectionFactory) "with-queries", "Should the benchmark include querying Seq", _ => _withQueries = true); + + _storagePath = Enable(); } protected override async Task Run() @@ -123,8 +125,9 @@ protected override async Task Run() try { - var (_, apiKey) = _connectionFactory.GetConnectionDetails(_connection); - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var (_, apiKey) = SeqConnectionFactory.GetConnectionDetails(_connection, config); + var connection = SeqConnectionFactory.Connect(_connection, config); var timeout = _timeout.ApplyTimeout(connection.Client.HttpClient); var seqVersion = (await connection.Client.GetRootAsync()).Version; await using var reportingLogger = BuildReportingLogger(); @@ -159,9 +162,9 @@ protected override async Task Run() if (!_withQueries) { - int benchDurationMs = 120_000; - await Task.Delay(benchDurationMs); - cancellationTokenSource.Cancel(); + const int benchDurationMs = 120_000; + await Task.Delay(benchDurationMs, cancellationToken); + await cancellationTokenSource.CancelAsync(); var response = await connection.Data.QueryAsync( "select count(*) from stream group by time(1s)", @@ -199,7 +202,7 @@ protected override async Task Run() { var collectedTimings = await QueryBenchmark(reportingLogger, runId, connection, seqVersion, timeout); collectedTimings.LogSummary(_description); - cancellationTokenSource.Cancel(); + await cancellationTokenSource.CancelAsync(); } } @@ -212,7 +215,7 @@ protected override async Task Run() } } - async Task IngestionBenchmark(Logger reportingLogger, string runId, SeqConnection connection, string? apiKey, + static async Task IngestionBenchmark(Logger reportingLogger, string runId, SeqConnection connection, string? apiKey, string seqVersion, bool isQueryBench, CancellationToken cancellationToken = default) { reportingLogger.Information( @@ -224,7 +227,7 @@ async Task IngestionBenchmark(Logger reportingLogger, string runId, SeqConnectio var simulationTasks = Enumerable.Range(1, 500) .Select(i => Simulation.RunAsync(connection, apiKey, 10000, echoToStdout: false, cancellationToken)) .ToArray(); - await Task.Delay(20_000); // how long to ingest before beginning queries + await Task.Delay(20_000, cancellationToken); // how long to ingest before beginning queries } else { @@ -245,7 +248,7 @@ async Task QueryBenchmark(Logger reportingLogger, string r foreach (var c in cases.Cases.OrderBy(c => c.Id) - .Concat(new [] { QueryBenchRunResults.FINAL_COUNT_CASE })) + .Concat([QueryBenchRunResults.FINAL_COUNT_CASE])) { var timings = new QueryBenchCaseTimings(c); queryBenchRunResults.Add(timings); diff --git a/src/SeqCli/Cli/Commands/Bench/QueryBenchCase.cs b/src/SeqCli/Cli/Commands/Bench/QueryBenchCase.cs index ce31c26e..55bee6d2 100644 --- a/src/SeqCli/Cli/Commands/Bench/QueryBenchCase.cs +++ b/src/SeqCli/Cli/Commands/Bench/QueryBenchCase.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Cli/Commands/Bench/QueryBenchCaseTimings.cs b/src/SeqCli/Cli/Commands/Bench/QueryBenchCaseTimings.cs index a3bc8834..be8cd5ec 100644 --- a/src/SeqCli/Cli/Commands/Bench/QueryBenchCaseTimings.cs +++ b/src/SeqCli/Cli/Commands/Bench/QueryBenchCaseTimings.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Cli/Commands/Cluster/HealthCommand.cs b/src/SeqCli/Cli/Commands/Cluster/HealthCommand.cs index c2888415..a868cb52 100644 --- a/src/SeqCli/Cli/Commands/Cluster/HealthCommand.cs +++ b/src/SeqCli/Cli/Commands/Cluster/HealthCommand.cs @@ -31,38 +31,37 @@ namespace SeqCli.Cli.Commands.Cluster; Example = "seqcli cluster health -s https://seq.example.com --wait-until-healthy")] class HealthCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; readonly TimeoutFeature _timeout; readonly WaitUntilHealthyFeature _waitUntilHealthy; + readonly StoragePathFeature _storagePath; - public HealthCommand(SeqConnectionFactory connectionFactory, SeqCliOutputConfig outputConfig) + public HealthCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - _waitUntilHealthy = Enable(new WaitUntilHealthyFeature("cluster")); _timeout = Enable(new TimeoutFeature()); - _output = Enable(new OutputFormatFeature(outputConfig)); + _output = Enable(); + _storagePath = Enable(); _connection = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var timeout = _timeout.ApplyTimeout(connection.Client.HttpClient); if (_waitUntilHealthy.ShouldWait) { - return await RunUntilHealthy(connection, timeout ?? TimeSpan.FromSeconds(30)); + return await RunUntilHealthy(connection, timeout ?? TimeSpan.FromSeconds(30), _output.GetOutputFormat(config)); } - return await RunOnce(connection); + return await RunOnce(connection, _output.GetOutputFormat(config)); } - async Task RunUntilHealthy(SeqConnection connection, TimeSpan timeout) + async Task RunUntilHealthy(SeqConnection connection, TimeSpan timeout, OutputFormat outputFormat) { using var ct = new CancellationTokenSource(timeout); @@ -78,7 +77,7 @@ async Task RunUntilHealthy(SeqConnection connection, TimeSpan timeout) { try { - if (await RunOnce(connection) == 0) + if (await RunOnce(connection, outputFormat) == 0) { return 0; } @@ -98,13 +97,13 @@ async Task RunUntilHealthy(SeqConnection connection, TimeSpan timeout) } } - async Task RunOnce(SeqConnection connection) + static async Task RunOnce(SeqConnection connection, OutputFormat output) { var health = await connection.Cluster.CheckHealthAsync(); - if (_output.Json) + if (output.Json) { - _output.WriteObject(health); + output.WriteObject(health); } else if (!string.IsNullOrWhiteSpace(health.Description)) { Console.WriteLine($"{health.Status}: {health.Description}"); } else { diff --git a/src/SeqCli/Cli/Commands/ConfigCommand.cs b/src/SeqCli/Cli/Commands/ConfigCommand.cs index c4b1142b..08249429 100644 --- a/src/SeqCli/Cli/Commands/ConfigCommand.cs +++ b/src/SeqCli/Cli/Commands/ConfigCommand.cs @@ -13,12 +13,8 @@ // limitations under the License. using System; -using System.Collections; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Reflection; using System.Threading.Tasks; +using SeqCli.Cli.Features; using SeqCli.Config; using SeqCli.Util; using Serilog; @@ -30,12 +26,14 @@ class ConfigCommand : Command { string? _key, _value; bool _clear; + readonly StoragePathFeature _storagePath; public ConfigCommand() { Options.Add("k|key=", "The field, for example `connection.serverUrl`", k => _key = k); Options.Add("v|value=", "The field value; if not specified, the command will print the current value", v => _value = v); Options.Add("c|clear", "Clear the field", _ => _clear = true); + _storagePath = Enable(); } protected override Task Run() @@ -44,7 +42,7 @@ protected override Task Run() try { - var config = SeqCliConfig.ReadFromFile(RuntimeConfigurationLoader.DefaultConfigFilename); + var config = SeqCliConfig.ReadFromFile(_storagePath.ConfigFilePath); if (_key != null) { @@ -52,13 +50,13 @@ protected override Task Run() { verb = "clear"; KeyValueSettings.Clear(config, _key); - SeqCliConfig.WriteToFile(config, RuntimeConfigurationLoader.DefaultConfigFilename); + SeqCliConfig.WriteToFile(config, _storagePath.ConfigFilePath); } else if (_value != null) { verb = "update"; KeyValueSettings.Set(config, _key, _value); - SeqCliConfig.WriteToFile(config, RuntimeConfigurationLoader.DefaultConfigFilename); + SeqCliConfig.WriteToFile(config, _storagePath.ConfigFilePath); } else { diff --git a/src/SeqCli/Cli/Commands/Dashboard/ListCommand.cs b/src/SeqCli/Cli/Commands/Dashboard/ListCommand.cs index 1325511c..087f9fc4 100644 --- a/src/SeqCli/Cli/Commands/Dashboard/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Dashboard/ListCommand.cs @@ -24,34 +24,32 @@ namespace SeqCli.Cli.Commands.Dashboard; [Command("dashboard", "list", "List dashboards", Example="seqcli dashboard list")] class ListCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly EntityIdentityFeature _entityIdentity; readonly EntityOwnerFeature _entityOwner; readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; - - public ListCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + readonly StoragePathFeature _storagePath; + + public ListCommand() { - if (config == null) throw new ArgumentNullException(nameof(config)); - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - _entityIdentity = Enable(new EntityIdentityFeature("dashboard", "list")); _entityOwner = Enable(new EntityOwnerFeature("dashboard", "list", "listed", _entityIdentity)); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); _connection = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var list = _entityIdentity.Id != null ? - new[] { await connection.Dashboards.FindAsync(_entityIdentity.Id) } : + var list = _entityIdentity.Id != null ? [await connection.Dashboards.FindAsync(_entityIdentity.Id)] + : (await connection.Dashboards.ListAsync(ownerId: _entityOwner.OwnerId, shared: _entityOwner.IncludeShared)) .Where(d => _entityIdentity.Title == null || _entityIdentity.Title == d.Title); - _output.ListEntities(list); + _output.GetOutputFormat(config).ListEntities(list); return 0; } diff --git a/src/SeqCli/Cli/Commands/Dashboard/RemoveCommand.cs b/src/SeqCli/Cli/Commands/Dashboard/RemoveCommand.cs index b8de3709..61022b26 100644 --- a/src/SeqCli/Cli/Commands/Dashboard/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/Dashboard/RemoveCommand.cs @@ -16,6 +16,7 @@ using System.Linq; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using Serilog; @@ -25,19 +26,17 @@ namespace SeqCli.Cli.Commands.Dashboard; Example="seqcli dashboard remove -i dashboard-159")] class RemoveCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly EntityIdentityFeature _entityIdentity; readonly EntityOwnerFeature _entityOwner; readonly ConnectionFeature _connection; - - public RemoveCommand(SeqConnectionFactory connectionFactory) + readonly StoragePathFeature _storagePath; + + public RemoveCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - _entityIdentity = Enable(new EntityIdentityFeature("dashboard", "remove")); _entityOwner = Enable(new EntityOwnerFeature("dashboard", "remove", "removed", _entityIdentity)); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() @@ -48,10 +47,11 @@ protected override async Task Run() return 1; } - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var toRemove = _entityIdentity.Id != null ? - new[] { await connection.Dashboards.FindAsync(_entityIdentity.Id) } : + var toRemove = _entityIdentity.Id != null ? [await connection.Dashboards.FindAsync(_entityIdentity.Id)] + : (await connection.Dashboards.ListAsync(ownerId: _entityOwner.OwnerId, shared: _entityOwner.IncludeShared)) .Where(dashboard => _entityIdentity.Title == dashboard.Title) .ToArray(); diff --git a/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs b/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs index 83c4150d..652066a0 100644 --- a/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs +++ b/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs @@ -33,21 +33,17 @@ class RenderCommand : Command { const int MaximumReturnedHitRows = 10000; - readonly SeqConnectionFactory _connectionFactory; - readonly DateRangeFeature _range; readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; readonly SignalExpressionFeature _signal; readonly TimeoutFeature _timeout; - + readonly StoragePathFeature _storagePath; + string? _id, _lastDuration, _intervalDuration, _chartTitle; - public RenderCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public RenderCommand() { - if (config == null) throw new ArgumentNullException(nameof(config)); - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "i=|id=", "The id of a single dashboard to render", @@ -63,13 +59,15 @@ public RenderCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config _range = Enable(); _signal = Enable(); _timeout = Enable(); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); _connection = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); if (_id == null) { @@ -158,8 +156,9 @@ protected override async Task Run() var q = BuildSqlQuery(query, rangeStart, rangeEnd, timeGrouping); var timeout = _timeout.ApplyTimeout(connection.Client.HttpClient); - - if (_output.Json) + + var output = _output.GetOutputFormat(config); + if (output.Json) { var result = await connection.Data.QueryAsync(q, signal: signal, timeout: timeout); @@ -169,7 +168,7 @@ protected override async Task Run() else { var result = await connection.Data.QueryCsvAsync(q, signal: signal, timeout: timeout); - _output.WriteCsv(result); + output.WriteCsv(result); } return 0; diff --git a/src/SeqCli/Cli/Commands/ExpressionIndex/CreateCommand.cs b/src/SeqCli/Cli/Commands/ExpressionIndex/CreateCommand.cs index 5fc39086..d0760376 100644 --- a/src/SeqCli/Cli/Commands/ExpressionIndex/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/ExpressionIndex/CreateCommand.cs @@ -29,29 +29,28 @@ namespace SeqCli.Cli.Commands.ExpressionIndex; Example = "seqcli expressionindex create --expression \"ServerName\"")] class CreateCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; - + readonly StoragePathFeature _storagePath; + string? _expression; - public CreateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public CreateCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "e=|expression=", "The expression to index", v => _expression = ArgumentString.Normalize(v)); _connection = Enable(); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); if (string.IsNullOrEmpty(_expression)) { @@ -63,7 +62,7 @@ protected override async Task Run() index.Expression = _expression; index = await connection.ExpressionIndexes.AddAsync(index); - _output.WriteEntity(index); + _output.GetOutputFormat(config).WriteEntity(index); return 0; } diff --git a/src/SeqCli/Cli/Commands/ExpressionIndex/ListCommand.cs b/src/SeqCli/Cli/Commands/ExpressionIndex/ListCommand.cs index 6836d3f1..16b00cb8 100644 --- a/src/SeqCli/Cli/Commands/ExpressionIndex/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/ExpressionIndex/ListCommand.cs @@ -9,33 +9,32 @@ namespace SeqCli.Cli.Commands.ExpressionIndex; [Command("expressionindex", "list", "List expression indexes", Example="seqcli expressionindex list")] class ListCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; + readonly StoragePathFeature _storagePath; + string? _id; - public ListCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public ListCommand() { - if (config == null) throw new ArgumentNullException(nameof(config)); - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "i=|id=", "The id of a single expression index to list", id => _id = id); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); _connection = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var list = _id is not null ? [await connection.ExpressionIndexes.FindAsync(_id)] : await connection.ExpressionIndexes.ListAsync(); - _output.ListEntities(list); + _output.GetOutputFormat(config).ListEntities(list); return 0; } } \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/ExpressionIndex/RemoveCommand.cs b/src/SeqCli/Cli/Commands/ExpressionIndex/RemoveCommand.cs index c7ebee37..b00e7a41 100644 --- a/src/SeqCli/Cli/Commands/ExpressionIndex/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/ExpressionIndex/RemoveCommand.cs @@ -12,10 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; -using System.Linq; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using Serilog; @@ -25,21 +24,19 @@ namespace SeqCli.Cli.Commands.ExpressionIndex; Example = "seqcli expressionindex -i expressionindex-2529")] class RemoveCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; + readonly StoragePathFeature _storagePath; string? _id; - public RemoveCommand(SeqConnectionFactory connectionFactory) + public RemoveCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "i=|id=", "The id of an expression index to remove", id => _id = id); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() @@ -50,7 +47,8 @@ protected override async Task Run() return 1; } - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var toRemove = await connection.ExpressionIndexes.FindAsync(_id); await connection.ExpressionIndexes.RemoveAsync(toRemove); diff --git a/src/SeqCli/Cli/Commands/Feed/CreateCommand.cs b/src/SeqCli/Cli/Commands/Feed/CreateCommand.cs index 28a891c0..9d4aea80 100644 --- a/src/SeqCli/Cli/Commands/Feed/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/Feed/CreateCommand.cs @@ -26,18 +26,15 @@ namespace SeqCli.Cli.Commands.Feed; Example = "seqcli feed create -n 'CI' --location=\"https://f.feedz.io/example/ci\" -u Seq --password-stdin")] class CreateCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; - + readonly StoragePathFeature _storagePath; + string? _name, _location, _username, _password; bool _passwordStdin; - public CreateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public CreateCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "n=|name=", "A unique name for the feed", @@ -64,12 +61,14 @@ public CreateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config _ => _passwordStdin = true); _connection = Enable(); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var feed = await connection.Feeds.TemplateAsync(); feed.Name = _name; @@ -94,7 +93,7 @@ protected override async Task Run() feed = await connection.Feeds.AddAsync(feed); - _output.WriteEntity(feed); + _output.GetOutputFormat(config).WriteEntity(feed); return 0; } diff --git a/src/SeqCli/Cli/Commands/Feed/ListCommand.cs b/src/SeqCli/Cli/Commands/Feed/ListCommand.cs index 31e27116..a7d45220 100644 --- a/src/SeqCli/Cli/Commands/Feed/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Feed/ListCommand.cs @@ -24,18 +24,14 @@ namespace SeqCli.Cli.Commands.Feed; [Command("feed", "list", "List NuGet feeds", Example="seqcli feed list")] class ListCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; - + readonly StoragePathFeature _storagePath; + string? _name, _id; - public ListCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public ListCommand() { - if (config == null) throw new ArgumentNullException(nameof(config)); - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "n=|name=", "The name of the feed to list", @@ -46,20 +42,22 @@ public ListCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) "The id of a single feed to list", id => _id = id); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); _connection = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var list = _id != null ? - new[] { await connection.Feeds.FindAsync(_id) } : + var list = _id != null ? [await connection.Feeds.FindAsync(_id)] + : (await connection.Feeds.ListAsync()) .Where(f => _name == null || _name == f.Name); - _output.ListEntities(list); + _output.GetOutputFormat(config).ListEntities(list); return 0; } diff --git a/src/SeqCli/Cli/Commands/Feed/RemoveCommand.cs b/src/SeqCli/Cli/Commands/Feed/RemoveCommand.cs index 4f0ce4b4..3180bfb5 100644 --- a/src/SeqCli/Cli/Commands/Feed/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/Feed/RemoveCommand.cs @@ -16,6 +16,7 @@ using System.Linq; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using Serilog; @@ -25,16 +26,13 @@ namespace SeqCli.Cli.Commands.Feed; Example="seqcli feed remove -n CI")] class RemoveCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; - + readonly StoragePathFeature _storagePath; + string? _name, _id; - public RemoveCommand(SeqConnectionFactory connectionFactory) + public RemoveCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "n=|name=", "The name of the feed to remove", @@ -46,6 +44,7 @@ public RemoveCommand(SeqConnectionFactory connectionFactory) id => _id = id); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() @@ -56,10 +55,11 @@ protected override async Task Run() return 1; } - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var toRemove = _id != null ? - new[] {await connection.Feeds.FindAsync(_id)} : + var toRemove = _id != null ? [await connection.Feeds.FindAsync(_id)] + : (await connection.Feeds.ListAsync()) .Where(f => _name == f.Name) .ToArray(); diff --git a/src/SeqCli/Cli/Commands/Feed/UpdateCommand.cs b/src/SeqCli/Cli/Commands/Feed/UpdateCommand.cs index 1dd5d265..59ed98b3 100644 --- a/src/SeqCli/Cli/Commands/Feed/UpdateCommand.cs +++ b/src/SeqCli/Cli/Commands/Feed/UpdateCommand.cs @@ -20,6 +20,6 @@ namespace SeqCli.Cli.Commands.Feed; [Command("feed", "update", "Update an existing NuGet feed", Example="seqcli feed update --json '{...}'")] -class UpdateCommand(SeqConnectionFactory connectionFactory): - Shared.UpdateCommand(connectionFactory, "feed", nameof(SeqConnection.Feeds), "NuGet feed"); +class UpdateCommand(): + Shared.UpdateCommand("feed", nameof(SeqConnection.Feeds), "NuGet feed"); \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs new file mode 100644 index 00000000..03d6d32e --- /dev/null +++ b/src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs @@ -0,0 +1,263 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if WINDOWS + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.ServiceProcess; +using System.Threading.Tasks; +using SeqCli.Cli.Features; +using SeqCli.Config; +using SeqCli.Forwarder.Cli.Features; +using SeqCli.Forwarder.ServiceProcess; +using SeqCli.Forwarder.Util; + +// ReSharper disable once ClassNeverInstantiated.Global + +namespace SeqCli.Cli.Commands.Forwarder +{ + [Command("forwarder", "install", "Install the forwarder as a Windows service", IsPreview = true)] + [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] + class InstallCommand : Command + { + readonly StoragePathFeature _storagePath; + readonly ServiceCredentialsFeature _serviceCredentials; + readonly ListenUriFeature _listenUri; + + bool _setup; + + public InstallCommand() + { + _storagePath = Enable(); + _listenUri = Enable(); + _serviceCredentials = Enable(); + + Options.Add( + "setup", + "Install and start the service only if it does not exist; otherwise reconfigure the binary location", + _ => _setup = true); + } + + string ServiceUsername => _serviceCredentials.IsUsernameSpecified ? _serviceCredentials.Username : "NT AUTHORITY\\LocalService"; + + protected override Task Run() + { + try + { + if (!_setup) + { + Install(); + return Task.FromResult(0); + } + + var exit = Setup(); + if (exit == 0) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("Setup completed successfully."); + Console.ResetColor(); + } + return Task.FromResult(exit); + } + catch (DirectoryNotFoundException dex) + { + Console.WriteLine("Could not install the service, directory not found: " + dex.Message); + return Task.FromResult(-1); + } + catch (Exception ex) + { + Console.WriteLine("Could not install the service: " + ex.Message); + return Task.FromResult(-1); + } + } + + int Setup() + { + ServiceController controller; + try + { + Console.WriteLine($"Checking the status of the {SeqCliForwarderWindowsService.WindowsServiceName} service..."); + + controller = new ServiceController(SeqCliForwarderWindowsService.WindowsServiceName); + Console.WriteLine("Status is {0}", controller.Status); + } + catch (InvalidOperationException) + { + Install(); + var controller2 = new ServiceController(SeqCliForwarderWindowsService.WindowsServiceName); + return Start(controller2); + } + + Console.WriteLine("Service is installed; checking path and dependency configuration..."); + Reconfigure(controller); + + return controller.Status != ServiceControllerStatus.Running ? Start(controller) : 0; + } + + static void Reconfigure(ServiceController controller) + { + var sc = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "sc.exe"); + if (0 != CaptiveProcess.Run(sc, "config \"" + controller.ServiceName + "\" depend= Winmgmt/Tcpip/CryptSvc", Console.WriteLine, Console.WriteLine)) + Console.WriteLine("Could not reconfigure service dependencies; ignoring."); + + if (!ServiceConfiguration.GetServiceBinaryPath(controller, out var path)) + return; + + var current = "\"" + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Program.BinaryName) + "\""; + if (path.StartsWith(current)) + return; + + var seqRun = path.IndexOf(Program.BinaryName + "\" run", StringComparison.OrdinalIgnoreCase); + if (seqRun == -1) + { + Console.WriteLine("Current binary path is an unrecognized format."); + return; + } + + Console.WriteLine("Existing service binary path is: {0}", path); + + var trimmed = path.Substring((seqRun + Program.BinaryName + " ").Length); + var newPath = current + trimmed; + Console.WriteLine("Updating service binary path configuration to: {0}", newPath); + + var escaped = newPath.Replace("\"", "\\\""); + if (0 != CaptiveProcess.Run(sc, "config \"" + controller.ServiceName + "\" binPath= \"" + escaped + "\"", Console.WriteLine, Console.WriteLine)) + { + Console.WriteLine("Could not reconfigure service path; ignoring."); + return; + } + + Console.WriteLine("Service binary path reconfigured successfully."); + } + + static int Start(ServiceController controller) + { + controller.Start(); + + if (controller.Status != ServiceControllerStatus.Running) + { + Console.WriteLine("Waiting up to 60 seconds for the service to start (currently: " + controller.Status + ")..."); + controller.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(60)); + } + + if (controller.Status == ServiceControllerStatus.Running) + { + Console.WriteLine("Started."); + return 0; + } + + Console.WriteLine("The service hasn't started successfully."); + return -1; + } + + [DllImport("shlwapi.dll")] + static extern bool PathIsNetworkPath(string pszPath); + + void Install() + { + Console.WriteLine("Installing service..."); + + if (PathIsNetworkPath(_storagePath.StorageRootPath)) + throw new ArgumentException("Seq requires a local (or SAN) storage location; network shares are not supported."); + + Console.WriteLine($"Updating the configuration in {_storagePath.ConfigFilePath}..."); + var config = SeqCliConfig.ReadFromFile(_storagePath.ConfigFilePath); + + if (!string.IsNullOrEmpty(_listenUri.ListenUri)) + { + config.Forwarder.Api.ListenUri = _listenUri.ListenUri; + SeqCliConfig.WriteToFile(config, _storagePath.ConfigFilePath); + } + + if (_serviceCredentials.IsUsernameSpecified) + { + if (!_serviceCredentials.IsPasswordSpecified) + throw new ArgumentException( + "If a service user account is specified, a password for the account must also be specified."); + + // https://technet.microsoft.com/en-us/library/cc794944(v=ws.10).aspx + Console.WriteLine($"Ensuring {_serviceCredentials.Username} is granted 'Log on as a Service' rights..."); + AccountRightsHelper.EnsureServiceLogOnRights(_serviceCredentials.Username); + } + + Console.WriteLine($"Granting {ServiceUsername} rights to {_storagePath.StorageRootPath}..."); + GiveFullControl(_storagePath.StorageRootPath); + + Console.WriteLine($"Granting {ServiceUsername} rights to {_storagePath.InternalLogPath}..."); + GiveFullControl(_storagePath.InternalLogPath); + + var listenUri = MakeListenUriReservationPattern(config.Forwarder.Api.ListenUri); + Console.WriteLine($"Adding URL reservation at {listenUri} for {ServiceUsername}..."); + var netshResult = CaptiveProcess.Run("netsh", $"http add urlacl url={listenUri} user=\"{ServiceUsername}\"", Console.WriteLine, Console.WriteLine); + if (netshResult != 0) + Console.WriteLine($"Could not add URL reservation for {listenUri}: `netsh` returned {netshResult}; ignoring"); + + var exePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory!, Program.BinaryName); + var forwarderRunCmdline = $"\"{exePath}\" run --storage=\"{_storagePath.StorageRootPath}\""; + + var binPath = forwarderRunCmdline.Replace("\"", "\\\""); + + var scCmdline = "create \"" + SeqCliForwarderWindowsService.WindowsServiceName + "\"" + + " binPath= \"" + binPath + "\"" + + " start= auto" + + " depend= Winmgmt/Tcpip/CryptSvc"; + + if (_serviceCredentials.IsUsernameSpecified) + scCmdline += $" obj= {_serviceCredentials.Username} password= {_serviceCredentials.Password}"; + + var sc = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "sc.exe"); + if (0 != CaptiveProcess.Run(sc, scCmdline, Console.WriteLine, Console.WriteLine)) + { + throw new ArgumentException("Service setup failed"); + } + + Console.WriteLine("Setting service restart policy..."); + if (0 != CaptiveProcess.Run(sc, $"failure \"{SeqCliForwarderWindowsService.WindowsServiceName}\" actions= restart/60000/restart/60000/restart/60000// reset= 600000", Console.WriteLine, Console.WriteLine)) + Console.WriteLine("Could not set service restart policy; ignoring"); + Console.WriteLine("Setting service description..."); + if (0 != CaptiveProcess.Run(sc, $"description \"{SeqCliForwarderWindowsService.WindowsServiceName}\" \"Durable storage and forwarding of application log events\"", Console.WriteLine, Console.WriteLine)) + Console.WriteLine("Could not set service description; ignoring"); + + Console.WriteLine("Service installed successfully."); + } + + void GiveFullControl(string target) + { + if (target == null) throw new ArgumentNullException(nameof(target)); + + if (!Directory.Exists(target)) + Directory.CreateDirectory(target); + + var storageInfo = new DirectoryInfo(target); + var storageAccessControl = storageInfo.GetAccessControl(); + storageAccessControl.AddAccessRule(new FileSystemAccessRule(ServiceUsername, + FileSystemRights.FullControl, AccessControlType.Allow)); + storageInfo.SetAccessControl(storageAccessControl); + } + + static string MakeListenUriReservationPattern(string uri) + { + var listenUri = uri.Replace("localhost", "+"); + if (!listenUri.EndsWith("/")) + listenUri += "/"; + return listenUri; + } + } +} + +#endif diff --git a/src/SeqCli/Cli/Commands/Forwarder/RestartCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/RestartCommand.cs new file mode 100644 index 00000000..ef028c90 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Forwarder/RestartCommand.cs @@ -0,0 +1,84 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if WINDOWS + +using System; +using System.Diagnostics.CodeAnalysis; +using System.ServiceProcess; +using System.Threading.Tasks; +using SeqCli.Forwarder.ServiceProcess; + +// ReSharper disable UnusedType.Global + +namespace SeqCli.Cli.Commands.Forwarder; + +[Command("forwarder", "restart", "Restart the forwarder Windows service", IsPreview = true)] +[SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] +class RestartCommand : Command +{ + protected override Task Run() + { + try + { + var controller = new ServiceController(SeqCliForwarderWindowsService.WindowsServiceName); + + if (controller.Status != ServiceControllerStatus.Stopped) + { + Console.WriteLine("Stopping {0}...", controller.ServiceName); + controller.Stop(); + + if (controller.Status != ServiceControllerStatus.Stopped) + { + Console.WriteLine("Waiting up to 60 seconds for the service to stop (currently: " + + controller.Status + ")..."); + controller.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(60)); + } + + if (controller.Status != ServiceControllerStatus.Stopped) + { + Console.WriteLine("The service hasn't stopped successfully."); + return Task.FromResult(-1); + } + } + + Console.WriteLine("Starting {0}...", controller.ServiceName); + controller.Start(); + + if (controller.Status != ServiceControllerStatus.Running) + { + Console.WriteLine("Waiting up to 15 seconds for the service to start (currently: " + controller.Status + ")..."); + controller.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(15)); + } + + if (controller.Status == ServiceControllerStatus.Running) + { + Console.WriteLine("Started."); + return Task.FromResult(0); + } + + Console.WriteLine("The service hasn't started successfully."); + return Task.FromResult(-1); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + if (ex.InnerException != null) + Console.WriteLine(ex.InnerException.Message); + return Task.FromResult(1); + } + } +} + +#endif diff --git a/src/SeqCli/Cli/Commands/Forwarder/RunCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/RunCommand.cs new file mode 100644 index 00000000..d0ab573f --- /dev/null +++ b/src/SeqCli/Cli/Commands/Forwarder/RunCommand.cs @@ -0,0 +1,285 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Net; +using System.Threading.Tasks; +using Autofac; +using Autofac.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; +using SeqCli.Cli.Features; +using SeqCli.Config; +using SeqCli.Connection; +using SeqCli.Forwarder; +using SeqCli.Forwarder.Util; +using SeqCli.Forwarder.Web.Api; +using SeqCli.Forwarder.Web.Host; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using Serilog.Formatting.Compact; + +#if WINDOWS +using System.Security.Cryptography.X509Certificates; +using SeqCli.Forwarder.ServiceProcess; +#endif + +// ReSharper disable UnusedType.Global + +namespace SeqCli.Cli.Commands.Forwarder; + +[Command("forwarder", "run", "Listen on an HTTP endpoint and forward ingested logs to Seq", IsPreview = true)] +class RunCommand : Command +{ + readonly StoragePathFeature _storagePath; + readonly ListenUriFeature _listenUri; + readonly ConnectionFeature _connection; + + bool _noLogo; + + public RunCommand() + { + Options.Add("nologo", _ => _noLogo = true); + _listenUri = Enable(); + _connection = Enable(); + _storagePath = Enable(); + } + + protected override async Task Run(string[] unrecognized) + { + if (Environment.UserInteractive) + { + if (!_noLogo) + { + WriteBanner(); + Console.WriteLine(); + } + + Console.WriteLine("Running as server; press Ctrl+C to exit."); + Console.WriteLine(); + } + + SeqCliConfig config; + try + { + config = RuntimeConfigurationLoader.Load(_storagePath); + } + catch (Exception ex) + { + await using var logger = CreateLogger( + LogEventLevel.Information, + _storagePath.InternalLogPath); + + logger.Fatal(ex, "Failed to load configuration from {ConfigFilePath}", _storagePath.ConfigFilePath); + return 1; + } + + var connection = SeqConnectionFactory.Connect(_connection, config); + + // The API key is passed through separately because `SeqConnection` doesn't expose a batched ingestion + // mechanism and so we manually construct `HttpRequestMessage`s deeper in the stack. Nice feature gap to + // close at some point! + var (serverUrl, apiKey) = SeqConnectionFactory.GetConnectionDetails(_connection, config); + + Log.Logger = CreateLogger( + config.Forwarder.Diagnostics.InternalLoggingLevel, + _storagePath.InternalLogPath, + config.Forwarder.Diagnostics.InternalLogServerUri, + config.Forwarder.Diagnostics.InternalLogServerApiKey); + + Log.Information("Loaded configuration from {ConfigFilePath}", _storagePath.ConfigFilePath); + Log.Information("Forwarding to {ServerUrl}", serverUrl); + + var listenUri = _listenUri.ListenUri ?? config.Forwarder.Api.ListenUri; + + try + { + ILifetimeScope? container = null; + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseKestrel(options => + { + options.AddServerHeader = false; + options.AllowSynchronousIO = true; + }).ConfigureKestrel((_, options) => + { + var apiListenUri = new Uri(listenUri); + + var ipAddress = apiListenUri.HostNameType switch + { + UriHostNameType.Basic => IPAddress.Any, + UriHostNameType.Dns => IPAddress.Any, + UriHostNameType.IPv4 => IPAddress.Parse(apiListenUri.Host), + UriHostNameType.IPv6 => IPAddress.Parse(apiListenUri.Host), + _ => throw new NotSupportedException($"Listen URI type `{apiListenUri.HostNameType}` is not supported.") + }; + + if (apiListenUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) + { + options.Listen(ipAddress, apiListenUri.Port, listenOptions => + { +#if WINDOWS + listenOptions.UseHttps(StoreName.My, apiListenUri.Host, + location: StoreLocation.LocalMachine, allowInvalid: true); +#else + listenOptions.UseHttps(); +#endif + }); + } + else + { + options.Listen(ipAddress, apiListenUri.Port); + } + }); + + builder.Services.AddSerilog(); + + builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory()) + .ConfigureContainer(containerBuilder => + { + containerBuilder.RegisterBuildCallback(ls => container = ls); + containerBuilder.RegisterModule(new ForwarderModule(_storagePath.BufferPath, config, connection, apiKey)); + }); + + await using var app = builder.Build(); + + if (container == null) throw new Exception("Host did not build container."); + + foreach (var mapper in container.Resolve>()) + { + mapper.MapEndpoints(app); + } + + var service = container.Resolve( + new TypedParameter(typeof(IHost), app), + new NamedParameter("listenUri", listenUri)); + + var exit = ExecutionEnvironment.SupportsStandardIO + ? await RunStandardIOAsync(service, Console.Out) + : RunService(service); + + Log.Information("Exiting with status code {StatusCode}", exit); + + return exit; + } + catch (Exception ex) + { + Log.Fatal(ex, "Unhandled exception"); + return -1; + } + finally + { + await Log.CloseAndFlushAsync(); + } + } + + [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] + // ReSharper disable once UnusedParameter.Local + static int RunService(ServerService service) + { +#if WINDOWS + System.ServiceProcess.ServiceBase.Run([ + new SeqCliForwarderWindowsService(service) + ]); + return 0; +#else + throw new NotSupportedException("Windows services are not supported on this platform."); +#endif + } + + static async Task RunStandardIOAsync(ServerService service, TextWriter cout) + { + service.Start(); + + try + { + Console.TreatControlCAsInput = true; + var k = Console.ReadKey(true); + while (k.Key != ConsoleKey.C || !k.Modifiers.HasFlag(ConsoleModifiers.Control)) + k = Console.ReadKey(true); + + cout.WriteLine("Ctrl+C pressed; stopping..."); + Console.TreatControlCAsInput = false; + } + catch (Exception ex) + { + Log.Debug(ex, "Console not attached, waiting for any input"); + Console.Read(); + } + + await service.StopAsync(); + + return 0; + } + + static void WriteBanner() + { + Write("─", ConsoleColor.DarkGray, 47); + Console.WriteLine(); + Write(" SeqCli Forwarder", ConsoleColor.White); + Write(" ──", ConsoleColor.DarkGray); + Write(" © Datalust Pty Ltd and Contributors", ConsoleColor.Gray); + Console.WriteLine(); + Write("─", ConsoleColor.DarkGray, 47); + Console.WriteLine(); + } + + static void Write(string s, ConsoleColor color, int repeats = 1) + { + Console.ForegroundColor = color; + for (var i = 0; i < repeats; ++i) + Console.Write(s); + Console.ResetColor(); + } + + static Logger CreateLogger( + LogEventLevel internalLoggingLevel, + string internalLogPath, + string? internalLogServerUri = null, + string? internalLogServerApiKey = null) + { + var loggerConfiguration = new LoggerConfiguration() + .Enrich.FromLogContext() + .Enrich.WithProperty("MachineName", Environment.MachineName) + .Enrich.WithProperty("Application", "SeqCli Forwarder") + .MinimumLevel.Is(internalLoggingLevel) + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .WriteTo.File( + new RenderedCompactJsonFormatter(), + GetRollingLogFilePathFormat(internalLogPath), + rollingInterval: RollingInterval.Day, + fileSizeLimitBytes: 1024 * 1024); + + if (Environment.UserInteractive) + loggerConfiguration.WriteTo.Console(restrictedToMinimumLevel: LogEventLevel.Information); + + if (!string.IsNullOrWhiteSpace(internalLogServerUri)) + loggerConfiguration.WriteTo.Seq( + internalLogServerUri, + apiKey: internalLogServerApiKey); + + return loggerConfiguration.CreateLogger(); + } + + static string GetRollingLogFilePathFormat(string internalLogPath) + { + if (internalLogPath == null) throw new ArgumentNullException(nameof(internalLogPath)); + + return Path.Combine(internalLogPath, "seq-forwarder-.log"); + } +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Forwarder/StartCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/StartCommand.cs new file mode 100644 index 00000000..0d6d0a0b --- /dev/null +++ b/src/SeqCli/Cli/Commands/Forwarder/StartCommand.cs @@ -0,0 +1,68 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if WINDOWS + +using System; +using System.Diagnostics.CodeAnalysis; +using System.ServiceProcess; +using System.Threading.Tasks; +using SeqCli.Forwarder.ServiceProcess; + +namespace SeqCli.Cli.Commands.Forwarder; + +[Command("forwarder", "start", "Start the forwarder Windows service", IsPreview = true)] +[SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] +class StartCommand : Command +{ + protected override Task Run() + { + try + { + var controller = new ServiceController(SeqCliForwarderWindowsService.WindowsServiceName); + if (controller.Status != ServiceControllerStatus.Stopped) + { + Console.WriteLine("Cannot start {0}, current status is: {1}", controller.ServiceName, controller.Status); + return Task.FromResult(-1); + } + + Console.WriteLine("Starting {0}...", controller.ServiceName); + controller.Start(); + + if (controller.Status != ServiceControllerStatus.Running) + { + Console.WriteLine("Waiting up to 15 seconds for the service to start (currently: " + controller.Status + ")..."); + controller.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(15)); + } + + if (controller.Status == ServiceControllerStatus.Running) + { + Console.WriteLine("Started."); + return Task.FromResult(0); + } + + Console.WriteLine("The service hasn't started successfully."); + return Task.FromResult(-1); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + if (ex.InnerException != null) + Console.WriteLine(ex.InnerException.Message); + return Task.FromResult(-1); + } + } +} + +#endif diff --git a/src/SeqCli/Cli/Commands/Forwarder/StatusCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/StatusCommand.cs new file mode 100644 index 00000000..f7733460 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Forwarder/StatusCommand.cs @@ -0,0 +1,52 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if WINDOWS + +using System; +using System.Diagnostics.CodeAnalysis; +using System.ServiceProcess; +using System.Threading.Tasks; +using SeqCli.Forwarder.ServiceProcess; + +namespace SeqCli.Cli.Commands.Forwarder; + +[Command("forwarder", "status", "Show the status of the forwarder Windows service", IsPreview = true)] +[SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] +class StatusCommand : Command +{ + protected override Task Run() + { + try + { + var controller = new ServiceController(SeqCliForwarderWindowsService.WindowsServiceName); + Console.WriteLine($"The {SeqCliForwarderWindowsService.WindowsServiceName} service is installed and {controller.Status.ToString().ToLowerInvariant()}."); + } + catch (InvalidOperationException) + { + Console.WriteLine($"The {SeqCliForwarderWindowsService.WindowsServiceName} service is not installed."); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + if (ex.InnerException != null) + Console.WriteLine(ex.InnerException.Message); + return Task.FromResult(1); + } + + return Task.FromResult(1); + } +} + +#endif diff --git a/src/SeqCli/Cli/Commands/Forwarder/StopCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/StopCommand.cs new file mode 100644 index 00000000..0dc72a45 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Forwarder/StopCommand.cs @@ -0,0 +1,69 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if WINDOWS + +using System; +using System.Diagnostics.CodeAnalysis; +using System.ServiceProcess; +using System.Threading.Tasks; +using SeqCli.Forwarder.ServiceProcess; + +namespace SeqCli.Cli.Commands.Forwarder; + +[Command("forwarder", "stop", "Stop the forwarder Windows service", IsPreview = true)] +[SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] +class StopCommand : Command +{ + protected override Task Run() + { + try + { + var controller = new ServiceController(SeqCliForwarderWindowsService.WindowsServiceName); + + if (controller.Status != ServiceControllerStatus.Running) + { + Console.WriteLine("Cannot stop {0}, current status is: {1}", controller.ServiceName, controller.Status); + return Task.FromResult(-1); + } + + Console.WriteLine("Stopping {0}...", controller.ServiceName); + controller.Stop(); + + if (controller.Status != ServiceControllerStatus.Stopped) + { + Console.WriteLine("Waiting up to 60 seconds for the service to stop (currently: " + controller.Status + ")..."); + controller.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(60)); + } + + if (controller.Status == ServiceControllerStatus.Stopped) + { + Console.WriteLine("Stopped."); + return Task.FromResult(0); + } + + Console.WriteLine("The service hasn't stopped successfully."); + return Task.FromResult(-1); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + if (ex.InnerException != null) + Console.WriteLine(ex.InnerException.Message); + return Task.FromResult(-1); + } + } +} + +#endif diff --git a/src/SeqCli/Cli/Commands/Forwarder/TruncateCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/TruncateCommand.cs new file mode 100644 index 00000000..bf50a2ae --- /dev/null +++ b/src/SeqCli/Cli/Commands/Forwarder/TruncateCommand.cs @@ -0,0 +1,51 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Threading.Tasks; +using SeqCli.Cli.Features; +using Serilog; + +namespace SeqCli.Cli.Commands.Forwarder; + +[Command("forwarder", "truncate", "Empty the forwarder's persistent log buffer", IsPreview = true)] +class TruncateCommand : Command +{ + readonly StoragePathFeature _storagePath; + readonly ConfirmFeature _confirm; + + public TruncateCommand() + { + _storagePath = Enable(); + _confirm = Enable(); + } + + protected override async Task Run(string[] args) + { + try + { + if (!_confirm.TryConfirm("All data in the forwarder's log buffer will be deleted. This cannot be undone.")) + return 1; + + return 0; + } + catch (Exception ex) + { + await using var logger = new LoggerConfiguration().WriteTo.Console().CreateLogger(); + + logger.Fatal(ex, "Could not truncate log buffer"); + return 1; + } + } +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Forwarder/UninstallCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/UninstallCommand.cs new file mode 100644 index 00000000..eb3299b1 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Forwarder/UninstallCommand.cs @@ -0,0 +1,50 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if WINDOWS + +using System; +using System.IO; +using System.Threading.Tasks; +using SeqCli.Forwarder.ServiceProcess; +using SeqCli.Forwarder.Util; + +namespace SeqCli.Cli.Commands.Forwarder; + +[Command("forwarder", "uninstall", "Uninstall the forwarder Windows service", IsPreview = true)] +class UninstallCommand : Command +{ + protected override Task Run() + { + try + { + Console.WriteLine("Uninstalling service..."); + + var sc = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "sc.exe"); + var exitCode = CaptiveProcess.Run(sc, $"delete \"{SeqCliForwarderWindowsService.WindowsServiceName}\"", Console.WriteLine, Console.WriteLine); + if (exitCode != 0) + throw new InvalidOperationException($"The `sc.exe delete` call failed with exit code {exitCode}."); + + Console.WriteLine("Service uninstalled successfully."); + return Task.FromResult(0); + } + catch (Exception ex) + { + Console.WriteLine("Could not uninstall the service: " + ex.Message); + return Task.FromResult(-1); + } + } +} + +#endif diff --git a/src/SeqCli/Cli/Commands/HelpCommand.cs b/src/SeqCli/Cli/Commands/HelpCommand.cs index 026cfcac..3aab532b 100644 --- a/src/SeqCli/Cli/Commands/HelpCommand.cs +++ b/src/SeqCli/Cli/Commands/HelpCommand.cs @@ -18,23 +18,31 @@ using System.Reflection; using System.Threading.Tasks; using Autofac.Features.Metadata; +using CommandList = System.Collections.Generic.List, SeqCli.Cli.CommandMetadata>>; namespace SeqCli.Cli.Commands; [Command("help", "Show information about available commands", Example = "seqcli help search")] class HelpCommand : Command { - readonly List, CommandMetadata>> _orderedCommands; - bool _markdown; + readonly IEnumerable, CommandMetadata>> _availableCommands; + bool _markdown, _pre; public HelpCommand(IEnumerable, CommandMetadata>> availableCommands) { + _availableCommands = availableCommands; + Options.Add("pre", "Show preview commands", _ => _pre = true); Options.Add("m|markdown", "Generate markdown for use in documentation", _ => _markdown = true); - _orderedCommands = availableCommands.OrderBy(c => c.Metadata.Name).ThenBy(c => c.Metadata.SubCommand).ToList(); } protected override Task Run(string[] unrecognized) { + var orderedCommands = _availableCommands + .Where(c => !c.Metadata.IsPreview || _pre) + .OrderBy(c => c.Metadata.Name) + .ThenBy(c => c.Metadata.SubCommand) + .ToList(); + var ea = Assembly.GetEntryAssembly(); // ReSharper disable once PossibleNullReferenceException var name = ea!.GetName().Name!; @@ -44,7 +52,7 @@ protected override Task Run(string[] unrecognized) if (unrecognized.Length != 0) return base.Run(unrecognized); - PrintMarkdownHelp(name); + PrintMarkdownHelp(name, orderedCommands); return Task.FromResult(0); } @@ -53,7 +61,7 @@ protected override Task Run(string[] unrecognized) { topLevelCommand = unrecognized[0].ToLowerInvariant(); var subCommand = unrecognized.Length > 1 && !unrecognized[1].Contains("-") ? unrecognized[1] : null; - var cmds = _orderedCommands.Where(c => c.Metadata.Name == topLevelCommand && + var cmds = orderedCommands.Where(c => c.Metadata.Name == topLevelCommand && (subCommand == null || subCommand == c.Metadata.SubCommand)).ToArray(); if (cmds.Length == 1 && cmds[0].Metadata.SubCommand == subCommand) @@ -79,15 +87,15 @@ protected override Task Run(string[] unrecognized) } } - if (topLevelCommand != null && _orderedCommands.Any(a => a.Metadata.Name == topLevelCommand)) - PrintHelp(name, topLevelCommand); + if (topLevelCommand != null && orderedCommands.Any(a => a.Metadata.Name == topLevelCommand)) + PrintHelp(name, topLevelCommand, orderedCommands); else - PrintHelp(name); + PrintHelp(name, orderedCommands); return Task.FromResult(0); } - void PrintMarkdownHelp(string executableName) + static void PrintMarkdownHelp(string executableName, CommandList orderedCommands) { Console.WriteLine("## Commands"); Console.WriteLine(); @@ -101,7 +109,7 @@ void PrintMarkdownHelp(string executableName) Console.WriteLine("Available commands:"); Console.WriteLine(); - foreach (var cmd in _orderedCommands.GroupBy(cmd => cmd.Metadata.Name).OrderBy(c => c.Key)) + foreach (var cmd in orderedCommands.GroupBy(cmd => cmd.Metadata.Name).OrderBy(c => c.Key)) { if (cmd.Count() == 1) { @@ -122,7 +130,7 @@ void PrintMarkdownHelp(string executableName) } Console.WriteLine(); - foreach (var cmd in _orderedCommands) + foreach (var cmd in orderedCommands) { if (cmd.Metadata.SubCommand != null) Console.WriteLine($"### `{cmd.Metadata.Name} {cmd.Metadata.SubCommand}`"); @@ -166,14 +174,14 @@ void PrintMarkdownHelp(string executableName) } } - void PrintHelp(string executableName) + static void PrintHelp(string executableName, CommandList orderedCommands) { Console.WriteLine($"Usage: {executableName} []"); Console.WriteLine(); Console.WriteLine("Available commands are:"); var printedGroups = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var avail in _orderedCommands) + foreach (var avail in orderedCommands) { if (avail.Metadata.SubCommand != null) { @@ -193,13 +201,13 @@ void PrintHelp(string executableName) Console.WriteLine($"Type `{executableName} help ` for detailed help"); } - void PrintHelp(string executableName, string topLevelCommand) + static void PrintHelp(string executableName, string topLevelCommand, CommandList orderedCommands) { Console.WriteLine($"Usage: {executableName} {topLevelCommand} []"); Console.WriteLine(); Console.WriteLine("Available sub-commands are:"); - foreach (var avail in _orderedCommands.Where(c => c.Metadata.Name == topLevelCommand)) + foreach (var avail in orderedCommands.Where(c => c.Metadata.Name == topLevelCommand)) { Printing.Define($" {avail.Metadata.SubCommand}", avail.Metadata.HelpText, Console.Out); } diff --git a/src/SeqCli/Cli/Commands/Index/ListCommand.cs b/src/SeqCli/Cli/Commands/Index/ListCommand.cs index 958451b5..78138f70 100644 --- a/src/SeqCli/Cli/Commands/Index/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Index/ListCommand.cs @@ -26,35 +26,34 @@ namespace SeqCli.Cli.Commands.Index; [Command("index", "list", "List indexes", Example="seqcli index list")] class ListCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; + readonly StoragePathFeature _storagePath; + string? _id; - public ListCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public ListCommand() { - if (config == null) throw new ArgumentNullException(nameof(config)); - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "i=|id=", "The id of a single index to list", id => _id = id); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); _connection = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var list = _id is not null ? [await connection.Indexes.FindAsync(_id)] : await connection.Indexes.ListAsync(); - _output.ListEntities(list); + _output.GetOutputFormat(config).ListEntities(list); return 0; } diff --git a/src/SeqCli/Cli/Commands/Index/SuppressCommand.cs b/src/SeqCli/Cli/Commands/Index/SuppressCommand.cs index 5aa495bd..bd330179 100644 --- a/src/SeqCli/Cli/Commands/Index/SuppressCommand.cs +++ b/src/SeqCli/Cli/Commands/Index/SuppressCommand.cs @@ -25,20 +25,19 @@ namespace SeqCli.Cli.Commands.Index; [Command("index", "suppress", "Suppress an index", Example="seqcli index suppress -i index-2191448f1d9b4f22bd32c6edef752748")] class SuppressCommand : Command { - readonly SeqConnectionFactory _connectionFactory; readonly ConnectionFeature _connection; + readonly StoragePathFeature _storagePath; + string? _id; - public SuppressCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public SuppressCommand() { - if (config == null) throw new ArgumentNullException(nameof(config)); - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "i=|id=", "The id of an index to suppress", id => _id = id); + _storagePath = Enable(); _connection = Enable(); } @@ -50,7 +49,8 @@ protected override async Task Run() return 1; } - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var toSuppress = await connection.Indexes.FindAsync(_id); await connection.Indexes.SuppressAsync(toSuppress); diff --git a/src/SeqCli/Cli/Commands/IngestCommand.cs b/src/SeqCli/Cli/Commands/IngestCommand.cs index ad52e0ba..1827ed59 100644 --- a/src/SeqCli/Cli/Commands/IngestCommand.cs +++ b/src/SeqCli/Cli/Commands/IngestCommand.cs @@ -16,6 +16,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using SeqCli.Ingestion; using SeqCli.Levels; @@ -33,20 +34,19 @@ class IngestCommand : Command { const string DefaultPattern = "{@m:line}"; - readonly SeqConnectionFactory _connectionFactory; readonly InvalidDataHandlingFeature _invalidDataHandlingFeature; readonly FileInputFeature _fileInputFeature; readonly PropertiesFeature _properties; readonly SendFailureHandlingFeature _sendFailureHandlingFeature; readonly ConnectionFeature _connection; readonly BatchSizeFeature _batchSize; + readonly StoragePathFeature _storagePath; string? _filter, _level, _message; string _pattern = DefaultPattern; bool _json; - public IngestCommand(SeqConnectionFactory connectionFactory) + public IngestCommand() { - _connectionFactory = connectionFactory; _fileInputFeature = Enable(new FileInputFeature("File(s) to ingest", allowMultiple: true)); _invalidDataHandlingFeature = Enable(); _properties = Enable(); @@ -76,6 +76,7 @@ public IngestCommand(SeqConnectionFactory connectionFactory) _sendFailureHandlingFeature = Enable(); _connection = Enable(); _batchSize = Enable(); + _storagePath = Enable(); } protected override async Task Run() @@ -97,16 +98,21 @@ protected override async Task Run() filter = evt => Seq.Syntax.Expressions.ExpressionResult.IsTrue(eval(evt)); } - var connection = _connectionFactory.Connect(_connection); - var (_, apiKey) = _connectionFactory.GetConnectionDetails(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); + + // The API key is passed through separately because `SeqConnection` doesn't expose a batched ingestion + // mechanism and so we manually construct `HttpRequestMessage`s deeper in the stack. Nice feature gap to + // close at some point! + var (_, apiKey) = SeqConnectionFactory.GetConnectionDetails(_connection, config); var batchSize = _batchSize.Value; foreach (var input in _fileInputFeature.OpenInputs()) { using (input) { - var reader = _json - ? (ILogEventReader) new JsonLogEventReader(input) + ILogEventReader reader = _json + ? new JsonLogEventReader(input) : new PlainTextLogEventReader(input, _pattern); reader = new EnrichingReader(reader, enrichers); diff --git a/src/SeqCli/Cli/Commands/License/ApplyCommand.cs b/src/SeqCli/Cli/Commands/License/ApplyCommand.cs index 7f2970b7..53b50da9 100644 --- a/src/SeqCli/Cli/Commands/License/ApplyCommand.cs +++ b/src/SeqCli/Cli/Commands/License/ApplyCommand.cs @@ -3,6 +3,7 @@ using System.Text; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using SeqCli.Util; using Serilog; @@ -15,17 +16,15 @@ namespace SeqCli.Cli.Commands.License; Example = "seqcli license apply --certificate=\"license.txt\"")] class ApplyCommand : Command { - readonly SeqConnectionFactory _connectionFactory; readonly ConnectionFeature _connection; - + readonly StoragePathFeature _storagePath; + string? _certificateFilename; bool _certificateStdin; bool _automaticallyRefresh; - public ApplyCommand(SeqConnectionFactory connectionFactory) + public ApplyCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add("c=|certificate=", "Certificate file; the file must be UTF-8 text", v => _certificateFilename = ArgumentString.Normalize(v)); @@ -39,6 +38,7 @@ public ApplyCommand(SeqConnectionFactory connectionFactory) "the certificate when the subscription is changed or renewed", _ => _automaticallyRefresh = true); + _storagePath = Enable(); _connection = Enable(); } @@ -71,7 +71,8 @@ protected override async Task Run() return 1; } - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var license = await connection.Licenses.FindCurrentAsync(); license.LicenseText = certificate; license.AutomaticallyRefresh = _automaticallyRefresh; diff --git a/src/SeqCli/Cli/Commands/License/ShowCommand.cs b/src/SeqCli/Cli/Commands/License/ShowCommand.cs index 1db3bc6d..7f163efb 100644 --- a/src/SeqCli/Cli/Commands/License/ShowCommand.cs +++ b/src/SeqCli/Cli/Commands/License/ShowCommand.cs @@ -18,29 +18,32 @@ namespace SeqCli.Cli.Commands.License; Example = "seqcli license show")] class ShowCommand : Command { - readonly SeqConnectionFactory _connectionFactory; readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; + readonly StoragePathFeature _storage; - public ShowCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public ShowCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); _connection = Enable(); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storage = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storage); + var output = _output.GetOutputFormat(config); + + var connection = SeqConnectionFactory.Connect(_connection, config); var license = await connection.Licenses.FindCurrentAsync(); - if (_output.Json) + if (output.Json) { - _output.WriteEntity(license); + output.WriteEntity(license); } else { - _output.WriteText(license?.LicenseText); + output.WriteText(license?.LicenseText); } return 0; diff --git a/src/SeqCli/Cli/Commands/LogCommand.cs b/src/SeqCli/Cli/Commands/LogCommand.cs index 2d6002ae..be3c5f8e 100644 --- a/src/SeqCli/Cli/Commands/LogCommand.cs +++ b/src/SeqCli/Cli/Commands/LogCommand.cs @@ -22,6 +22,7 @@ using Newtonsoft.Json.Linq; using SeqCli.Api; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using Serilog; @@ -32,15 +33,13 @@ namespace SeqCli.Cli.Commands; [Command("log", "Send a structured log event to the server", Example = "seqcli log -m 'Hello, {Name}!' -p Name=World -p App=Test")] class LogCommand : Command { - readonly SeqConnectionFactory _connectionFactory; readonly PropertiesFeature _properties; readonly ConnectionFeature _connection; + readonly StoragePathFeature _storagePath; string? _message, _level, _timestamp, _exception; - public LogCommand(SeqConnectionFactory connectionFactory) + public LogCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "m=|message=", "A message to associate with the event (the default is to send no message); https://messagetemplates.org syntax is supported", @@ -63,6 +62,7 @@ public LogCommand(SeqConnectionFactory connectionFactory) _properties = Enable(); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() @@ -103,8 +103,9 @@ protected override async Task Run() content = new StringContent(builder.ToString(), Encoding.UTF8, ApiConstants.ClefMediaType); } - var connection = _connectionFactory.Connect(_connection); - var (_, apiKey) = _connectionFactory.GetConnectionDetails(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); + var (_, apiKey) = SeqConnectionFactory.GetConnectionDetails(_connection, config); var request = new HttpRequestMessage(HttpMethod.Post, ApiConstants.IngestionEndpoint) {Content = content}; if (apiKey != null) diff --git a/src/SeqCli/Cli/Commands/Node/HealthCommand.cs b/src/SeqCli/Cli/Commands/Node/HealthCommand.cs index ef910c39..60a82b76 100644 --- a/src/SeqCli/Cli/Commands/Node/HealthCommand.cs +++ b/src/SeqCli/Cli/Commands/Node/HealthCommand.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -31,38 +31,38 @@ namespace SeqCli.Cli.Commands.Node; Example = "seqcli node health -s https://seq-2.example.com")] class HealthCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly WaitUntilHealthyFeature _waitUntilHealthy; readonly TimeoutFeature _timeout; readonly OutputFormatFeature _output; - - public HealthCommand(SeqConnectionFactory connectionFactory, SeqCliOutputConfig outputConfig) + readonly StoragePathFeature _storagePath; + + public HealthCommand() { - _connectionFactory = connectionFactory; _waitUntilHealthy = Enable(new WaitUntilHealthyFeature("node")); _timeout = Enable(new TimeoutFeature()); _connection = Enable(); - _output = Enable(new OutputFormatFeature(outputConfig)); + _output = Enable(); + _storagePath = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var timeout = _timeout.ApplyTimeout(connection.Client.HttpClient); if (_waitUntilHealthy.ShouldWait) { - return await RunUntilHealthy(connection, timeout ?? TimeSpan.FromSeconds(30)); + return await RunUntilHealthy(connection, timeout ?? TimeSpan.FromSeconds(30), _output.GetOutputFormat(config)); } - return await RunOnce(connection); + return await RunOnce(connection, _output.GetOutputFormat(config)); } - async Task RunUntilHealthy(SeqConnection connection, TimeSpan timeout) + async Task RunUntilHealthy(SeqConnection connection, TimeSpan timeout, OutputFormat outputFormat) { using var ct = new CancellationTokenSource(timeout); @@ -76,7 +76,7 @@ async Task RunUntilHealthy(SeqConnection connection, TimeSpan timeout) { while (true) { - if (await RunOnce(connection) == 0) + if (await RunOnce(connection, outputFormat) == 0) { return 0; } @@ -91,7 +91,7 @@ async Task RunUntilHealthy(SeqConnection connection, TimeSpan timeout) } } - async Task RunOnce(SeqConnection connection) + async Task RunOnce(SeqConnection connection, OutputFormat outputFormat) { try { @@ -104,17 +104,17 @@ async Task RunOnce(SeqConnection connection) Log.Information("{HeaderName}: {HeaderValue}", key, value); } - if (_output.Json) + if (outputFormat.Json) { var shouldBeJson = await response.Content.ReadAsStringAsync(); try { var obj = JsonConvert.DeserializeObject(shouldBeJson) ?? throw new InvalidDataException(); - _output.WriteObject(obj); + outputFormat.WriteObject(obj); } catch { - _output.WriteObject(new { Response = shouldBeJson }); + outputFormat.WriteObject(new { Response = shouldBeJson }); } } else diff --git a/src/SeqCli/Cli/Commands/Node/ListCommand.cs b/src/SeqCli/Cli/Commands/Node/ListCommand.cs index 358db970..c374c6a0 100644 --- a/src/SeqCli/Cli/Commands/Node/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Node/ListCommand.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -25,17 +25,14 @@ namespace SeqCli.Cli.Commands.Node; Example = "seqcli node list --json")] class ListCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; - + readonly StoragePathFeature _storagePath; + string? _name, _id; - public ListCommand(SeqConnectionFactory connectionFactory, SeqCliOutputConfig outputConfig) + public ListCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "n=|name=", "The name of the cluster node to list", @@ -46,20 +43,22 @@ public ListCommand(SeqConnectionFactory connectionFactory, SeqCliOutputConfig ou "The id of a single cluster node to list", id => _id = id); - _output = Enable(new OutputFormatFeature(outputConfig)); + _output = Enable(); + _storagePath = Enable(); _connection = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var list = _id != null ? - new[] { await connection.Cluster.FindAsync(_id) } : + var list = _id != null ? [await connection.Cluster.FindAsync(_id)] + : (await connection.Cluster.ListAsync()) .Where(n => _name == null || _name == n.Name); - _output.ListEntities(list); + _output.GetOutputFormat(config).ListEntities(list); return 0; } diff --git a/src/SeqCli/Cli/Commands/PrintCommand.cs b/src/SeqCli/Cli/Commands/PrintCommand.cs index c277914d..51d845ab 100644 --- a/src/SeqCli/Cli/Commands/PrintCommand.cs +++ b/src/SeqCli/Cli/Commands/PrintCommand.cs @@ -34,16 +34,13 @@ class PrintCommand : Command { readonly FileInputFeature _fileInputFeature; readonly InvalidDataHandlingFeature _invalidDataHandlingFeature; + readonly StoragePathFeature _storage; - string? _filter, _template = OutputFormatFeature.DefaultOutputTemplate; - bool _noColor, _forceColor; + string? _filter, _template = OutputFormat.DefaultOutputTemplate; + bool? _noColor, _forceColor; - public PrintCommand(SeqCliOutputConfig outputConfig) + public PrintCommand() { - if (outputConfig == null) throw new ArgumentNullException(nameof(outputConfig)); - _noColor = outputConfig.DisableColor; - _forceColor = outputConfig.ForceColor; - _fileInputFeature = Enable(new FileInputFeature("CLEF file to read", allowMultiple: true)); Options.Add("f=|filter=", @@ -56,28 +53,32 @@ public PrintCommand(SeqCliOutputConfig outputConfig) _invalidDataHandlingFeature = Enable(); + // These should be ported to use `OutputFormatFeature`. Options.Add("no-color", "Don't colorize text output", _ => _noColor = true); - Options.Add("force-color", "Force redirected output to have ANSI color (unless `--no-color` is also specified)", _ => _forceColor = true); + + _storage = Enable(); } protected override async Task Run() { + var config = RuntimeConfigurationLoader.Load(_storage); + var applyThemeToRedirectedOutput - = !_noColor && _forceColor; + = !(_noColor ?? config.Output.DisableColor) && (_forceColor ?? config.Output.ForceColor); var theme - = _noColor ? ConsoleTheme.None - : applyThemeToRedirectedOutput ? OutputFormatFeature.DefaultAnsiTheme - : OutputFormatFeature.DefaultTheme; + = _noColor ?? config.Output.DisableColor ? ConsoleTheme.None + : applyThemeToRedirectedOutput ? OutputFormat.DefaultAnsiTheme + : OutputFormat.DefaultTheme; var outputConfiguration = new LoggerConfiguration() .MinimumLevel.Is(LevelAlias.Minimum) .Enrich.With() .WriteTo.Console( - outputTemplate: _template ?? OutputFormatFeature.DefaultOutputTemplate, + outputTemplate: _template ?? OutputFormat.DefaultOutputTemplate, theme: theme, applyThemeToRedirectedOutput: applyThemeToRedirectedOutput); diff --git a/src/SeqCli/Cli/Commands/Profile/CreateCommand.cs b/src/SeqCli/Cli/Commands/Profile/CreateCommand.cs index f4289763..ba2dfc5d 100644 --- a/src/SeqCli/Cli/Commands/Profile/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/Profile/CreateCommand.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using SeqCli.Cli.Features; using SeqCli.Config; using SeqCli.Util; using Serilog; @@ -11,7 +12,8 @@ namespace SeqCli.Cli.Commands.Profile; class CreateCommand : Command { string? _url, _apiKey, _name; - + readonly StoragePathFeature _storagePath; + public CreateCommand() { Options.Add("n=|name=", @@ -25,6 +27,8 @@ public CreateCommand() Options.Add("a=|apikey=", "The API key to use when connecting to the server, if required", v => _apiKey = ArgumentString.Normalize(v)); + + _storagePath = Enable(); } protected override Task Run() @@ -48,9 +52,11 @@ int RunSync() try { - var config = SeqCliConfig.ReadFromFile(RuntimeConfigurationLoader.DefaultConfigFilename); - config.Profiles[_name] = new SeqCliConnectionConfig { ServerUrl = _url, ApiKey = _apiKey }; - SeqCliConfig.WriteToFile(config, RuntimeConfigurationLoader.DefaultConfigFilename); + var config = SeqCliConfig.ReadFromFile(_storagePath.ConfigFilePath); + var connectionConfig = new SeqCliConnectionConfig { ServerUrl = _url }; + connectionConfig.EncodeApiKey(_apiKey, config.Encryption.DataProtector()); + config.Profiles[_name] = connectionConfig; + SeqCliConfig.WriteToFile(config, _storagePath.ConfigFilePath); return 0; } catch (Exception ex) diff --git a/src/SeqCli/Cli/Commands/Profile/ListCommand.cs b/src/SeqCli/Cli/Commands/Profile/ListCommand.cs index 8d3b8048..c43d3324 100644 --- a/src/SeqCli/Cli/Commands/Profile/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Profile/ListCommand.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Threading.Tasks; +using SeqCli.Cli.Features; using SeqCli.Config; namespace SeqCli.Cli.Commands.Profile; @@ -9,9 +10,16 @@ namespace SeqCli.Cli.Commands.Profile; Example = "seqcli profile list")] class ListCommand : Command { + readonly StoragePathFeature _storagePath; + + public ListCommand() + { + _storagePath = Enable(); + } + protected override Task Run() { - var config = RuntimeConfigurationLoader.Load(); + var config = RuntimeConfigurationLoader.Load(_storagePath); foreach (var profile in config.Profiles.OrderBy(p => p.Key)) { diff --git a/src/SeqCli/Cli/Commands/Profile/RemoveCommand.cs b/src/SeqCli/Cli/Commands/Profile/RemoveCommand.cs index a112bdb1..244b520f 100644 --- a/src/SeqCli/Cli/Commands/Profile/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/Profile/RemoveCommand.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using SeqCli.Cli.Features; using SeqCli.Config; using SeqCli.Util; using Serilog; @@ -11,12 +12,15 @@ namespace SeqCli.Cli.Commands.Profile; class RemoveCommand : Command { string? _name; + readonly StoragePathFeature _storagePath; public RemoveCommand() { Options.Add("n=|name=", "The name of the connection profile to remove", v => _name = ArgumentString.Normalize(v)); + + _storagePath = Enable(); } protected override Task Run() @@ -34,14 +38,14 @@ int RunSync() try { - var config = SeqCliConfig.ReadFromFile(RuntimeConfigurationLoader.DefaultConfigFilename); + var config = SeqCliConfig.ReadFromFile(_storagePath.ConfigFilePath); if (!config.Profiles.Remove(_name)) { Log.Error("No profile with name {ProfileName} was found", _name); return 1; } - SeqCliConfig.WriteToFile(config, RuntimeConfigurationLoader.DefaultConfigFilename); + SeqCliConfig.WriteToFile(config, _storagePath.ConfigFilePath); return 0; } diff --git a/src/SeqCli/Cli/Commands/QueryCommand.cs b/src/SeqCli/Cli/Commands/QueryCommand.cs index 2dfd8058..1c54110f 100644 --- a/src/SeqCli/Cli/Commands/QueryCommand.cs +++ b/src/SeqCli/Cli/Commands/QueryCommand.cs @@ -29,23 +29,22 @@ namespace SeqCli.Cli.Commands; class QueryCommand : Command { readonly OutputFormatFeature _output; - readonly SeqConnectionFactory _connectionFactory; readonly ConnectionFeature _connection; readonly DateRangeFeature _range; readonly SignalExpressionFeature _signal; readonly TimeoutFeature _timeout; + readonly StoragePathFeature _storagePath; string? _query; bool _trace; - public QueryCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public QueryCommand() { - if (config == null) throw new ArgumentNullException(nameof(config)); - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); Options.Add("q=|query=", "The query to execute", v => _query = v); _range = Enable(); _signal = Enable(); _timeout = Enable(); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); Options.Add("trace", "Enable detailed (server-side) query tracing", _ => _trace = true); _connection = Enable(); } @@ -58,11 +57,13 @@ protected override async Task Run() return 1; } - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var timeout = _timeout.ApplyTimeout(connection.Client.HttpClient); - - if (_output.Json) + + var output = _output.GetOutputFormat(config); + if (output.Json) { var result = await connection.Data.QueryAsync(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); @@ -72,7 +73,7 @@ protected override async Task Run() else { var result = await connection.Data.QueryCsvAsync(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); - _output.WriteCsv(result); + output.WriteCsv(result); } return 0; diff --git a/src/SeqCli/Cli/Commands/RetentionPolicy/CreateCommand.cs b/src/SeqCli/Cli/Commands/RetentionPolicy/CreateCommand.cs index 6ead5878..aa5c6a07 100644 --- a/src/SeqCli/Cli/Commands/RetentionPolicy/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/RetentionPolicy/CreateCommand.cs @@ -29,19 +29,16 @@ namespace SeqCli.Cli.Commands.RetentionPolicy; Example = "seqcli retention create --after 30d --delete-all-events")] class CreateCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; - + readonly StoragePathFeature _storagePath; + string? _afterDuration; bool _deleteAllEvents; string? _deleteMatchingSignal; - public CreateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public CreateCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "after=", "A duration after which the policy will delete events, e.g. `7d`", @@ -62,12 +59,14 @@ public CreateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config ); _connection = Enable(); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); SignalExpressionPart? removedSignalExpression; @@ -106,7 +105,7 @@ protected override async Task Run() policy = await connection.RetentionPolicies.AddAsync(policy); - _output.WriteEntity(policy); + _output.GetOutputFormat(config).WriteEntity(policy); return 0; } diff --git a/src/SeqCli/Cli/Commands/RetentionPolicy/ListCommand.cs b/src/SeqCli/Cli/Commands/RetentionPolicy/ListCommand.cs index 57702a93..391866ad 100644 --- a/src/SeqCli/Cli/Commands/RetentionPolicy/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/RetentionPolicy/ListCommand.cs @@ -13,7 +13,6 @@ // limitations under the License. using System; -using System.Linq; using System.Threading.Tasks; using SeqCli.Cli.Features; using SeqCli.Config; @@ -24,37 +23,35 @@ namespace SeqCli.Cli.Commands.RetentionPolicy; [Command("retention", "list", "List retention policies", Example="seqcli retention list")] class ListCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; - + readonly StoragePathFeature _storagePath; + string? _id; - public ListCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public ListCommand() { - if (config == null) throw new ArgumentNullException(nameof(config)); - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "i=|id=", "The id of a single retention policy to list", id => _id = id); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); _connection = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var list = _id != null ? - new[] { await connection.RetentionPolicies.FindAsync(_id) } : + var list = _id != null ? [await connection.RetentionPolicies.FindAsync(_id)] + : (await connection.RetentionPolicies.ListAsync()) .ToArray(); - - _output.ListEntities(list); + + _output.GetOutputFormat(config).ListEntities(list); return 0; } diff --git a/src/SeqCli/Cli/Commands/RetentionPolicy/RemoveCommand.cs b/src/SeqCli/Cli/Commands/RetentionPolicy/RemoveCommand.cs index e083d779..3d9266e0 100644 --- a/src/SeqCli/Cli/Commands/RetentionPolicy/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/RetentionPolicy/RemoveCommand.cs @@ -15,6 +15,7 @@ using System; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using Serilog; @@ -24,22 +25,20 @@ namespace SeqCli.Cli.Commands.RetentionPolicy; Example="seqcli retention remove -i retentionpolicy-17")] class RemoveCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; - + readonly StoragePathFeature _storagePath; + string? _id; - public RemoveCommand(SeqConnectionFactory connectionFactory) + public RemoveCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "i=|id=", "The id of a single retention policy to remove", id => _id = id); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() @@ -50,7 +49,8 @@ protected override async Task Run() return 1; } - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var toRemove = await connection.RetentionPolicies.FindAsync(_id); diff --git a/src/SeqCli/Cli/Commands/RetentionPolicy/UpdateCommand.cs b/src/SeqCli/Cli/Commands/RetentionPolicy/UpdateCommand.cs index 12dded9e..ca6503ce 100644 --- a/src/SeqCli/Cli/Commands/RetentionPolicy/UpdateCommand.cs +++ b/src/SeqCli/Cli/Commands/RetentionPolicy/UpdateCommand.cs @@ -20,6 +20,6 @@ namespace SeqCli.Cli.Commands.RetentionPolicy; [Command("retention", "update", "Update an existing retention policy", Example="seqcli retention update --json '{...}'")] -class UpdateCommand(SeqConnectionFactory connectionFactory): - Shared.UpdateCommand(connectionFactory, "retention", nameof(SeqConnection.RetentionPolicies), "retention policy"); +class UpdateCommand(): + Shared.UpdateCommand("retention", nameof(SeqConnection.RetentionPolicies), "retention policy"); \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Sample/IngestCommand.cs b/src/SeqCli/Cli/Commands/Sample/IngestCommand.cs index 251c00a0..a5dd3790 100644 --- a/src/SeqCli/Cli/Commands/Sample/IngestCommand.cs +++ b/src/SeqCli/Cli/Commands/Sample/IngestCommand.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ using System.Linq; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using SeqCli.Sample.Loader; @@ -25,19 +26,17 @@ namespace SeqCli.Cli.Commands.Sample; Example = "seqcli sample ingest")] class IngestCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly ConfirmFeature _confirm; readonly BatchSizeFeature _batchSize; - + readonly StoragePathFeature _storagePath; + bool _quiet; bool _setup; int _simulations = 1; - public IngestCommand(SeqConnectionFactory connectionFactory) + public IngestCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); _confirm = Enable(); _connection = Enable(); @@ -47,11 +46,13 @@ public IngestCommand(SeqConnectionFactory connectionFactory) v => _simulations = int.Parse(v)); _batchSize = Enable(); + _storagePath = Enable(); } protected override async Task Run() { - var (url, apiKey) = _connectionFactory.GetConnectionDetails(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var (url, apiKey) = SeqConnectionFactory.GetConnectionDetails(_connection, config); var batchSize = _batchSize.Value; if (!_confirm.TryConfirm(_setup @@ -62,7 +63,7 @@ protected override async Task Run() return 1; } - var connection = _connectionFactory.Connect(_connection); + var connection = SeqConnectionFactory.Connect(_connection, config); if (_setup) { diff --git a/src/SeqCli/Cli/Commands/Sample/SetupCommand.cs b/src/SeqCli/Cli/Commands/Sample/SetupCommand.cs index e801ca10..fb0c0722 100644 --- a/src/SeqCli/Cli/Commands/Sample/SetupCommand.cs +++ b/src/SeqCli/Cli/Commands/Sample/SetupCommand.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ using SeqCli.Templates.Import; using SeqCli.Util; using Seq.Api; +using SeqCli.Config; // ReSharper disable once UnusedType.Global @@ -32,27 +33,26 @@ namespace SeqCli.Cli.Commands.Sample; Example = "seqcli sample setup")] class SetupCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly ConfirmFeature _confirm; - - public SetupCommand(SeqConnectionFactory connectionFactory) + readonly StoragePathFeature _storagePath; + + public SetupCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - // The command will also at some point accept an `--allow-outbound-requests` flag, which will cause sample // apps to be installed, and a health check to be set up. _confirm = Enable(); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var (url, _) = _connectionFactory.GetConnectionDetails(_connection); + var (url, _) = SeqConnectionFactory.GetConnectionDetails(_connection, config); if (!_confirm.TryConfirm($"This will apply sample configuration items to the Seq server at {url}.")) { await Console.Error.WriteLineAsync("Canceled by user."); @@ -64,8 +64,10 @@ protected override async Task Run() internal static async Task ImportTemplates(SeqConnection connection) { - var templateArgs = new Dictionary(); - templateArgs["ownerId"] = new JsonTemplateNull(); + var templateArgs = new Dictionary + { + ["ownerId"] = new JsonTemplateNull() + }; var templatesPath = Content.GetPath(Path.Combine("Sample", "Templates")); var templateFiles = Directory.GetFiles(templatesPath); diff --git a/src/SeqCli/Cli/Commands/SearchCommand.cs b/src/SeqCli/Cli/Commands/SearchCommand.cs index 001bca4d..151be6eb 100644 --- a/src/SeqCli/Cli/Commands/SearchCommand.cs +++ b/src/SeqCli/Cli/Commands/SearchCommand.cs @@ -34,20 +34,18 @@ namespace SeqCli.Cli.Commands; Example = "seqcli search -f \"@Exception like '%TimeoutException%'\" -c 30")] class SearchCommand : Command { - readonly SeqConnectionFactory _connectionFactory; readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; readonly DateRangeFeature _range; readonly SignalExpressionFeature _signal; + readonly StoragePathFeature _storagePath; string? _filter; int _count = 1; int _httpClientTimeout = 100000; bool _trace, _noWebSockets; - public SearchCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public SearchCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "f=|filter=", "A filter to apply to the search, for example `Host = 'xmpweb-01.example.com'`", @@ -58,7 +56,8 @@ public SearchCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config v => _count = int.Parse(v, CultureInfo.InvariantCulture)); _range = Enable(); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); _signal = Enable(); Options.Add( @@ -77,8 +76,9 @@ protected override async Task Run() { try { - await using var output = _output.CreateOutputLogger(); - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + await using var output = _output.GetOutputFormat(config).CreateOutputLogger(); + var connection = SeqConnectionFactory.Connect(_connection, config); connection.Client.HttpClient.Timeout = TimeSpan.FromMilliseconds(_httpClientTimeout); string? filter = null; diff --git a/src/SeqCli/Cli/Commands/Settings/ClearCommand.cs b/src/SeqCli/Cli/Commands/Settings/ClearCommand.cs index e4025de8..7d527e43 100644 --- a/src/SeqCli/Cli/Commands/Settings/ClearCommand.cs +++ b/src/SeqCli/Cli/Commands/Settings/ClearCommand.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ using System; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; namespace SeqCli.Cli.Commands.Settings; @@ -22,22 +23,21 @@ namespace SeqCli.Cli.Commands.Settings; [Command("setting", "clear", "Clear a runtime-configurable server setting")] class ClearCommand: Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly SettingNameFeature _name; - - public ClearCommand(SeqConnectionFactory connectionFactory) + readonly StoragePathFeature _storagePath; + + public ClearCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - _name = Enable(); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var setting = await connection.Settings.FindNamedAsync(_name.Name); setting.Value = null; diff --git a/src/SeqCli/Cli/Commands/Settings/NamesCommand.cs b/src/SeqCli/Cli/Commands/Settings/NamesCommand.cs index 40f060d5..d50345ab 100644 --- a/src/SeqCli/Cli/Commands/Settings/NamesCommand.cs +++ b/src/SeqCli/Cli/Commands/Settings/NamesCommand.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Cli/Commands/Settings/SetCommand.cs b/src/SeqCli/Cli/Commands/Settings/SetCommand.cs index 67ad8844..edc26154 100644 --- a/src/SeqCli/Cli/Commands/Settings/SetCommand.cs +++ b/src/SeqCli/Cli/Commands/Settings/SetCommand.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ using System; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using Serilog; @@ -23,18 +24,15 @@ namespace SeqCli.Cli.Commands.Settings; [Command("setting", "set", "Change a runtime-configurable server setting")] class SetCommand: Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly SettingNameFeature _name; + readonly StoragePathFeature _storagePath; string? _value; bool _valueSpecified, _readValueFromStdin; - public SetCommand(SeqConnectionFactory connectionFactory) + public SetCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - _name = Enable(); Options.Add("v|value=", @@ -51,6 +49,7 @@ public SetCommand(SeqConnectionFactory connectionFactory) _ => _readValueFromStdin = true); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() @@ -60,8 +59,9 @@ protected override async Task Run() Log.Error("A value must be supplied with either `--value=VALUE` or `--value-stdin`."); return 1; } - - var connection = _connectionFactory.Connect(_connection); + + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var setting = await connection.Settings.FindNamedAsync(_name.Name); setting.Value = ReadValue(); diff --git a/src/SeqCli/Cli/Commands/Settings/ShowCommand.cs b/src/SeqCli/Cli/Commands/Settings/ShowCommand.cs index e351b41d..f6f7ef5b 100644 --- a/src/SeqCli/Cli/Commands/Settings/ShowCommand.cs +++ b/src/SeqCli/Cli/Commands/Settings/ShowCommand.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ using System.Globalization; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; namespace SeqCli.Cli.Commands.Settings; @@ -23,22 +24,21 @@ namespace SeqCli.Cli.Commands.Settings; [Command("setting", "show", "Print the current value of a runtime-configurable server setting")] class ShowCommand: Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly SettingNameFeature _name; - - public ShowCommand(SeqConnectionFactory connectionFactory) + readonly StoragePathFeature _storagePath; + + public ShowCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - _name = Enable(); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var setting = await connection.Settings.FindNamedAsync(_name.Name); diff --git a/src/SeqCli/Cli/Commands/Shared/UpdateCommand.cs b/src/SeqCli/Cli/Commands/Shared/UpdateCommand.cs index df555ee8..de40eaa5 100644 --- a/src/SeqCli/Cli/Commands/Shared/UpdateCommand.cs +++ b/src/SeqCli/Cli/Commands/Shared/UpdateCommand.cs @@ -16,6 +16,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using SeqCli.Templates.Ast; using SeqCli.Templates.Import; @@ -27,18 +28,16 @@ namespace SeqCli.Cli.Commands.Shared; abstract class UpdateCommand: Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; + readonly StoragePathFeature _storagePath; readonly string _resourceGroupName; readonly string _entityName; string? _json; bool _jsonStdin; - protected UpdateCommand(SeqConnectionFactory connectionFactory, string commandGroupName, string resourceGroupName, string? entityName = null) + protected UpdateCommand(string commandGroupName, string resourceGroupName, string? entityName = null) { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); _resourceGroupName = resourceGroupName; _entityName = entityName ?? commandGroupName; @@ -53,11 +52,13 @@ protected UpdateCommand(SeqConnectionFactory connectionFactory, string commandGr _ => _jsonStdin = true); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); if (_json == null && !_jsonStdin) { diff --git a/src/SeqCli/Cli/Commands/Signal/CreateCommand.cs b/src/SeqCli/Cli/Commands/Signal/CreateCommand.cs index eb6ae8ff..16cb2f99 100644 --- a/src/SeqCli/Cli/Commands/Signal/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/Signal/CreateCommand.cs @@ -29,20 +29,17 @@ namespace SeqCli.Cli.Commands.Signal; Example = "seqcli signal create -t 'Exceptions' -f \"@Exception is not null\"")] class CreateCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; - + readonly StoragePathFeature _storagePath; + readonly List _columns = new(); string? _title, _description, _filter, _group; bool _isProtected, _noGrouping; - public CreateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public CreateCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "t=|title=", "A title for the signal", @@ -79,12 +76,14 @@ public CreateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config _ => _isProtected = true); _connection = Enable(); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var signal = await connection.Signals.TemplateAsync(); signal.OwnerId = null; @@ -124,7 +123,7 @@ protected override async Task Run() signal = await connection.Signals.AddAsync(signal); - _output.WriteEntity(signal); + _output.GetOutputFormat(config).WriteEntity(signal); return 0; } diff --git a/src/SeqCli/Cli/Commands/Signal/ImportCommand.cs b/src/SeqCli/Cli/Commands/Signal/ImportCommand.cs index eb0750dc..b9c79f78 100644 --- a/src/SeqCli/Cli/Commands/Signal/ImportCommand.cs +++ b/src/SeqCli/Cli/Commands/Signal/ImportCommand.cs @@ -28,11 +28,11 @@ namespace SeqCli.Cli.Commands.Signal; Example="seqcli signal import -i ./Exceptions.json")] class ImportCommand : Command { - readonly SeqConnectionFactory _connectionFactory; readonly FileInputFeature _fileInputFeature; readonly EntityOwnerFeature _entityOwner; readonly ConnectionFeature _connection; - + readonly StoragePathFeature _storagePath; + bool _merge; readonly JsonSerializer _serializer = JsonSerializer.Create( @@ -40,11 +40,8 @@ class ImportCommand : Command Converters = { new StringEnumConverter() } }); - public ImportCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public ImportCommand() { - if (config == null) throw new ArgumentNullException(nameof(config)); - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "merge", "Update signals that have ids matching those in the imported data; the default is to always create new signals", @@ -53,11 +50,13 @@ public ImportCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config _fileInputFeature = Enable(new FileInputFeature("File to import")); _entityOwner = Enable(new EntityOwnerFeature("signal", "import", "imported")); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); using var input = _fileInputFeature.OpenSingleInput(); var line = await input.ReadLineAsync(); diff --git a/src/SeqCli/Cli/Commands/Signal/ListCommand.cs b/src/SeqCli/Cli/Commands/Signal/ListCommand.cs index a4ef83df..a035489c 100644 --- a/src/SeqCli/Cli/Commands/Signal/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Signal/ListCommand.cs @@ -24,34 +24,32 @@ namespace SeqCli.Cli.Commands.Signal; [Command("signal", "list", "List available signals", Example="seqcli signal list")] class ListCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly EntityIdentityFeature _entityIdentity; readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; readonly EntityOwnerFeature _entityOwner; - - public ListCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + readonly StoragePathFeature _storagePath; + + public ListCommand() { - if (config == null) throw new ArgumentNullException(nameof(config)); - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - _entityIdentity = Enable(new EntityIdentityFeature("signal", "list")); _entityOwner = Enable(new EntityOwnerFeature("signal", "list", "listed", _entityIdentity)); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); _connection = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var list = _entityIdentity.Id != null ? - new[] { await connection.Signals.FindAsync(_entityIdentity.Id) } : + var list = _entityIdentity.Id != null ? [await connection.Signals.FindAsync(_entityIdentity.Id)] + : (await connection.Signals.ListAsync(ownerId: _entityOwner.OwnerId, shared: _entityOwner.IncludeShared)) .Where(signal => _entityIdentity.Title == null || _entityIdentity.Title == signal.Title); - _output.ListEntities(list); + _output.GetOutputFormat(config).ListEntities(list); return 0; } diff --git a/src/SeqCli/Cli/Commands/Signal/RemoveCommand.cs b/src/SeqCli/Cli/Commands/Signal/RemoveCommand.cs index 895bdfa3..a989d7e5 100644 --- a/src/SeqCli/Cli/Commands/Signal/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/Signal/RemoveCommand.cs @@ -16,6 +16,7 @@ using System.Linq; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using Serilog; @@ -25,19 +26,17 @@ namespace SeqCli.Cli.Commands.Signal; Example = "seqcli signal remove -t 'Test Signal'")] class RemoveCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly EntityIdentityFeature _entityIdentity; readonly ConnectionFeature _connection; readonly EntityOwnerFeature _entityOwner; - - public RemoveCommand(SeqConnectionFactory connectionFactory) + readonly StoragePathFeature _storagePath; + + public RemoveCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - _entityIdentity = Enable(new EntityIdentityFeature("signal", "remove")); _entityOwner = Enable(new EntityOwnerFeature("signal", "remove", "removed", _entityIdentity)); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() @@ -48,10 +47,11 @@ protected override async Task Run() return 1; } - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var toRemove = _entityIdentity.Id != null ? - new[] { await connection.Signals.FindAsync(_entityIdentity.Id) } : + var toRemove = _entityIdentity.Id != null ? [await connection.Signals.FindAsync(_entityIdentity.Id)] + : (await connection.Signals.ListAsync(ownerId: _entityOwner.OwnerId, shared: _entityOwner.IncludeShared)) .Where(signal => _entityIdentity.Title == signal.Title) .ToArray(); diff --git a/src/SeqCli/Cli/Commands/Signal/UpdateCommand.cs b/src/SeqCli/Cli/Commands/Signal/UpdateCommand.cs index de734755..29f93fd4 100644 --- a/src/SeqCli/Cli/Commands/Signal/UpdateCommand.cs +++ b/src/SeqCli/Cli/Commands/Signal/UpdateCommand.cs @@ -20,6 +20,6 @@ namespace SeqCli.Cli.Commands.Signal; [Command("signal", "update", "Update an existing signal", Example="seqcli signal update --json '{...}'")] -class UpdateCommand(SeqConnectionFactory connectionFactory): - Shared.UpdateCommand(connectionFactory, "signal", nameof(SeqConnection.Signals)); +class UpdateCommand(): + Shared.UpdateCommand("signal", nameof(SeqConnection.Signals)); \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/TailCommand.cs b/src/SeqCli/Cli/Commands/TailCommand.cs index 0eab6644..ae8c5907 100644 --- a/src/SeqCli/Cli/Commands/TailCommand.cs +++ b/src/SeqCli/Cli/Commands/TailCommand.cs @@ -26,22 +26,21 @@ namespace SeqCli.Cli.Commands; // ReSharper disable once UnusedType.Global class TailCommand : Command { - readonly SeqConnectionFactory _connectionFactory; readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; readonly SignalExpressionFeature _signal; + readonly StoragePathFeature _storagePath; string? _filter; - public TailCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public TailCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "f=|filter=", "An optional server-side filter to apply to the stream, for example `@Level = 'Error'`", v => _filter = v); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); _signal = Enable(); _connection = Enable(); } @@ -50,8 +49,9 @@ protected override async Task Run() { var cancel = new CancellationTokenSource(); Console.CancelKeyPress += (_,_) => cancel.Cancel(); - - var connection = _connectionFactory.Connect(_connection); + + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); string? strict = null; if (!string.IsNullOrWhiteSpace(_filter)) @@ -60,7 +60,7 @@ protected override async Task Run() strict = converted.StrictExpression; } - await using var output = _output.CreateOutputLogger(); + await using var output = _output.GetOutputFormat(config).CreateOutputLogger(); try { diff --git a/src/SeqCli/Cli/Commands/Template/ExportCommand.cs b/src/SeqCli/Cli/Commands/Template/ExportCommand.cs index d9b654ec..71cefcf5 100644 --- a/src/SeqCli/Cli/Commands/Template/ExportCommand.cs +++ b/src/SeqCli/Cli/Commands/Template/ExportCommand.cs @@ -3,6 +3,7 @@ using System.IO; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using SeqCli.Templates.Export; using SeqCli.Util; @@ -16,16 +17,14 @@ namespace SeqCli.Cli.Commands.Template; Example = "seqcli template export -o ./Templates")] class ExportCommand : Command { - readonly SeqConnectionFactory _connectionFactory; readonly ConnectionFeature _connection; - + readonly StoragePathFeature _storagePath; + readonly HashSet _include = new(); string? _outputDir = "."; - public ExportCommand(SeqConnectionFactory connectionFactory) + public ExportCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "o=|output=", "The directory in which to write template files; the directory must exist; any existing files with " + @@ -39,6 +38,7 @@ public ExportCommand(SeqConnectionFactory connectionFactory) i => _include.Add(ArgumentString.Normalize(i))); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() @@ -55,7 +55,8 @@ protected override async Task Run() return 1; } - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var export = new TemplateSetExporter(connection, _include, _outputDir); await export.ExportTemplateSet(); diff --git a/src/SeqCli/Cli/Commands/Template/ImportCommand.cs b/src/SeqCli/Cli/Commands/Template/ImportCommand.cs index eb611e02..2259b68d 100644 --- a/src/SeqCli/Cli/Commands/Template/ImportCommand.cs +++ b/src/SeqCli/Cli/Commands/Template/ImportCommand.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using SeqCli.Templates.Ast; using SeqCli.Templates.Export; @@ -22,17 +23,16 @@ namespace SeqCli.Cli.Commands.Template; Example = "seqcli template import -i ./Templates")] class ImportCommand : Command { - readonly SeqConnectionFactory _connectionFactory; readonly ConnectionFeature _connection; readonly PropertiesFeature _args; - + readonly StoragePathFeature _storagePath; + string? _inputDir = "."; string? _stateFile; bool _merge; - public ImportCommand(SeqConnectionFactory connectionFactory) + public ImportCommand() { - _connectionFactory = connectionFactory; Options.Add( "i=|input=", @@ -54,6 +54,7 @@ public ImportCommand(SeqConnectionFactory connectionFactory) _args = Enable(new PropertiesFeature("g", "arg", "Template arguments, e.g. `-g ownerId=user-314159`")); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() @@ -97,7 +98,8 @@ protected override async Task Run() _ => throw new NotSupportedException("Unexpected property type.") })); - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var err = await TemplateSetImporter.ImportAsync(templates, connection, args, state, _merge); await TemplateImportState.SaveAsync(stateFile, state); diff --git a/src/SeqCli/Cli/Commands/User/CreateCommand.cs b/src/SeqCli/Cli/Commands/User/CreateCommand.cs index 0b871d24..09a8064c 100644 --- a/src/SeqCli/Cli/Commands/User/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/User/CreateCommand.cs @@ -28,18 +28,15 @@ namespace SeqCli.Cli.Commands.User; Example = "seqcli user create -n alice -d 'Alice Example' -r 'User (read/write)' --password-stdin")] class CreateCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; - + readonly StoragePathFeature _storagePath; + string? _username, _displayName, _roleTitle, _filter, _emailAddress, _password; bool _passwordStdin, _noPasswordChange; - public CreateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public CreateCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "n=|name=", "A unique username for the user", @@ -81,12 +78,14 @@ public CreateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config _ => _noPasswordChange = true); _connection = Enable(); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var user = await connection.Users.TemplateAsync(); @@ -148,7 +147,7 @@ protected override async Task Run() user = await connection.Users.AddAsync(user); - _output.WriteEntity(user); + _output.GetOutputFormat(config).WriteEntity(user); return 0; } diff --git a/src/SeqCli/Cli/Commands/User/ListCommand.cs b/src/SeqCli/Cli/Commands/User/ListCommand.cs index d3220d1c..d7de035d 100644 --- a/src/SeqCli/Cli/Commands/User/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/User/ListCommand.cs @@ -24,32 +24,30 @@ namespace SeqCli.Cli.Commands.User; [Command("user", "list", "List users", Example="seqcli user list")] class ListCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly UserIdentityFeature _userIdentity; readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; - - public ListCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + readonly StoragePathFeature _storagePath; + + public ListCommand() { - if (config == null) throw new ArgumentNullException(nameof(config)); - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - _userIdentity = Enable(new UserIdentityFeature("list")); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); _connection = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var list = _userIdentity.Id != null ? - new[] { await connection.Users.FindAsync(_userIdentity.Id) } : + var list = _userIdentity.Id != null ? [await connection.Users.FindAsync(_userIdentity.Id)] + : (await connection.Users.ListAsync()) .Where(u => _userIdentity.Name == null || _userIdentity.Name == u.Username); - _output.ListEntities(list); + _output.GetOutputFormat(config).ListEntities(list); return 0; } diff --git a/src/SeqCli/Cli/Commands/User/RemoveCommand.cs b/src/SeqCli/Cli/Commands/User/RemoveCommand.cs index 51b6127c..082ec8e7 100644 --- a/src/SeqCli/Cli/Commands/User/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/User/RemoveCommand.cs @@ -16,6 +16,7 @@ using System.Linq; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using Serilog; @@ -25,17 +26,15 @@ namespace SeqCli.Cli.Commands.User; Example="seqcli user remove -n alice")] class RemoveCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly UserIdentityFeature _userIdentity; readonly ConnectionFeature _connection; - - public RemoveCommand(SeqConnectionFactory connectionFactory) + readonly StoragePathFeature _storagePath; + + public RemoveCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - _userIdentity = Enable(new UserIdentityFeature("remove")); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() @@ -46,10 +45,11 @@ protected override async Task Run() return 1; } - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var toRemove = _userIdentity.Id != null ? - new[] {await connection.Users.FindAsync(_userIdentity.Id)} : + var toRemove = _userIdentity.Id != null ? [await connection.Users.FindAsync(_userIdentity.Id)] + : (await connection.Users.ListAsync()) .Where(u => _userIdentity.Name == u.Username) .ToArray(); diff --git a/src/SeqCli/Cli/Commands/User/UpdateCommand.cs b/src/SeqCli/Cli/Commands/User/UpdateCommand.cs index d440037c..de30ec3f 100644 --- a/src/SeqCli/Cli/Commands/User/UpdateCommand.cs +++ b/src/SeqCli/Cli/Commands/User/UpdateCommand.cs @@ -20,6 +20,6 @@ namespace SeqCli.Cli.Commands.User; [Command("user", "update", "Update an existing user", Example="seqcli user update --json '{...}'")] -class UpdateCommand(SeqConnectionFactory connectionFactory): - Shared.UpdateCommand(connectionFactory, "user", nameof(SeqConnection.Users)); +class UpdateCommand(): + Shared.UpdateCommand("user", nameof(SeqConnection.Users)); \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Workspace/CreateCommand.cs b/src/SeqCli/Cli/Commands/Workspace/CreateCommand.cs index f0c3262e..52103479 100644 --- a/src/SeqCli/Cli/Commands/Workspace/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/Workspace/CreateCommand.cs @@ -15,19 +15,16 @@ namespace SeqCli.Cli.Commands.Workspace; Example = "seqcli workspace create -t 'My Workspace' -c signal-314159 -c dashboard-628318")] class CreateCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; - + readonly StoragePathFeature _storagePath; + string? _title, _description; bool _isProtected; readonly List _include = new(); - public CreateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + public CreateCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - Options.Add( "t=|title=", "A title for the workspace", @@ -49,12 +46,14 @@ public CreateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config _ => _isProtected = true); _connection = Enable(); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); var workspace = await connection.Workspaces.TemplateAsync(); workspace.OwnerId = null; @@ -69,7 +68,7 @@ protected override async Task Run() workspace = await connection.Workspaces.AddAsync(workspace); - _output.WriteEntity(workspace); + _output.GetOutputFormat(config).WriteEntity(workspace); return 0; } diff --git a/src/SeqCli/Cli/Commands/Workspace/ListCommand.cs b/src/SeqCli/Cli/Commands/Workspace/ListCommand.cs index 7b3ed432..9212b5e6 100644 --- a/src/SeqCli/Cli/Commands/Workspace/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Workspace/ListCommand.cs @@ -10,34 +10,32 @@ namespace SeqCli.Cli.Commands.Workspace; [Command("workspace", "list", "List available workspaces", Example = "seqcli workspace list")] class ListCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly EntityIdentityFeature _entityIdentity; readonly ConnectionFeature _connection; readonly OutputFormatFeature _output; readonly EntityOwnerFeature _entityOwner; - - public ListCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + readonly StoragePathFeature _storagePath; + + public ListCommand() { - if (config == null) throw new ArgumentNullException(nameof(config)); - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - _entityIdentity = Enable(new EntityIdentityFeature("workspace", "list")); _entityOwner = Enable(new EntityOwnerFeature("workspace", "list", "listed", _entityIdentity)); - _output = Enable(new OutputFormatFeature(config.Output)); + _output = Enable(); + _storagePath = Enable(); _connection = Enable(); } protected override async Task Run() { - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var list = _entityIdentity.Id != null ? - new[] { await connection.Workspaces.FindAsync(_entityIdentity.Id) } : + var list = _entityIdentity.Id != null ? [await connection.Workspaces.FindAsync(_entityIdentity.Id)] + : (await connection.Workspaces.ListAsync(ownerId: _entityOwner.OwnerId, shared: _entityOwner.IncludeShared)) .Where(workspace => _entityIdentity.Title == null || _entityIdentity.Title == workspace.Title); - _output.ListEntities(list); + _output.GetOutputFormat(config).ListEntities(list); return 0; } diff --git a/src/SeqCli/Cli/Commands/Workspace/RemoveCommand.cs b/src/SeqCli/Cli/Commands/Workspace/RemoveCommand.cs index 9e4efbcf..6208f068 100644 --- a/src/SeqCli/Cli/Commands/Workspace/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/Workspace/RemoveCommand.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using SeqCli.Cli.Features; +using SeqCli.Config; using SeqCli.Connection; using Serilog; @@ -11,19 +12,17 @@ namespace SeqCli.Cli.Commands.Workspace; Example = "seqcli workspace remove -t 'My Workspace'")] class RemoveCommand : Command { - readonly SeqConnectionFactory _connectionFactory; - readonly EntityIdentityFeature _entityIdentity; readonly ConnectionFeature _connection; readonly EntityOwnerFeature _entityOwner; - - public RemoveCommand(SeqConnectionFactory connectionFactory) + readonly StoragePathFeature _storagePath; + + public RemoveCommand() { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - _entityIdentity = Enable(new EntityIdentityFeature("workspace", "remove")); _entityOwner = Enable(new EntityOwnerFeature("workspace", "remove", "removed", _entityIdentity)); _connection = Enable(); + _storagePath = Enable(); } protected override async Task Run() @@ -34,10 +33,11 @@ protected override async Task Run() return 1; } - var connection = _connectionFactory.Connect(_connection); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var toRemove = _entityIdentity.Id != null ? - new[] { await connection.Workspaces.FindAsync(_entityIdentity.Id) } : + var toRemove = _entityIdentity.Id != null ? [await connection.Workspaces.FindAsync(_entityIdentity.Id)] + : (await connection.Workspaces.ListAsync(ownerId: _entityOwner.OwnerId, shared: _entityOwner.IncludeShared)) .Where(workspace => _entityIdentity.Title == workspace.Title) .ToArray(); diff --git a/src/SeqCli/Cli/Commands/Workspace/UpdateCommand.cs b/src/SeqCli/Cli/Commands/Workspace/UpdateCommand.cs index c457506d..121b7511 100644 --- a/src/SeqCli/Cli/Commands/Workspace/UpdateCommand.cs +++ b/src/SeqCli/Cli/Commands/Workspace/UpdateCommand.cs @@ -20,6 +20,6 @@ namespace SeqCli.Cli.Commands.Workspace; [Command("workspace", "update", "Update an existing workspace", Example="seqcli workspace update --json '{...}'")] -class UpdateCommand(SeqConnectionFactory connectionFactory): - Shared.UpdateCommand(connectionFactory, "workspace", nameof(SeqConnection.Workspaces)); +class UpdateCommand(): + Shared.UpdateCommand("workspace", nameof(SeqConnection.Workspaces)); \ No newline at end of file diff --git a/src/SeqCli/Cli/Features/ListenUriFeature.cs b/src/SeqCli/Cli/Features/ListenUriFeature.cs new file mode 100644 index 00000000..016543ee --- /dev/null +++ b/src/SeqCli/Cli/Features/ListenUriFeature.cs @@ -0,0 +1,13 @@ +namespace SeqCli.Cli.Features; + +class ListenUriFeature : CommandFeature +{ + public string? ListenUri { get; private set; } + + public override void Enable(OptionSet options) + { + options.Add("l=|listen=", + "Set the address `seqcli forwarder` will listen at; http://127.0.0.1:15341/ is used by default.", + v => ListenUri = v); + } +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Features/OutputFormat.cs b/src/SeqCli/Cli/Features/OutputFormat.cs new file mode 100644 index 00000000..3af5b881 --- /dev/null +++ b/src/SeqCli/Cli/Features/OutputFormat.cs @@ -0,0 +1,176 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using Seq.Api.Model; +using SeqCli.Csv; +using SeqCli.Output; +using SeqCli.Util; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using Serilog.Sinks.SystemConsole.Themes; +using Serilog.Templates.Themes; + +namespace SeqCli.Cli.Features; + +sealed class OutputFormat(bool json, bool noColor, bool forceColor) +{ + public const string DefaultOutputTemplate = + "[{Timestamp:o} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"; + + public static readonly ConsoleTheme DefaultAnsiTheme = AnsiConsoleTheme.Code; + + public static readonly ConsoleTheme DefaultTheme = + OperatingSystem.IsWindows() ? SystemConsoleTheme.Literate : DefaultAnsiTheme; + + static readonly TemplateTheme DefaultTemplateTheme = Serilog.Templates.Themes.TemplateTheme.Code; + + public bool Json => json; + + bool ApplyThemeToRedirectedOutput => noColor == false && forceColor; + + ConsoleTheme Theme + => noColor ? ConsoleTheme.None + : ApplyThemeToRedirectedOutput ? DefaultAnsiTheme + : DefaultTheme; + + TemplateTheme? TemplateTheme + => noColor ? null + : ApplyThemeToRedirectedOutput ? DefaultTemplateTheme + : null; + + public Logger CreateOutputLogger() + { + var outputConfiguration = new LoggerConfiguration() + .MinimumLevel.Is(LevelAlias.Minimum) + .Enrich.With(); + + if (json) + { + outputConfiguration.WriteTo.Console(OutputFormatter.Json(TemplateTheme)); + } + else + { + outputConfiguration.WriteTo.Console( + outputTemplate: DefaultOutputTemplate, + theme: Theme, + applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput); + } + + return outputConfiguration.CreateLogger(); + } + + public void WriteCsv(string csv) + { + if (noColor ) + { + Console.Write(csv); + } + else + { + var tokens = new CsvTokenizer().Tokenize(csv); + CsvWriter.WriteCsv(tokens, Theme, Console.Out, true); + } + } + + public void WriteEntity(Entity entity) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + + var jo = JObject.FromObject( + entity, + JsonSerializer.CreateDefault(new JsonSerializerSettings { + DateParseHandling = DateParseHandling.None, + Converters = { + new StringEnumConverter() + } + })); + + if (json) + { + jo.Remove("Links"); + // Proof-of-concept; this is a very inefficient + // way to write colorized JSON ;) + + var writer = new LoggerConfiguration() + .Destructure.With() + .Enrich.With() + .WriteTo.Console( + outputTemplate: "{@Message:j}{NewLine}", + theme: Theme, + applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput) + .CreateLogger(); + writer.Information("{@Entity}", jo); + } + else + { + var dyn = (dynamic) jo; + Console.WriteLine($"{entity.Id} {dyn.Title ?? dyn.Name ?? dyn.Username ?? dyn.Expression}"); + } + } + + public void WriteObject(object value) + { + if (value == null) throw new ArgumentNullException(nameof(value)); + + if (json) + { + var jo = JObject.FromObject( + value, + JsonSerializer.CreateDefault(new JsonSerializerSettings { + DateParseHandling = DateParseHandling.None, + Converters = { + new StringEnumConverter() + } + })); + + // Using the same method of JSON colorization as above + + var writer = new LoggerConfiguration() + .Destructure.With() + .Enrich.With() + .WriteTo.Console( + outputTemplate: "{@Message:j}{NewLine}", + theme: Theme, + applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput) + .CreateLogger(); + writer.Information("{@Entity}", jo); + } + else + { + Console.WriteLine(value.ToString()); + } + } + + public void ListEntities(IEnumerable list) + { + foreach (var entity in list) + { + WriteEntity(entity); + } + } + + // ReSharper disable once MemberCanBeMadeStatic.Global +#pragma warning disable CA1822 + public void WriteText(string? text) +#pragma warning restore CA1822 + { + Console.WriteLine(text?.TrimEnd()); + } +} diff --git a/src/SeqCli/Cli/Features/OutputFormatFeature.cs b/src/SeqCli/Cli/Features/OutputFormatFeature.cs index c3919a31..ded3629f 100644 --- a/src/SeqCli/Cli/Features/OutputFormatFeature.cs +++ b/src/SeqCli/Cli/Features/OutputFormatFeature.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// Copyright © Datalust Pty Ltd // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,55 +12,20 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; -using System.Collections.Generic; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using Newtonsoft.Json.Linq; -using Seq.Api.Model; using SeqCli.Config; -using SeqCli.Csv; -using SeqCli.Output; -using SeqCli.Util; -using Serilog; -using Serilog.Core; -using Serilog.Events; -using Serilog.Sinks.SystemConsole.Themes; -using Serilog.Templates.Themes; namespace SeqCli.Cli.Features; class OutputFormatFeature : CommandFeature { - public const string DefaultOutputTemplate = - "[{Timestamp:o} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"; + bool _json; + bool? _noColor, _forceColor; - public static readonly ConsoleTheme DefaultAnsiTheme = AnsiConsoleTheme.Code; - public static readonly ConsoleTheme DefaultTheme = OperatingSystem.IsWindows() ? SystemConsoleTheme.Literate : DefaultAnsiTheme; - static readonly TemplateTheme DefaultTemplateTheme = TemplateTheme.Code; - - bool _json, _noColor, _forceColor; - - public OutputFormatFeature(SeqCliOutputConfig outputConfig) + public OutputFormat GetOutputFormat(SeqCliConfig config) { - _noColor = outputConfig.DisableColor; - _forceColor = outputConfig.ForceColor; + return new OutputFormat(_json, _noColor ?? config.Output.DisableColor, _forceColor ?? config.Output.ForceColor); } - public bool Json => _json; - - bool ApplyThemeToRedirectedOutput => _noColor == false && _forceColor; - - ConsoleTheme Theme - => _noColor ? ConsoleTheme.None - : ApplyThemeToRedirectedOutput ? DefaultAnsiTheme - : DefaultTheme; - - TemplateTheme? TemplateTheme - => _noColor ? null - : ApplyThemeToRedirectedOutput ? DefaultTemplateTheme - : null; - public override void Enable(OptionSet options) { options.Add( @@ -74,121 +39,4 @@ public override void Enable(OptionSet options) "Force redirected output to have ANSI color (unless `--no-color` is also specified)", _ => _forceColor = true); } - - public Logger CreateOutputLogger() - { - var outputConfiguration = new LoggerConfiguration() - .MinimumLevel.Is(LevelAlias.Minimum) - .Enrich.With(); - - if (_json) - { - outputConfiguration.WriteTo.Console(OutputFormatter.Json(TemplateTheme)); - } - else - { - outputConfiguration.WriteTo.Console( - outputTemplate: DefaultOutputTemplate, - theme: Theme, - applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput); - } - - return outputConfiguration.CreateLogger(); - } - - public void WriteCsv(string csv) - { - if (_noColor) - { - Console.Write(csv); - } - else - { - var tokens = new CsvTokenizer().Tokenize(csv); - CsvWriter.WriteCsv(tokens, Theme, Console.Out, true); - } - } - - public void WriteEntity(Entity entity) - { - if (entity == null) throw new ArgumentNullException(nameof(entity)); - - var jo = JObject.FromObject( - entity, - JsonSerializer.CreateDefault(new JsonSerializerSettings { - DateParseHandling = DateParseHandling.None, - Converters = { - new StringEnumConverter() - } - })); - - if (_json) - { - jo.Remove("Links"); - // Proof-of-concept; this is a very inefficient - // way to write colorized JSON ;) - - var writer = new LoggerConfiguration() - .Destructure.With() - .Enrich.With() - .WriteTo.Console( - outputTemplate: "{@Message:j}{NewLine}", - theme: Theme, - applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput) - .CreateLogger(); - writer.Information("{@Entity}", jo); - } - else - { - var dyn = (dynamic) jo; - Console.WriteLine($"{entity.Id} {dyn.Title ?? dyn.Name ?? dyn.Username ?? dyn.Expression}"); - } - } - - public void WriteObject(object value) - { - if (value == null) throw new ArgumentNullException(nameof(value)); - - if (_json) - { - var jo = JObject.FromObject( - value, - JsonSerializer.CreateDefault(new JsonSerializerSettings { - DateParseHandling = DateParseHandling.None, - Converters = { - new StringEnumConverter() - } - })); - - // Using the same method of JSON colorization as above - - var writer = new LoggerConfiguration() - .Destructure.With() - .Enrich.With() - .WriteTo.Console( - outputTemplate: "{@Message:j}{NewLine}", - theme: Theme, - applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput) - .CreateLogger(); - writer.Information("{@Entity}", jo); - } - else - { - Console.WriteLine(value.ToString()); - } - } - - public void ListEntities(IEnumerable list) - { - foreach (var entity in list) - { - WriteEntity(entity); - } - } - - // ReSharper disable once MemberCanBeMadeStatic.Global - public void WriteText(string? text) - { - Console.WriteLine(text?.TrimEnd()); - } } diff --git a/src/SeqCli/Cli/Features/SendFailureHandlingFeature.cs b/src/SeqCli/Cli/Features/SendFailureHandlingFeature.cs index 624623ef..97892e73 100644 --- a/src/SeqCli/Cli/Features/SendFailureHandlingFeature.cs +++ b/src/SeqCli/Cli/Features/SendFailureHandlingFeature.cs @@ -25,6 +25,6 @@ public override void Enable(OptionSet options) { options.Add("send-failure=", "Specify how connection failures are handled: `fail` (default), `retry`, `continue`, or `ignore`", - v => SendFailureHandling = (SendFailureHandling)Enum.Parse(typeof(SendFailureHandling), v, ignoreCase: true)); + v => SendFailureHandling = Enum.Parse(v, ignoreCase: true)); } } \ No newline at end of file diff --git a/src/SeqCli/Cli/Features/ServiceCredentialsFeature.cs b/src/SeqCli/Cli/Features/ServiceCredentialsFeature.cs new file mode 100644 index 00000000..d82b15a8 --- /dev/null +++ b/src/SeqCli/Cli/Features/ServiceCredentialsFeature.cs @@ -0,0 +1,42 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if WINDOWS + +using SeqCli.Cli; + +namespace SeqCli.Forwarder.Cli.Features +{ + class ServiceCredentialsFeature : CommandFeature + { + public bool IsUsernameSpecified => !string.IsNullOrEmpty(Username); + public bool IsPasswordSpecified => !string.IsNullOrEmpty(Password); + + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; + + public override void Enable(OptionSet options) + { + options.Add("u=|username=", + "The name of a Windows account to run the service under; if not specified the `NT AUTHORITY\\LocalService` account will be used", + v => Username = v.Trim()); + + options.Add("p=|password=", + "The password for the Windows account to run the service under", + v => Password = v.Trim()); + } + } +} + +#endif diff --git a/src/SeqCli/Cli/Features/StoragePathFeature.cs b/src/SeqCli/Cli/Features/StoragePathFeature.cs new file mode 100644 index 00000000..e79f366a --- /dev/null +++ b/src/SeqCli/Cli/Features/StoragePathFeature.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; + +#if WINDOWS +using SeqCli.Forwarder.ServiceProcess; +#endif + +namespace SeqCli.Cli.Features; + +class StoragePathFeature : CommandFeature +{ + string? _storageRoot; + + public string StorageRootPath + { + get + { + if (!string.IsNullOrWhiteSpace(_storageRoot)) + return _storageRoot; + + return TryQueryInstalledStorageRoot() ?? GetDefaultStorageRoot(); + } + } + + public string ConfigFilePath => Path.Combine(StorageRootPath, "SeqCli.json"); + + public string BufferPath => Path.Combine(StorageRootPath, "SeqCli", "Buffer"); + + public string InternalLogPath => Path.Combine(StorageRootPath, "SeqCli", "Logs"); + + public override void Enable(OptionSet options) + { + options.Add("storage=", + "The folder where `SeqCli.json` and other data will be stored; " + + "`" + GetDefaultStorageRoot() + "` is used by default", + v => _storageRoot = Path.GetFullPath(v)); + } + + static string GetDefaultStorageRoot() + { + return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + + static string? TryQueryInstalledStorageRoot() + { +#if WINDOWS + if (Forwarder.Util.ServiceConfiguration.GetServiceStoragePath( + SeqCliForwarderWindowsService.WindowsServiceName, out var storage)) + return storage; +#endif + + return null; + } +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Features/TimeoutFeature.cs b/src/SeqCli/Cli/Features/TimeoutFeature.cs index d5fb066b..3530812e 100644 --- a/src/SeqCli/Cli/Features/TimeoutFeature.cs +++ b/src/SeqCli/Cli/Features/TimeoutFeature.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd +// Copyright © Datalust Pty Ltd // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Config/Forwarder/SeqCliForwarderApiConfig.cs b/src/SeqCli/Config/Forwarder/SeqCliForwarderApiConfig.cs new file mode 100644 index 00000000..136c6df1 --- /dev/null +++ b/src/SeqCli/Config/Forwarder/SeqCliForwarderApiConfig.cs @@ -0,0 +1,8 @@ +namespace SeqCli.Config.Forwarder; + +// ReSharper disable AutoPropertyCanBeMadeGetOnly.Global + +class SeqCliForwarderApiConfig +{ + public string ListenUri { get; set; } = "http://127.0.0.1:15341"; +} \ No newline at end of file diff --git a/src/SeqCli/Config/Forwarder/SeqCliForwarderConfig.cs b/src/SeqCli/Config/Forwarder/SeqCliForwarderConfig.cs new file mode 100644 index 00000000..1043d0c8 --- /dev/null +++ b/src/SeqCli/Config/Forwarder/SeqCliForwarderConfig.cs @@ -0,0 +1,10 @@ +namespace SeqCli.Config.Forwarder; + +// ReSharper disable AutoPropertyCanBeMadeGetOnly.Global + +class SeqCliForwarderConfig +{ + public SeqCliForwarderStorageConfig Storage { get; set; } = new(); + public SeqCliForwarderDiagnosticConfig Diagnostics { get; set; } = new(); + public SeqCliForwarderApiConfig Api { get; set; } = new(); +} \ No newline at end of file diff --git a/src/SeqCli/Config/Forwarder/SeqCliForwarderDiagnosticConfig.cs b/src/SeqCli/Config/Forwarder/SeqCliForwarderDiagnosticConfig.cs new file mode 100644 index 00000000..326257d9 --- /dev/null +++ b/src/SeqCli/Config/Forwarder/SeqCliForwarderDiagnosticConfig.cs @@ -0,0 +1,14 @@ +using Serilog.Events; +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable AutoPropertyCanBeMadeGetOnly.Global + +namespace SeqCli.Config.Forwarder; + +public class SeqCliForwarderDiagnosticConfig +{ + public LogEventLevel InternalLoggingLevel { get; set; } = LogEventLevel.Information; + public string? InternalLogServerUri { get; set; } + public string? InternalLogServerApiKey { get; set; } + public bool ExposeIngestionLog { get; set; } + public bool IngestionLogShowDetail { get; set; } +} \ No newline at end of file diff --git a/src/SeqCli/Config/Forwarder/SeqCliForwarderStorageConfig.cs b/src/SeqCli/Config/Forwarder/SeqCliForwarderStorageConfig.cs new file mode 100644 index 00000000..8f88badf --- /dev/null +++ b/src/SeqCli/Config/Forwarder/SeqCliForwarderStorageConfig.cs @@ -0,0 +1,8 @@ +namespace SeqCli.Config.Forwarder; + +// ReSharper disable AutoPropertyCanBeMadeGetOnly.Global + +public class SeqCliForwarderStorageConfig +{ + public ulong BufferSizeBytes { get; set; } = 67_108_864; +} \ No newline at end of file diff --git a/src/SeqCli/Config/RuntimeConfigurationLoader.cs b/src/SeqCli/Config/RuntimeConfigurationLoader.cs index 1550e7aa..9dee4560 100644 --- a/src/SeqCli/Config/RuntimeConfigurationLoader.cs +++ b/src/SeqCli/Config/RuntimeConfigurationLoader.cs @@ -12,25 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; -using System.IO; +using SeqCli.Cli.Features; namespace SeqCli.Config; static class RuntimeConfigurationLoader { - public static readonly string DefaultConfigFilename = - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "SeqCli.json"); - const string DefaultEnvironmentVariablePrefix = "SEQCLI_"; /// /// This is the method to use when loading configuration for runtime use. It will read the default configuration /// file, if any, and apply overrides from the environment. /// - public static SeqCliConfig Load() + public static SeqCliConfig Load(StoragePathFeature storage) { - var config = SeqCliConfig.ReadFromFile(DefaultConfigFilename); + var config = SeqCliConfig.ReadFromFile(storage.ConfigFilePath); EnvironmentOverrides.Apply(DefaultEnvironmentVariablePrefix, config); diff --git a/src/SeqCli/Config/SeqCliConfig.cs b/src/SeqCli/Config/SeqCliConfig.cs index 6efcbdde..4a0bec4f 100644 --- a/src/SeqCli/Config/SeqCliConfig.cs +++ b/src/SeqCli/Config/SeqCliConfig.cs @@ -18,6 +18,8 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Newtonsoft.Json.Serialization; +using SeqCli.Config.Forwarder; +// ReSharper disable UnusedAutoPropertyAccessor.Global // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global namespace SeqCli.Config; @@ -58,10 +60,6 @@ public static void WriteToFile(SeqCliConfig data, string filename) var content = JsonConvert.SerializeObject(data, Formatting.Indented, SerializerSettings); File.WriteAllText(filename, content); } - - public SeqCliConnectionConfig Connection { get; set; } = new(); - public SeqCliOutputConfig Output { get; set; } = new(); - public Dictionary Profiles { get; } = new(StringComparer.OrdinalIgnoreCase); /// /// Some configuration objects, for example those with environment overrides, should not be exported @@ -71,4 +69,10 @@ public void DisallowExport() { _exportable = false; } + + public SeqCliConnectionConfig Connection { get; set; } = new(); + public SeqCliOutputConfig Output { get; set; } = new(); + public SeqCliForwarderConfig Forwarder { get; set; } = new(); + public SeqCliEncryptionProviderConfig Encryption { get; set; } = new(); + public Dictionary Profiles { get; } = new(StringComparer.OrdinalIgnoreCase); } \ No newline at end of file diff --git a/src/SeqCli/Config/SeqCliConnectionConfig.cs b/src/SeqCli/Config/SeqCliConnectionConfig.cs index 072d733e..0fd7bd6a 100644 --- a/src/SeqCli/Config/SeqCliConnectionConfig.cs +++ b/src/SeqCli/Config/SeqCliConnectionConfig.cs @@ -13,7 +13,9 @@ // limitations under the License. using System; +using System.Text; using Newtonsoft.Json; +using SeqCli.Encryptor; using SeqCli.Util; namespace SeqCli.Config; @@ -22,39 +24,38 @@ class SeqCliConnectionConfig { const string ProtectedDataPrefix = "pd."; + static readonly Encoding ProtectedDataEncoding = new UTF8Encoding(false); + public string ServerUrl { get; set; } = "http://localhost:5341"; [JsonProperty("apiKey")] public string? EncodedApiKey { get; set; } - [JsonIgnore] - public string? ApiKey + public string? DecodeApiKey(IDataProtector dataProtector) { - get - { - if (string.IsNullOrWhiteSpace(EncodedApiKey)) - return null; - - if (!OperatingSystem.IsWindows()) - return EncodedApiKey; + if (string.IsNullOrWhiteSpace(EncodedApiKey)) + return null; + + if (!EncodedApiKey.StartsWith(ProtectedDataPrefix)) + return EncodedApiKey; - if (!EncodedApiKey.StartsWith(ProtectedDataPrefix)) - return EncodedApiKey; + return ProtectedDataEncoding.GetString(dataProtector.Decrypt(Convert.FromBase64String(EncodedApiKey[ProtectedDataPrefix.Length..]))); + } - return UserScopeDataProtection.Unprotect(EncodedApiKey.Substring(ProtectedDataPrefix.Length)); - } - set + public void EncodeApiKey(string? apiKey, IDataProtector dataProtector) + { + if (apiKey == null) { - if (string.IsNullOrWhiteSpace(value)) - { - EncodedApiKey = null; - return; - } - - if (OperatingSystem.IsWindows()) - EncodedApiKey = $"{ProtectedDataPrefix}{UserScopeDataProtection.Protect(value)}"; - else - EncodedApiKey = value; + EncodedApiKey = null; + return; } + + var encoded = dataProtector.Encrypt(ProtectedDataEncoding.GetBytes(apiKey)); + + EncodedApiKey = $"{ProtectedDataPrefix}{Convert.ToBase64String(encoded)}"; } + + public uint? PooledConnectionLifetimeMilliseconds { get; set; } = null; + public ulong EventBodyLimitBytes { get; set; } = 256 * 1024; + public ulong PayloadLimitBytes { get; set; } = 10 * 1024 * 1024; } \ No newline at end of file diff --git a/src/SeqCli/Config/SeqCliEncryptionProviderConfig.cs b/src/SeqCli/Config/SeqCliEncryptionProviderConfig.cs new file mode 100644 index 00000000..e7273707 --- /dev/null +++ b/src/SeqCli/Config/SeqCliEncryptionProviderConfig.cs @@ -0,0 +1,40 @@ +// Copyright 2024 Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using SeqCli.Encryptor; + +namespace SeqCli.Config; + +class SeqCliEncryptionProviderConfig +{ + public string? Encryptor { get; set; } + public string? EncryptorArgs { get; set; } + + public string? Decryptor { get; set; } + public string? DecryptorArgs { get; set; } + + public IDataProtector DataProtector() + { +#if WINDOWS + return new WindowsNativeDataProtector(); +#else + if (!string.IsNullOrWhiteSpace(Encryptor) && !string.IsNullOrWhiteSpace(Decryptor)) + { + return new ExternalDataProtector(this); + } + + return new PlaintextDataProtector(); +#endif + } +} \ No newline at end of file diff --git a/src/SeqCli/Config/SeqCliOutputConfig.cs b/src/SeqCli/Config/SeqCliOutputConfig.cs index 62b3a6f2..18529c90 100644 --- a/src/SeqCli/Config/SeqCliOutputConfig.cs +++ b/src/SeqCli/Config/SeqCliOutputConfig.cs @@ -12,6 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable AutoPropertyCanBeMadeGetOnly.Global + namespace SeqCli.Config; class SeqCliOutputConfig diff --git a/src/SeqCli/Connection/SeqConnectionFactory.cs b/src/SeqCli/Connection/SeqConnectionFactory.cs index 08ef3cfb..89596982 100644 --- a/src/SeqCli/Connection/SeqConnectionFactory.cs +++ b/src/SeqCli/Connection/SeqConnectionFactory.cs @@ -16,25 +16,19 @@ using Seq.Api; using SeqCli.Cli.Features; using SeqCli.Config; +using SeqCli.Encryptor; namespace SeqCli.Connection; -class SeqConnectionFactory +static class SeqConnectionFactory { - readonly SeqCliConfig _config; - - public SeqConnectionFactory(SeqCliConfig config) - { - _config = config ?? throw new ArgumentNullException(nameof(config)); - } - - public SeqConnection Connect(ConnectionFeature connection) + public static SeqConnection Connect(ConnectionFeature connection, SeqCliConfig config) { - var (url, apiKey) = GetConnectionDetails(connection); + var (url, apiKey) = GetConnectionDetails(connection, config); return new SeqConnection(url, apiKey); } - public (string? serverUrl, string? apiKey) GetConnectionDetails(ConnectionFeature connection) + public static (string? serverUrl, string? apiKey) GetConnectionDetails(ConnectionFeature connection, SeqCliConfig config) { if (connection == null) throw new ArgumentNullException(nameof(connection)); @@ -46,16 +40,16 @@ public SeqConnection Connect(ConnectionFeature connection) } else if (connection.IsProfileNameSpecified) { - if (!_config.Profiles.TryGetValue(connection.ProfileName!, out var profile)) + if (!config.Profiles.TryGetValue(connection.ProfileName!, out var profile)) throw new ArgumentException($"A profile named `{connection.ProfileName}` was not found; see `seqcli profile list` for available profiles."); url = profile.ServerUrl; - apiKey = profile.ApiKey; + apiKey = profile.DecodeApiKey(config.Encryption.DataProtector()); } else { - url = _config.Connection.ServerUrl; - apiKey = connection.IsApiKeySpecified ? connection.ApiKey : _config.Connection.ApiKey; + url = config.Connection.ServerUrl; + apiKey = connection.IsApiKeySpecified ? connection.ApiKey : config.Connection.DecodeApiKey(config.Encryption.DataProtector()); } return (url, apiKey); diff --git a/src/SeqCli/Csv/CsvTokenizer.cs b/src/SeqCli/Csv/CsvTokenizer.cs index aca5796a..2f3529a4 100644 --- a/src/SeqCli/Csv/CsvTokenizer.cs +++ b/src/SeqCli/Csv/CsvTokenizer.cs @@ -42,7 +42,7 @@ protected override IEnumerable> Tokenize(TextSpan span) if (next.Value != '"') { - yield return Result.Empty(next.Location, new[] {"double-quote"}); + yield return Result.Empty(next.Location, ["double-quote"]); yield break; } @@ -79,13 +79,13 @@ protected override IEnumerable> Tokenize(TextSpan span) } else { - yield return Result.Empty(next.Location, new[] {"comma", "newline"}); + yield return Result.Empty(next.Location, ["comma", "newline"]); yield break; } } else { - yield return Result.Empty(next.Location, new[] {"double-quote"}); + yield return Result.Empty(next.Location, ["double-quote"]); yield break; } diff --git a/src/SeqCli/Encryptor/ExternalDataProtector.cs b/src/SeqCli/Encryptor/ExternalDataProtector.cs new file mode 100644 index 00000000..0c84988b --- /dev/null +++ b/src/SeqCli/Encryptor/ExternalDataProtector.cs @@ -0,0 +1,116 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.Text; +using System.Threading; +using SeqCli.Config; + +namespace SeqCli.Encryptor; + +class ExternalDataProtector : IDataProtector +{ + public ExternalDataProtector(SeqCliEncryptionProviderConfig providerConfig) + { + _encryptor = providerConfig.Encryptor!; + _encryptorArgs = providerConfig.EncryptorArgs; + + _decryptor = providerConfig.Decryptor!; + _decryptorArgs = providerConfig.DecryptorArgs; + } + + readonly string _encryptor; + readonly string? _encryptorArgs; + readonly string _decryptor; + readonly string? _decryptorArgs; + + public byte[] Encrypt(byte[] unencrypted) + { + var exit = Invoke(_encryptor, _encryptorArgs, unencrypted, out var encrypted, out var err); + if (exit != 0) + { + throw new Exception($"Encryptor failed with exit code {exit} and produced: {err}"); + } + + return encrypted; + } + + public byte[] Decrypt(byte[] encrypted) + { + var exit = Invoke(_decryptor, _decryptorArgs, encrypted, out var decrypted, out var err); + if (exit != 0) + { + throw new Exception($"Decryptor failed with exit code {exit} and produced: {err}"); + } + + return decrypted; + } + + static int Invoke(string fullExePath, string? args, byte[] stdin, out byte[] stdout, out string stderr) + { + var startInfo = new ProcessStartInfo + { + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + ErrorDialog = false, + FileName = fullExePath, + Arguments = args + }; + + using var process = Process.Start(startInfo); + using var errorComplete = new ManualResetEvent(false); + + if (process == null) + throw new InvalidOperationException("The process did not start."); + + var stderrBuf = new StringBuilder(); + process.ErrorDataReceived += (_, e) => + { + if (e.Data == null) + // ReSharper disable once AccessToDisposedClosure + errorComplete.Set(); + else + stderrBuf.Append(e.Data); + }; + process.BeginErrorReadLine(); + + process.StandardInput.BaseStream.Write(stdin); + process.StandardInput.BaseStream.Close(); + + var stdoutBuf = ArrayPool.Shared.Rent(512); + var stdoutBufLength = 0; + while (true) + { + var remaining = stdoutBuf.Length - stdoutBufLength; + if (remaining == 0) + { + var newBuffer = ArrayPool.Shared.Rent(stdoutBuf.Length * 2); + stdoutBuf.CopyTo(newBuffer.AsSpan()); + + ArrayPool.Shared.Return(stdoutBuf); + stdoutBuf = newBuffer; + + remaining = stdoutBuf.Length - stdoutBufLength; + } + + var read = process.StandardOutput.BaseStream.Read(stdoutBuf, stdoutBufLength, remaining); + + if (read == 0) + { + break; + } + + stdoutBufLength += read; + } + + errorComplete.WaitOne(); + stderr = stderrBuf.ToString(); + + stdout = stdoutBuf.AsSpan()[..stdoutBufLength].ToArray(); + ArrayPool.Shared.Return(stdoutBuf); + + return process.ExitCode; + } +} \ No newline at end of file diff --git a/src/SeqCli/Encryptor/IDataProtector.cs b/src/SeqCli/Encryptor/IDataProtector.cs new file mode 100644 index 00000000..06db6d34 --- /dev/null +++ b/src/SeqCli/Encryptor/IDataProtector.cs @@ -0,0 +1,7 @@ +namespace SeqCli.Encryptor; + +public interface IDataProtector +{ + public byte[] Encrypt(byte[] unencrypted); + public byte[] Decrypt(byte[] encrypted); +} \ No newline at end of file diff --git a/src/SeqCli/Encryptor/PlaintextDataProtector.cs b/src/SeqCli/Encryptor/PlaintextDataProtector.cs new file mode 100644 index 00000000..e464002d --- /dev/null +++ b/src/SeqCli/Encryptor/PlaintextDataProtector.cs @@ -0,0 +1,14 @@ +namespace SeqCli.Encryptor; + +class PlaintextDataProtector : IDataProtector +{ + public byte[] Encrypt(byte[] unencrypted) + { + return unencrypted; + } + + public byte[] Decrypt(byte[] encrypted) + { + return encrypted; + } +} \ No newline at end of file diff --git a/src/SeqCli/Encryptor/WindowsNativeDataProtector.cs b/src/SeqCli/Encryptor/WindowsNativeDataProtector.cs new file mode 100644 index 00000000..278431f3 --- /dev/null +++ b/src/SeqCli/Encryptor/WindowsNativeDataProtector.cs @@ -0,0 +1,31 @@ +using System; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using SeqCli.Util; + +namespace SeqCli.Encryptor; + +class WindowsNativeDataProtector : IDataProtector +{ + public byte[] Encrypt(byte[] unencrypted) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + throw new PlatformNotSupportedException("Windows native encryption is only supported on Windows"); + + var salt = PasswordHash.GenerateSalt(); + var data = ProtectedData.Protect(unencrypted, salt, DataProtectionScope.LocalMachine); + + return [..data, ..salt]; + } + + public byte[] Decrypt(byte[] encrypted) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + throw new PlatformNotSupportedException("Windows native encryption is only supported on Windows"); + + var data = encrypted[..^16]; + var salt = encrypted[^16..]; + + return ProtectedData.Unprotect(data, salt, DataProtectionScope.LocalMachine); + } +} \ No newline at end of file diff --git a/src/SeqCli/Forwarder/Channel/ForwardingChannel.cs b/src/SeqCli/Forwarder/Channel/ForwardingChannel.cs new file mode 100644 index 00000000..cc3cc480 --- /dev/null +++ b/src/SeqCli/Forwarder/Channel/ForwardingChannel.cs @@ -0,0 +1,96 @@ +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.Forwarder.Storage; +using SeqCli.Ingestion; + +namespace SeqCli.Forwarder.Channel; + +class ForwardingChannel +{ + readonly ChannelWriter _writer; + readonly Task _writeWorker, _readWorker; + readonly CancellationTokenSource _stop; + readonly CancellationToken _hardCancel; + + public ForwardingChannel(BufferAppender appender, BufferReader reader, Bookmark bookmark, SeqConnection connection, string? apiKey, CancellationToken hardCancel) + { + var channel = System.Threading.Channels.Channel.CreateBounded(new BoundedChannelOptions(5) + { + SingleReader = false, + SingleWriter = true, + FullMode = BoundedChannelFullMode.Wait, + }); + + _stop = CancellationTokenSource.CreateLinkedTokenSource(_hardCancel); + _hardCancel = hardCancel; + _writer = channel.Writer; + _writeWorker = Task.Run(async () => + { + await foreach (var entry in channel.Reader.ReadAllAsync(hardCancel)) + { + try + { + // TODO: chunk sizes, max chunks, ingestion log + appender.TryAppend(entry.Data.AsSpan(), 100_000_000); + entry.CompletionSource.SetResult(); + } + catch (Exception e) + { + entry.CompletionSource.TrySetException(e); + } + } + }, cancellationToken: hardCancel); + + _readWorker = Task.Run(async () => + { + if (bookmark.TryGet(out var bookmarkValue)) + { + // TODO: initialize reader + // reader.AdvanceTo(bookmarkValue.Value); + } + + while (true) + { + if (_hardCancel.IsCancellationRequested) return; + + if (!reader.TryFillBatch(1024 * 1024, out var batch)) + { + await Task.Delay(100, hardCancel); + continue; + } + + await LogShipper.ShipBuffer(connection, apiKey, batch.Value.AsArraySegment(), SendFailureHandling.Retry); + + if (bookmark.TrySet(new BookmarkValue(batch.Value.ReaderHead.Chunk, + batch.Value.ReaderHead.CommitHead))) + { + reader.AdvanceTo(batch.Value.ReaderHead); + } + + batch.Value.Return(); + } + }, cancellationToken: hardCancel); + } + + public async Task WriteAsync(byte[] storage, Range range, CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _hardCancel); + + await _writer.WriteAsync(new ForwardingChannelEntry(storage[range], tcs), cts.Token); + await tcs.Task; + } + + public async Task StopAsync() + { + await _stop.CancelAsync(); + + _writer.Complete(); + await _writeWorker; + + await _readWorker; + } +} diff --git a/src/SeqCli/Forwarder/Channel/ForwardingChannelEntry.cs b/src/SeqCli/Forwarder/Channel/ForwardingChannelEntry.cs new file mode 100644 index 00000000..6b038637 --- /dev/null +++ b/src/SeqCli/Forwarder/Channel/ForwardingChannelEntry.cs @@ -0,0 +1,6 @@ +using System; +using System.Threading.Tasks; + +namespace SeqCli.Forwarder.Channel; + +readonly record struct ForwardingChannelEntry(ArraySegment Data, TaskCompletionSource CompletionSource); diff --git a/src/SeqCli/Forwarder/Channel/ForwardingChannelMap.cs b/src/SeqCli/Forwarder/Channel/ForwardingChannelMap.cs new file mode 100644 index 00000000..100ca72e --- /dev/null +++ b/src/SeqCli/Forwarder/Channel/ForwardingChannelMap.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.Forwarder.Filesystem.System; +using SeqCli.Forwarder.Storage; +using Serilog; + +namespace SeqCli.Forwarder.Channel; + +class ForwardingChannelMap +{ + readonly string _bufferPath; + readonly SeqConnection _connection; + readonly ForwardingChannel _defaultChannel; + readonly Lock _channelsSync = new(); + readonly Dictionary _channels = new(); + readonly CancellationTokenSource _shutdownTokenSource = new(); + + public ForwardingChannelMap(string bufferPath, SeqConnection connection, string? defaultApiKey) + { + _bufferPath = bufferPath; + _connection = connection; + _defaultChannel = OpenOrCreateChannel(defaultApiKey, "Default"); + + // TODO, load other channels at start-up + } + + ForwardingChannel OpenOrCreateChannel(string? apiKey, string name) + { + // TODO, when it's not the default, persist the API key and validate equality on reopen + + var storePath = Path.Combine(_bufferPath, name); + var defaultStore = new SystemStoreDirectory(storePath); + Log.Information("Opening local buffer in {StorePath}", storePath); + + return new ForwardingChannel( + BufferAppender.Open(defaultStore), + BufferReader.Open(defaultStore), + Bookmark.Open(defaultStore), + _connection, + apiKey, + _shutdownTokenSource.Token); + } + + public ForwardingChannel Get(string? apiKey) + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + return _defaultChannel; + } + + lock (_channelsSync) + { + if (_channels.TryGetValue(apiKey, out var channel)) + { + return channel; + } + + // Seq API keys begin with four identifying characters that aren't considered part of the + // confidential key. TODO: we could likely do better than this. + var name = apiKey[..4]; + var created = OpenOrCreateChannel(apiKey, name); + _channels.Add(apiKey, created); + return created; + } + } + + public async Task StopAsync() + { + Log.Information("Flushing log buffers"); + + _shutdownTokenSource.CancelAfter(TimeSpan.FromSeconds(30)); + + Task[] stopChannels; + lock (_channelsSync) + { + stopChannels = _channels.Values.Select(ch => ch.StopAsync()).ToArray(); + } + + await Task.WhenAll([ + _defaultChannel.StopAsync(), + ..stopChannels]); + + await _shutdownTokenSource.CancelAsync(); + } +} \ No newline at end of file diff --git a/src/SeqCli/Forwarder/Diagnostics/InMemorySink.cs b/src/SeqCli/Forwarder/Diagnostics/InMemorySink.cs new file mode 100644 index 00000000..1fd0f896 --- /dev/null +++ b/src/SeqCli/Forwarder/Diagnostics/InMemorySink.cs @@ -0,0 +1,48 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using Serilog.Core; +using Serilog.Events; + +namespace SeqCli.Forwarder.Diagnostics; + +class InMemorySink : ILogEventSink +{ + readonly int _queueLength; + readonly ConcurrentQueue _queue = new(); + + public InMemorySink(int queueLength) + { + _queueLength = queueLength; + } + + public IEnumerable Read() + { + return _queue.ToArray(); + } + + public void Emit(LogEvent logEvent) + { + ArgumentNullException.ThrowIfNull(logEvent); + _queue.Enqueue(logEvent); + + while (_queue.Count > _queueLength) + { + _queue.TryDequeue(out _); + } + } +} \ No newline at end of file diff --git a/src/SeqCli/Forwarder/Diagnostics/IngestionLog.cs b/src/SeqCli/Forwarder/Diagnostics/IngestionLog.cs new file mode 100644 index 00000000..e3fbadf2 --- /dev/null +++ b/src/SeqCli/Forwarder/Diagnostics/IngestionLog.cs @@ -0,0 +1,64 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Net; +using Serilog; +using Serilog.Events; + +namespace SeqCli.Forwarder.Diagnostics; + +static class IngestionLog +{ + const int Capacity = 100; + + static readonly InMemorySink Sink = new(Capacity); + + public static ILogger Log { get; } + + static IngestionLog() + { + Log = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.Sink(Sink) + .WriteTo.Logger(Serilog.Log.Logger) + .CreateLogger(); + } + + public static IEnumerable Read() + { + return Sink.Read(); + } + + public static ILogger ForClient(IPAddress? clientHostIP) + { + return Log.ForContext("ClientHostIP", clientHostIP); + } + + public static ILogger ForPayload(IPAddress clientHostIP, string payload) + { + var prefix = CapturePrefix(payload); + return ForClient(clientHostIP) + .ForContext("StartToLog", prefix.Length) + .ForContext("DocumentStart", prefix); + } + + static string CapturePrefix(string line) + { + if (line == null) throw new ArgumentNullException(nameof(line)); + var startToLog = Math.Min(line.Length, 1024); + return line.Substring(0, startToLog); + } +} \ No newline at end of file diff --git a/src/SeqCli/Forwarder/Filesystem/EmptyStoreFileReader.cs b/src/SeqCli/Forwarder/Filesystem/EmptyStoreFileReader.cs new file mode 100644 index 00000000..bc8ac092 --- /dev/null +++ b/src/SeqCli/Forwarder/Filesystem/EmptyStoreFileReader.cs @@ -0,0 +1,29 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace SeqCli.Forwarder.Filesystem; + +sealed class EmptyStoreFileReader : StoreFileReader +{ + public override void Dispose() + { + } + + public override long CopyTo(Span buffer, long from = 0, long? length = null) + { + return 0; + } +} diff --git a/src/SeqCli/Forwarder/Filesystem/StoreDirectory.cs b/src/SeqCli/Forwarder/Filesystem/StoreDirectory.cs new file mode 100644 index 00000000..f6f34d36 --- /dev/null +++ b/src/SeqCli/Forwarder/Filesystem/StoreDirectory.cs @@ -0,0 +1,91 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace SeqCli.Forwarder.Filesystem; + +/// +/// A container of s and their names. +/// +abstract class StoreDirectory +{ + /// + /// Create a new file with the given name, linking it into the filesystem. + /// + public abstract StoreFile Create(string name); + + protected virtual (string, StoreFile) CreateTemporary() + { + var tmpName = $"rc{Guid.NewGuid():N}.tmp"; + return (tmpName, Create(tmpName)); + } + + /// + /// Delete a file with the given name, returning whether the file was deleted. + /// + public abstract bool TryDelete(string name); + + /// + /// Atomically replace the contents of one file with another, creating it if it doesn't exist and deleting the other. + /// + public abstract StoreFile Replace(string toReplace, string replaceWith); + + /// + /// Atomically replace the contents of a file. + /// + public virtual StoreFile ReplaceContents(string name, Span contents, bool sync = true) + { + var (tmpName, tmpFile) = CreateTemporary(); + + try + { + if (!tmpFile.TryOpenAppend(out var opened)) + throw new Exception("Failed to write to a temporary file that was just created."); + + using var writer = opened; + writer.Append(contents); + writer.Commit(); + + if (sync) writer.Sync(); + } + catch + { + TryDelete(tmpName); + throw; + } + + return Replace(name, tmpName); + } + + /// + /// List all files in unspecified order. + /// + public abstract IEnumerable<(string Name, StoreFile File)> List(Func predicate); + + /// + /// Try get a file by name. + /// + public virtual bool TryGet(string name, [NotNullWhen(true)] out StoreFile? file) + { + file = List(n => n == name) + .Select(p => p.File) + .FirstOrDefault(); + + return file != null; + } +} diff --git a/src/SeqCli/Forwarder/Filesystem/StoreFile.cs b/src/SeqCli/Forwarder/Filesystem/StoreFile.cs new file mode 100644 index 00000000..1beed35f --- /dev/null +++ b/src/SeqCli/Forwarder/Filesystem/StoreFile.cs @@ -0,0 +1,65 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace SeqCli.Forwarder.Filesystem; + +abstract class StoreFile +{ + /// + /// Get the length of this file. + /// + /// + /// True if the length was read, false if the file is invalid. + /// + public abstract bool TryGetLength([NotNullWhen(true)] out long? length); + + /// + /// Read the complete contents of this file. + /// + /// + /// The number of bytes copied. + /// + public virtual long CopyContentsTo(Span buffer) + { + if (!TryGetLength(out var length)) throw new Exception("Failed to get the length of a file."); + + if (!TryOpenRead(length.Value, out var opened)) throw new Exception("Failed to open a reader to a file."); + + using var reader = opened; + return reader.CopyTo(buffer); + } + + /// + /// Try open a reader to the file. + /// + /// + /// True if the file was opened for reading, false if the file is invalid. + /// + public abstract bool TryOpenRead(long length, [NotNullWhen(true)] out StoreFileReader? reader); + + /// + /// Open a writer to the file. + /// + /// + /// Only a single writer to a file should be open at a given time. + /// Overlapping writers may result in data corruption. + /// + /// + /// True if the file was opened for writing, false if the file is invalid. + /// + public abstract bool TryOpenAppend([NotNullWhen(true)] out StoreFileAppender? appender); +} diff --git a/src/SeqCli/Forwarder/Filesystem/StoreFileAppender.cs b/src/SeqCli/Forwarder/Filesystem/StoreFileAppender.cs new file mode 100644 index 00000000..31f7af4d --- /dev/null +++ b/src/SeqCli/Forwarder/Filesystem/StoreFileAppender.cs @@ -0,0 +1,37 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace SeqCli.Forwarder.Filesystem; + +abstract class StoreFileAppender : IDisposable +{ + public abstract void Dispose(); + + /// + /// Append the given data to the end of the file. + /// + public abstract void Append(Span data); + + /// + /// Commit all appended data to underlying storage. + /// + public abstract long Commit(); + + /// + /// Durably sync committed data to underlying storage. + /// + public abstract void Sync(); +} diff --git a/src/SeqCli/Forwarder/Filesystem/StoreFileReader.cs b/src/SeqCli/Forwarder/Filesystem/StoreFileReader.cs new file mode 100644 index 00000000..5679c914 --- /dev/null +++ b/src/SeqCli/Forwarder/Filesystem/StoreFileReader.cs @@ -0,0 +1,27 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace SeqCli.Forwarder.Filesystem; + +abstract class StoreFileReader : IDisposable +{ + public abstract void Dispose(); + + /// + /// Copy the complete contents of the reader to the given buffer. + /// + public abstract long CopyTo(Span buffer, long from = 0, long? length = null); +} diff --git a/src/SeqCli/Forwarder/Filesystem/System/SystemStoreDirectory.cs b/src/SeqCli/Forwarder/Filesystem/System/SystemStoreDirectory.cs new file mode 100644 index 00000000..c1998e73 --- /dev/null +++ b/src/SeqCli/Forwarder/Filesystem/System/SystemStoreDirectory.cs @@ -0,0 +1,128 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; + +#if UNIX +using SeqCli.Forwarder.Filesystem.System.Unix; +#endif + +namespace SeqCli.Forwarder.Filesystem.System; + +sealed class SystemStoreDirectory : StoreDirectory +{ + readonly string _directoryPath; + + public SystemStoreDirectory(string path) + { + _directoryPath = Path.GetFullPath(path); + + if (!Directory.Exists(_directoryPath)) Directory.CreateDirectory(_directoryPath); + } + + public override SystemStoreFile Create(string name) + { + var filePath = Path.Combine(_directoryPath, name); + using var _ = File.OpenHandle(filePath, FileMode.Create, FileAccess.ReadWrite, + FileShare.ReadWrite | FileShare.Delete); + Dirsync(_directoryPath); + + return new SystemStoreFile(filePath); + } + + protected override (string, StoreFile) CreateTemporary() + { + // Temporary files are still created in the same directory + // This is necessary for renames to be atomic on some filesystems + var tmpName = $"rc{Guid.NewGuid():N}.tmp"; + + var filePath = Path.Combine(_directoryPath, tmpName); + using var _ = File.OpenHandle(filePath, FileMode.CreateNew, FileAccess.ReadWrite, + FileShare.ReadWrite | FileShare.Delete, FileOptions.DeleteOnClose); + + return (tmpName, new SystemStoreFile(filePath)); + } + + public override bool TryDelete(string name) + { + var filePath = Path.Combine(_directoryPath, name); + + try + { + File.Delete(filePath); + return true; + } + catch (IOException) + { + return false; + } + } + + public override SystemStoreFile Replace(string toReplace, string replaceWith) + { + var filePath = Path.Combine(_directoryPath, toReplace); + + File.Replace(Path.Combine(_directoryPath, replaceWith), filePath, null); + + return new SystemStoreFile(filePath); + } + + public override StoreFile ReplaceContents(string name, Span contents, bool sync = true) + { + var filePath = Path.Combine(_directoryPath, name); + + using var file = File.Open(filePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, + FileShare.ReadWrite | FileShare.Delete); + + // NOTE: This will be atomic if: + // 1. The incoming contents are larger or equal in size to the length of the file + // 2. The incoming contents are page sized or smaller + file.Position = 0; + file.Write(contents); + + if (sync) file.Flush(true); + + return new SystemStoreFile(filePath); + } + + public override IEnumerable<(string Name, StoreFile File)> List(Func predicate) + { + foreach (var filePath in Directory.EnumerateFiles(_directoryPath)) + { + var name = Path.GetFileName(filePath); + + if (!predicate(name)) continue; + + yield return (name, new SystemStoreFile(filePath)); + } + } + + static void Dirsync(string directoryPath) + { +#if UNIX + var dir = Libc.open(directoryPath, 0); + if (dir == -1) return; + + // NOTE: directory syncing here is best-effort + // If it fails for any reason we simply carry on +#pragma warning disable CA1806 + Libc.fsync(dir); + Libc.close(dir); +#pragma warning restore CA1806 +#endif + } +} diff --git a/src/SeqCli/Forwarder/Filesystem/System/SystemStoreFile.cs b/src/SeqCli/Forwarder/Filesystem/System/SystemStoreFile.cs new file mode 100644 index 00000000..55b37375 --- /dev/null +++ b/src/SeqCli/Forwarder/Filesystem/System/SystemStoreFile.cs @@ -0,0 +1,122 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.IO.MemoryMappedFiles; + +namespace SeqCli.Forwarder.Filesystem.System; + +sealed class SystemStoreFile : StoreFile +{ + static readonly FileStreamOptions AppendOptions = new() + { + Mode = FileMode.Append, + Access = FileAccess.Write, + Share = FileShare.ReadWrite | FileShare.Delete + }; + + readonly string _filePath; + + internal SystemStoreFile(string filePath) + { + _filePath = filePath; + } + + public override bool TryGetLength([NotNullWhen(true)] out long? length) + { + try + { + length = new FileInfo(_filePath).Length; + return true; + } + catch (IOException) + { + length = null; + return false; + } + } + + public override bool TryOpenRead(long length, [NotNullWhen(true)] out StoreFileReader? reader) + { + MemoryMappedFile? disposeMmap = null; + MemoryMappedViewAccessor? disposeAccessor = null; + + // If the requested length is empty then just return a dummy reader + if (length == 0) + { + reader = new EmptyStoreFileReader(); + return true; + } + + try + { + using var file = File.OpenHandle(_filePath, FileMode.OpenOrCreate, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, FileOptions.SequentialScan); + + disposeMmap = MemoryMappedFile.CreateFromFile(file, null, 0, MemoryMappedFileAccess.Read, + HandleInheritability.None, + false); + disposeAccessor = disposeMmap.CreateViewAccessor(0, length, MemoryMappedFileAccess.Read); + + var mmap = disposeMmap; + var accessor = disposeAccessor; + + disposeMmap = null; + disposeAccessor = null; + + reader = new SystemStoreFileReader(mmap, accessor, length); + return true; + } + // Thrown if the file length is 0 + catch (ArgumentException) + { + reader = null; + return false; + } + // Thrown if the file is truncated while creating an accessor + catch (UnauthorizedAccessException) + { + reader = null; + return false; + } + // Thrown if the file is deleted + catch (IOException) + { + reader = null; + return false; + } + finally + { + disposeMmap?.Dispose(); + disposeAccessor?.Dispose(); + } + } + + public override bool TryOpenAppend([NotNullWhen(true)] out StoreFileAppender? appender) + { + try + { + var file = File.Open(_filePath, AppendOptions); + appender = new SystemStoreFileAppender(file); + return true; + } + catch (IOException) + { + appender = null; + return false; + } + } +} diff --git a/src/SeqCli/Forwarder/Filesystem/System/SystemStoreFileAppender.cs b/src/SeqCli/Forwarder/Filesystem/System/SystemStoreFileAppender.cs new file mode 100644 index 00000000..298ea8d2 --- /dev/null +++ b/src/SeqCli/Forwarder/Filesystem/System/SystemStoreFileAppender.cs @@ -0,0 +1,58 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.IO; + +namespace SeqCli.Forwarder.Filesystem.System; + +sealed class SystemStoreFileAppender : StoreFileAppender +{ + readonly FileStream _file; + long _initialLength; + long _written; + + internal SystemStoreFileAppender(FileStream file) + { + _file = file; + _initialLength = _file.Length; + _written = 0; + } + + public override void Append(Span data) + { + _written += data.Length; + _file.Write(data); + } + + public override long Commit() + { + var writeHead = _initialLength + _written; + + _initialLength = writeHead; + _written = 0; + + return writeHead; + } + + public override void Sync() + { + _file.Flush(true); + } + + public override void Dispose() + { + _file.Dispose(); + } +} diff --git a/src/SeqCli/Forwarder/Filesystem/System/SystemStoreFileReader.cs b/src/SeqCli/Forwarder/Filesystem/System/SystemStoreFileReader.cs new file mode 100644 index 00000000..22108eb5 --- /dev/null +++ b/src/SeqCli/Forwarder/Filesystem/System/SystemStoreFileReader.cs @@ -0,0 +1,52 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.IO.MemoryMappedFiles; + +namespace SeqCli.Forwarder.Filesystem.System; + +sealed class SystemStoreFileReader : StoreFileReader +{ + readonly MemoryMappedViewAccessor _accessor; + readonly MemoryMappedFile _file; + readonly long _length; + + internal SystemStoreFileReader(MemoryMappedFile file, MemoryMappedViewAccessor accessor, long length) + { + _file = file; + + _accessor = accessor; + _length = length; + } + + public override long CopyTo(Span buffer, long from = 0, long? length = null) + { + unsafe + { + var ptr = (byte*)_accessor.SafeMemoryMappedViewHandle.DangerousGetHandle(); + var memmap = new Span(ptr + from, (int)(length ?? _length)); + + memmap.CopyTo(buffer); + + return memmap.Length; + } + } + + public override void Dispose() + { + _accessor.Dispose(); + _file.Dispose(); + } +} diff --git a/src/SeqCli/Forwarder/Filesystem/System/Unix/Libc.cs b/src/SeqCli/Forwarder/Filesystem/System/Unix/Libc.cs new file mode 100644 index 00000000..4561a20a --- /dev/null +++ b/src/SeqCli/Forwarder/Filesystem/System/Unix/Libc.cs @@ -0,0 +1,31 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if UNIX +using System.Runtime.InteropServices; + +namespace SeqCli.Forwarder.Filesystem.System.Unix; + +static class Libc +{ + [DllImport("libc")] + public static extern int open(string path, int flags); + + [DllImport("libc")] + public static extern int close(int fd); + + [DllImport("libc")] + public static extern int fsync(int fd); +} +#endif diff --git a/src/SeqCli/Forwarder/ForwarderModule.cs b/src/SeqCli/Forwarder/ForwarderModule.cs new file mode 100644 index 00000000..f06280c4 --- /dev/null +++ b/src/SeqCli/Forwarder/ForwarderModule.cs @@ -0,0 +1,92 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Net.Http; +using System.Threading; +using Autofac; +using Seq.Api; +using SeqCli.Config; +using SeqCli.Forwarder.Channel; +using SeqCli.Forwarder.Web.Api; +using SeqCli.Forwarder.Web.Host; +using Serilog.Formatting.Display; + +namespace SeqCli.Forwarder; + +class ForwarderModule : Module +{ + readonly string _bufferPath; + readonly SeqCliConfig _config; + readonly SeqConnection _connection; + readonly string? _apiKey; + + public ForwarderModule(string bufferPath, SeqCliConfig config, SeqConnection connection, string? apiKey) + { + _bufferPath = bufferPath ?? throw new ArgumentNullException(nameof(bufferPath)); + _config = config ?? throw new ArgumentNullException(nameof(config)); + _connection = connection; + _apiKey = apiKey; + } + + protected override void Load(ContainerBuilder builder) + { + builder.RegisterType().SingleInstance(); + builder.Register(_ => new ForwardingChannelMap(_bufferPath, _connection, _apiKey)).SingleInstance(); + + builder.RegisterType().As(); + builder.RegisterType().As(); + + if (_config.Forwarder.Diagnostics.ExposeIngestionLog) + { + builder.RegisterType().As(); + } + + builder.RegisterInstance(new MessageTemplateTextFormatter( + "[{Timestamp:o} {Level:u3}] {Message}{NewLine}" + (_config.Forwarder.Diagnostics.IngestionLogShowDetail + ? "" + : "Client IP address: {ClientHostIP}{NewLine}First {StartToLog} characters of payload: {DocumentStart:l}{NewLine}{Exception}{NewLine}"))).SingleInstance(); + + builder.Register(c => + { + var config = c.Resolve(); + var baseUri = config.Connection.ServerUrl; + if (string.IsNullOrWhiteSpace(baseUri)) + throw new ArgumentException("The destination Seq server URL must be configured in SeqForwarder.json."); + + if (!baseUri.EndsWith("/")) + baseUri += "/"; + + // additional configuration options that require the use of SocketsHttpHandler should be added to + // this expression, using an "or" operator. + + var hasSocketHandlerOption = + config.Connection.PooledConnectionLifetimeMilliseconds.HasValue; + + if (hasSocketHandlerOption) + { + var httpMessageHandler = new SocketsHttpHandler + { + PooledConnectionLifetime = config.Connection.PooledConnectionLifetimeMilliseconds.HasValue ? TimeSpan.FromMilliseconds(config.Connection.PooledConnectionLifetimeMilliseconds.Value) : Timeout.InfiniteTimeSpan, + }; + + return new HttpClient(httpMessageHandler) { BaseAddress = new Uri(baseUri) }; + } + + return new HttpClient { BaseAddress = new Uri(baseUri) }; + }).SingleInstance(); + + builder.RegisterInstance(_config); + } +} \ No newline at end of file diff --git a/src/SeqCli/Forwarder/ServiceProcess/SeqCliForwarderWindowsService.cs b/src/SeqCli/Forwarder/ServiceProcess/SeqCliForwarderWindowsService.cs new file mode 100644 index 00000000..43a21b17 --- /dev/null +++ b/src/SeqCli/Forwarder/ServiceProcess/SeqCliForwarderWindowsService.cs @@ -0,0 +1,50 @@ +// Copyright 2020 Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if WINDOWS + +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.ServiceProcess; +using SeqCli.Forwarder.Web.Host; + +namespace SeqCli.Forwarder.ServiceProcess +{ + [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] + class SeqCliForwarderWindowsService : ServiceBase + { + readonly ServerService _serverService; + + public static string WindowsServiceName { get; } = "SeqCli Forwarder"; + + public SeqCliForwarderWindowsService(ServerService serverService) + { + _serverService = serverService; + + ServiceName = WindowsServiceName; + } + + protected override void OnStart(string[] args) + { + _serverService.Start(); + } + + protected override void OnStop() + { + _serverService.StopAsync().Wait(); + } + } +} + +#endif diff --git a/src/SeqCli/Forwarder/Storage/Bookmark.cs b/src/SeqCli/Forwarder/Storage/Bookmark.cs new file mode 100644 index 00000000..44d0834d --- /dev/null +++ b/src/SeqCli/Forwarder/Storage/Bookmark.cs @@ -0,0 +1,144 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Threading; +using SeqCli.Forwarder.Filesystem; +using Path = System.IO.Path; + +namespace SeqCli.Forwarder.Storage; + +/// +/// A durable bookmark of progress processing buffers. +/// +sealed class Bookmark +{ + readonly StoreDirectory _storeDirectory; + + readonly Lock _sync = new(); + BookmarkName _name; + BookmarkValue? _value; + + Bookmark(StoreDirectory storeDirectory, BookmarkName name, BookmarkValue? value) + { + _storeDirectory = storeDirectory; + _name = name; + _value = value; + } + + public static Bookmark Open(StoreDirectory storeDirectory) + { + var (name, value) = Read(storeDirectory); + + return new Bookmark(storeDirectory, name, value); + } + + public bool TryGet([NotNullWhen(true)] out BookmarkValue? bookmark) + { + lock (_sync) + { + if (_value != null) + { + bookmark = _value.Value; + return true; + } + + bookmark = null; + return false; + } + } + + public bool TrySet(BookmarkValue value, bool sync = true) + { + lock (_sync) + { + _value = value; + } + + try + { + Write(_storeDirectory, _name, value, sync); + return true; + } + catch (IOException) + { + _name = new BookmarkName(_name.Id + 1); + return false; + } + } + + static void Write(StoreDirectory storeDirectory, BookmarkName name, BookmarkValue value, bool fsync) + { + unsafe + { + Span bookmark = stackalloc byte[16]; + value.EncodeTo(bookmark); + + storeDirectory.ReplaceContents(name.ToString(), bookmark, fsync); + } + } + + static (BookmarkName, BookmarkValue?) Read(StoreDirectory storeDirectory) + { + // NOTE: This method shouldn't throw + var bookmarks = new List<(string, BookmarkName, StoreFile)>(); + + foreach (var (candidateFileName, candidateFile) in storeDirectory + .List(candidateFileName => Path.GetExtension(candidateFileName) is ".bookmark")) + if (BookmarkName.TryParse(candidateFileName, out var parsedBookmarkName)) + bookmarks.Add((candidateFileName, parsedBookmarkName.Value, candidateFile)); + else + // The `.bookmark` file uses an unrecognized naming convention + storeDirectory.TryDelete(candidateFileName); + + switch (bookmarks.Count) + { + // There aren't any bookmarks; return a default one + case 0: + return (new BookmarkName(0), null); + // There are old bookmark values floating around; try delete them again + case > 1: + { + bookmarks.Sort((a, b) => a.Item2.Id.CompareTo(b.Item2.Id)); + + foreach (var (toDelete, _, _) in bookmarks.Take(bookmarks.Count - 1)) + storeDirectory.TryDelete(toDelete); + break; + } + } + + var (fileName, bookmarkName, file) = bookmarks[^1]; + + try + { + unsafe + { + Span bookmark = stackalloc byte[16]; + if (file.CopyContentsTo(bookmark) != 16) throw new Exception("The bookmark is corrupted."); + + return (bookmarkName, BookmarkValue.Decode(bookmark)); + } + } + catch + { + storeDirectory.TryDelete(fileName); + + return (new BookmarkName(bookmarkName.Id + 1), new BookmarkValue()); + } + } +} diff --git a/src/SeqCli/Forwarder/Storage/BookmarkName.cs b/src/SeqCli/Forwarder/Storage/BookmarkName.cs new file mode 100644 index 00000000..e40a8d8f --- /dev/null +++ b/src/SeqCli/Forwarder/Storage/BookmarkName.cs @@ -0,0 +1,56 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Diagnostics.CodeAnalysis; + +namespace SeqCli.Forwarder.Storage; + +/// +/// A bookmark file name with its incrementing identifier. +/// +readonly record struct BookmarkName +{ + readonly string _name; + + public readonly ulong Id; + + public BookmarkName(ulong id) + { + Id = id; + _name = Identifier.Format(id, ".bookmark"); + } + + BookmarkName(ulong id, string name) + { + Id = id; + _name = name; + } + + public static bool TryParse(string name, [NotNullWhen(true)] out BookmarkName? parsed) + { + if (Identifier.TryParse(name, ".bookmark", out var id)) + { + parsed = new BookmarkName(id.Value, name); + return true; + } + + parsed = null; + return false; + } + + public override string ToString() + { + return _name; + } +} diff --git a/src/SeqCli/Forwarder/Storage/BookmarkValue.cs b/src/SeqCli/Forwarder/Storage/BookmarkValue.cs new file mode 100644 index 00000000..7d86f90b --- /dev/null +++ b/src/SeqCli/Forwarder/Storage/BookmarkValue.cs @@ -0,0 +1,54 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Buffers.Binary; + +namespace SeqCli.Forwarder.Storage; + +/// +/// The in-memory value of a bookmark. +/// +readonly record struct BookmarkValue(ulong Id, long CommitHead) +{ + public void EncodeTo(Span bookmark) + { + if (bookmark.Length != 16) throw new Exception($"Bookmark values must be 16 bytes (got {bookmark.Length})."); + + BinaryPrimitives.WriteUInt64LittleEndian(bookmark, Id); + BinaryPrimitives.WriteInt64LittleEndian(bookmark[8..], CommitHead); + } + + public byte[] Encode() + { + var buffer = new byte[16]; + EncodeTo(buffer); + + return buffer; + } + + public static BookmarkValue Decode(Span bookmark) + { + if (bookmark.Length != 16) throw new Exception($"Bookmark values must be 16 bytes (got {bookmark.Length})."); + + var id = BinaryPrimitives.ReadUInt64LittleEndian(bookmark); + var commitHead = BinaryPrimitives.ReadInt64LittleEndian(bookmark[8..]); + + return new BookmarkValue + { + Id = id, + CommitHead = commitHead + }; + } +} diff --git a/src/SeqCli/Forwarder/Storage/BufferAppender.cs b/src/SeqCli/Forwarder/Storage/BufferAppender.cs new file mode 100644 index 00000000..bc2ca6a6 --- /dev/null +++ b/src/SeqCli/Forwarder/Storage/BufferAppender.cs @@ -0,0 +1,175 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using SeqCli.Forwarder.Filesystem; + +namespace SeqCli.Forwarder.Storage; + +/// +/// The write-side of a buffer. +/// +sealed class BufferAppender : IDisposable +{ + readonly StoreDirectory _storeDirectory; + BufferAppenderChunk? _currentChunk; + + BufferAppender(StoreDirectory storeDirectory) + { + _storeDirectory = storeDirectory; + _currentChunk = null; + } + + public void Dispose() + { + _currentChunk?.Dispose(); + } + + public static BufferAppender Open(StoreDirectory storeDirectory) + { + return new BufferAppender(storeDirectory); + } + + /// + /// Try write a batch. + /// + /// This method does not throw. + /// + /// This method will write the batch into the currently active chunk file unless: + /// + /// 1. The length of the current chunk is greater than or, + /// 2. There is no current chunk, because no writes have been made, or it encountered an IO error previously. + /// + /// If either of these cases is true, then the write will be made to a new chunk file. + /// + /// The newline-delimited data to write. A batch may contain multiple values separated by + /// newlines, but must end on a newline. + /// The file size to roll on. A single batched write may cause the currently + /// active chunk to exceed this size, but a subsequent write will roll over to a new file. + /// The maximum number of chunk files to keep before starting to delete them. This + /// is an optional parameter to use in cases where the reader isn't keeping up with the writer. + /// Whether to explicitly flush the write to disk. + /// True if the write fully succeeded. If this method returns false, it is safe to retry the write, + /// but it may result in duplicate data in the case of partial success. + public bool TryAppend(Span batch, long targetChunkLength, int? maxChunks = null, bool sync = true) + { + if (batch.Length == 0) return true; + + if (batch[^1] != (byte)'\n') throw new Exception("Batches must end with a newline character (\\n)."); + + if (_currentChunk != null) + { + // Only use the existing chunk if it's writable and shouldn't be rolled over + if (_currentChunk.WriteHead > targetChunkLength) + { + // Run a sync before moving to a new file, just to make sure any + // buffered data makes its way to disk + try + { + _currentChunk.Appender.Sync(); + } + catch (IOException) + { + // Ignored + } + finally + { + _currentChunk.Dispose(); + _currentChunk = null; + } + } + } + + // If there's no suitable candidate chunk then create a new one + if (_currentChunk == null) + { + var nextChunkId = ReadChunks(_storeDirectory, maxChunks); + + var chunkName = new ChunkName(nextChunkId); + + var chunkFile = _storeDirectory.Create(chunkName.ToString()); + + if (chunkFile.TryOpenAppend(out var opened)) + _currentChunk = new BufferAppenderChunk(opened); + else + return false; + } + + try + { + _currentChunk.Appender.Append(batch); + _currentChunk.Appender.Commit(); + + if (sync) _currentChunk.Appender.Sync(); + + _currentChunk.WriteHead += batch.Length; + + return true; + } + catch (IOException) + { + // Don't try an explicit sync here, because the file already failed to perform IO + + _currentChunk.Dispose(); + _currentChunk = null; + + return false; + } + } + + static ulong ReadChunks(StoreDirectory storeDirectory, int? maxChunks) + { + ulong nextChunkId = 0; + + List? chunks = null; + foreach (var (fileName, _) in storeDirectory.List(candidateName => + Path.GetExtension(candidateName) is ".clef")) + { + if (!ChunkName.TryParse(fileName, out var parsedChunkName)) continue; + + nextChunkId = Math.Max(nextChunkId, parsedChunkName.Value.Id); + + if (maxChunks == null) continue; + + chunks ??= []; + chunks.Add(parsedChunkName.Value); + } + + // Apply retention on the number of chunk files if the reader isn't keeping up + if (chunks != null) + { + ApplyPreWriteRetention(storeDirectory, maxChunks!.Value, chunks); + } + + return nextChunkId + 1; + } + + static void ApplyPreWriteRetention(StoreDirectory storeDirectory, int maxChunks, List unsortedChunks) + { + // We're going to create a new buffer file, so leave room for it if a max is specified + maxChunks = Math.Max(0, maxChunks - 1); + + unsortedChunks.Sort((a, b) => a.Id.CompareTo(b.Id)); + var sortedChunks = unsortedChunks; + + if (sortedChunks.Count > maxChunks) + foreach (var delete in sortedChunks.Take(sortedChunks.Count - maxChunks)) + // This call may fail if a reader is actively holding this file open + // In these cases we let the writer proceed instead of blocking + storeDirectory.TryDelete(delete.ToString()); + } +} diff --git a/src/SeqCli/Forwarder/Storage/BufferAppenderChunk.cs b/src/SeqCli/Forwarder/Storage/BufferAppenderChunk.cs new file mode 100644 index 00000000..69156679 --- /dev/null +++ b/src/SeqCli/Forwarder/Storage/BufferAppenderChunk.cs @@ -0,0 +1,34 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using SeqCli.Forwarder.Filesystem; + +namespace SeqCli.Forwarder.Storage; + +class BufferAppenderChunk : IDisposable +{ + public BufferAppenderChunk(StoreFileAppender appender) + { + Appender = appender; + } + + public StoreFileAppender Appender { get; } + public long WriteHead { get; set; } + + public void Dispose() + { + Appender.Dispose(); + } +} diff --git a/src/SeqCli/Forwarder/Storage/BufferReader.cs b/src/SeqCli/Forwarder/Storage/BufferReader.cs new file mode 100644 index 00000000..6fba78e9 --- /dev/null +++ b/src/SeqCli/Forwarder/Storage/BufferReader.cs @@ -0,0 +1,325 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using SeqCli.Forwarder.Filesystem; + +namespace SeqCli.Forwarder.Storage; + +/// +/// The read-side of a buffer. +/// +sealed class BufferReader +{ + readonly StoreDirectory _storeDirectory; + BufferReaderHead? _discardingHead; + BufferReaderHead? _readHead; + List _sortedChunks; + + BufferReader(StoreDirectory storeDirectory) + { + _sortedChunks = new List(); + _storeDirectory = storeDirectory; + _discardingHead = null; + _readHead = null; + } + + public static BufferReader Open(StoreDirectory storeDirectory) + { + var reader = new BufferReader(storeDirectory); + reader.ReadChunks(); + + return reader; + } + + /// + /// Try fill a batch from the underlying file set. + /// + /// This method does not throw. + /// + /// This method is expected to be called in a loop to continue filling and processing batches as they're written. + /// + /// Once the batch is processed, call to advance the reader past it. + /// + /// The maximum size in bytes of a batch to read. If a single value between newlines is larger + /// than this size then it will be discarded rather than read. + /// The newline-delimited batch of values read. + /// True if a batch was filled. If this method returns false, then it means there is either no new + /// data to read, some oversize data was discarded, or an IO error was encountered. + public bool TryFillBatch(int maxSize, [NotNullWhen(true)] out BufferReaderBatch? batch) + { + /* + This is where the meat of the buffer reader lives. Reading batches runs in two broad steps: + + 1. If a previous batch overflowed the buffer then we're in "discard mode". + Scan through the offending chunk until a newline delimiter is found. + 2. After discarding, attempt to fill a buffer with as much data as possible + from the underlying chunks. + */ + + if (_discardingHead != null) + { + var discardingRentedArray = ArrayPool.Shared.Rent(maxSize); + + // NOTE: We don't use `maxSize` here, because we're discarding these bytes + // so it doesn't matter what size the target array is + var discardingBatchBuffer = discardingRentedArray.AsSpan(); + + while (_discardingHead != null) + { + var chunk = _sortedChunks[0]; + + // If the chunk has changed (it may have been deleted externally) + // then stop discarding + if (chunk.Name.Id != _discardingHead.Value.Chunk) + { + _discardingHead = null; + + ArrayPool.Shared.Return(discardingRentedArray); + break; + } + + var chunkHead = Head(chunk); + + // Attempt to fill the buffer with data from the underlying chunk + if (!TryFillChunk(chunk, + chunkHead with { CommitHead = _discardingHead.Value.CommitHead }, + discardingBatchBuffer, + out var fill)) + { + // If attempting to read from the chunk fails then remove it and carry on + // This is also done below in the regular read-loop if reading fails + _sortedChunks.RemoveAt(0); + _discardingHead = null; + + ArrayPool.Shared.Return(discardingRentedArray); + break; + } + + // Scan forwards for the next newline + var firstNewlineIndex = discardingBatchBuffer[..fill.Value].IndexOf((byte)'\n'); + + // If a newline was found then advance the reader to it and stop discarding + if (firstNewlineIndex >= 0) fill = firstNewlineIndex + 1; + + _discardingHead = _discardingHead.Value with + { + CommitHead = _discardingHead.Value.CommitHead + fill.Value + }; + _readHead = _discardingHead; + + var isChunkFinished = _discardingHead.Value.CommitHead == chunkHead.WriteHead; + + // If the chunk is finished or a newline is found then stop discarding + if (firstNewlineIndex >= 0 || (isChunkFinished && _sortedChunks.Count > 1)) + { + _discardingHead = null; + + ArrayPool.Shared.Return(discardingRentedArray); + break; + } + + // If there's more data in the chunk to read then loop back through + if (!isChunkFinished) continue; + + // If the chunk is finished but a newline wasn't found then refresh + // our set of chunks and loop back through + ReadChunks(); + + ArrayPool.Shared.Return(discardingRentedArray); + batch = null; + return false; + } + } + + // Fill a buffer with newline-delimited values + + var rentedArray = ArrayPool.Shared.Rent(maxSize); + var batchBuffer = rentedArray.AsSpan()[..maxSize]; + var batchLength = 0; + + BufferReaderHead? batchHead = null; + var chunkIndex = 0; + + // Try fill the buffer with as much data as possible + // by walking over all chunks + while (chunkIndex < _sortedChunks.Count) + { + var chunk = _sortedChunks[chunkIndex]; + var chunkHead = Head(chunk); + + if (!TryFillChunk(chunk, chunkHead, batchBuffer[batchLength..], out var fill)) + { + // If we can't read from this chunk anymore then remove it and continue + _sortedChunks.RemoveAt(chunkIndex); + continue; + } + + var isBufferFull = batchLength + fill == maxSize; + var isChunkFinished = fill == chunkHead.WriteHead; + + // If either the buffer has been filled or we've reached the end of a chunk + // then scan to the last newline + if (isBufferFull || isChunkFinished) + { + // If the chunk is finished then we expect this to immediately find a trailing newline + // NOTE: `Span.LastIndexOf` and similar methods are vectorized + var lastNewlineIndex = batchBuffer[batchLength..(batchLength + fill.Value)].LastIndexOf((byte)'\n'); + if (lastNewlineIndex == -1) + { + // If this isn't the last chunk then discard the trailing data and move on + if (isChunkFinished && chunkIndex < _sortedChunks.Count) + { + chunkIndex += 1; + continue; + } + + // If this is the first chunk then we've hit an oversize payload + if (chunkIndex == 0) + { + _discardingHead = new BufferReaderHead(chunk.Name.Id, chunkHead.CommitHead + fill.Value); + + // Ensures we don't attempt to yield the data we've read + batchHead = null; + } + + // If the chunk isn't finished then the buffer is full + break; + } + + fill = lastNewlineIndex + 1; + } + + batchLength += fill.Value; + batchHead = new BufferReaderHead(chunk.Name.Id, chunkHead.CommitHead + fill.Value); + + chunkIndex += 1; + } + + // If the batch is empty (because there are no chunks or there's no new data) + // then refresh the set of chunks and return + if (batchHead == null || batchLength == 0) + { + ReadChunks(); + + ArrayPool.Shared.Return(rentedArray); + batch = null; + return false; + } + + // If the batch is non-empty then return it + batch = new BufferReaderBatch(batchHead.Value, ArrayPool.Shared, rentedArray, batchLength); + return true; + } + + /// + /// Advance the reader over a previously read batch. + /// + /// This method does not throw. + /// + /// The new head to resume reading from. + public void AdvanceTo(BufferReaderHead newReaderHead) + { + var removeLength = 0; + foreach (var chunk in _sortedChunks) + { + // A portion of the chunk is being skipped + if (chunk.Name.Id == newReaderHead.Chunk) break; + + // The remainder of the chunk is being skipped + if (chunk.Name.Id < newReaderHead.Chunk) + _storeDirectory.TryDelete(chunk.Name.ToString()); + else + throw new Exception("Chunks are out of order."); + + removeLength += 1; + } + + _readHead = newReaderHead; + _sortedChunks.RemoveRange(0, removeLength); + } + + BufferReaderChunkHead Head(BufferReaderChunk chunk) + { + if (_readHead != null && chunk.Name.Id == _readHead.Value.Chunk) + return chunk.Chunk.TryGetLength(out var writeHead) + ? new BufferReaderChunkHead(Math.Min(_readHead.Value.CommitHead, writeHead.Value), writeHead.Value) + : new BufferReaderChunkHead(_readHead.Value.CommitHead, _readHead.Value.CommitHead); + + chunk.Chunk.TryGetLength(out var length); + return new BufferReaderChunkHead(0, length ?? 0); + } + + void ReadChunks() + { + var head = _readHead ?? new BufferReaderHead(0, 0); + + List chunks = new(); + + foreach (var (fileName, file) in _storeDirectory + .List(candidateName => Path.GetExtension(candidateName) is ".clef")) + { + if (!ChunkName.TryParse(fileName, out var parsedChunkName)) continue; + + if (parsedChunkName.Value.Id >= head.Chunk) + chunks.Add(new BufferReaderChunk(parsedChunkName.Value, file)); + else + // If the chunk is before the one we're expecting to read then delete it; we've already processed it + _storeDirectory.TryDelete(fileName); + } + + chunks.Sort((a, b) => a.Name.Id.CompareTo(b.Name.Id)); + + var toDispose = _sortedChunks; + _sortedChunks = chunks; + + foreach (var chunk in toDispose) + try + { + chunk.Dispose(); + } + catch + { + // Ignored + } + } + + static bool TryFillChunk(BufferReaderChunk chunk, BufferReaderChunkHead chunkHead, Span buffer, + [NotNullWhen(true)] out int? filled) + { + var remaining = buffer.Length; + var fill = (int)Math.Min(remaining, chunkHead.Unadvanced); + + try + { + if (!chunk.TryCopyTo(buffer, chunkHead, fill)) + { + filled = null; + return false; + } + + filled = fill; + return true; + } + catch (IOException) + { + filled = null; + return false; + } + } +} diff --git a/src/SeqCli/Forwarder/Storage/BufferReaderBatch.cs b/src/SeqCli/Forwarder/Storage/BufferReaderBatch.cs new file mode 100644 index 00000000..34492a0b --- /dev/null +++ b/src/SeqCli/Forwarder/Storage/BufferReaderBatch.cs @@ -0,0 +1,55 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Buffers; + +namespace SeqCli.Forwarder.Storage; + +/// +/// A contiguous batch of records pulled from a reader. +/// +readonly record struct BufferReaderBatch +{ + readonly int _length; + + readonly ArrayPool? _pool; + readonly byte[] _storage; + + public BufferReaderBatch(BufferReaderHead readerHead, ArrayPool? pool, byte[] storage, int length) + { + ReaderHead = readerHead; + + _pool = pool; + _storage = storage; + _length = length; + } + + public BufferReaderHead ReaderHead { get; } + + public ReadOnlySpan AsSpan() + { + return _storage.AsSpan()[.._length]; + } + + public ArraySegment AsArraySegment() + { + return _storage[.._length]; + } + + public void Return() + { + _pool?.Return(_storage); + } +} diff --git a/src/SeqCli/Forwarder/Storage/BufferReaderChunk.cs b/src/SeqCli/Forwarder/Storage/BufferReaderChunk.cs new file mode 100644 index 00000000..e957f3fc --- /dev/null +++ b/src/SeqCli/Forwarder/Storage/BufferReaderChunk.cs @@ -0,0 +1,65 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using SeqCli.Forwarder.Filesystem; + +namespace SeqCli.Forwarder.Storage; + +/// +/// An active chunk in a . +/// +class BufferReaderChunk : IDisposable +{ + public BufferReaderChunk(ChunkName name, StoreFile chunk) + { + Name = name; + Chunk = chunk; + } + + public ChunkName Name { get; } + public StoreFile Chunk { get; } + + (long, StoreFileReader)? _reader; + + public void Dispose() + { + _reader?.Item2.Dispose(); + } + + public bool TryCopyTo(Span buffer, BufferReaderChunkHead head, int fill) + { + var readEnd = head.CommitHead + fill; + + if (_reader != null) + if (_reader.Value.Item1 < readEnd) + { + var toDispose = _reader.Value.Item2; + _reader = null; + + toDispose.Dispose(); + } + + if (_reader == null) + { + if (!Chunk.TryOpenRead(head.WriteHead, out var reader)) return false; + + _reader = (head.WriteHead, reader); + } + + _reader.Value.Item2.CopyTo(buffer, head.CommitHead, fill); + + return true; + } +} diff --git a/src/SeqCli/Forwarder/Storage/BufferReaderChunkHead.cs b/src/SeqCli/Forwarder/Storage/BufferReaderChunkHead.cs new file mode 100644 index 00000000..7969d254 --- /dev/null +++ b/src/SeqCli/Forwarder/Storage/BufferReaderChunkHead.cs @@ -0,0 +1,23 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace SeqCli.Forwarder.Storage; + +/// +/// The current position in a . +/// +readonly record struct BufferReaderChunkHead(long CommitHead, long WriteHead) +{ + public long Unadvanced => WriteHead - CommitHead; +} diff --git a/src/SeqCli/Forwarder/Storage/BufferReaderHead.cs b/src/SeqCli/Forwarder/Storage/BufferReaderHead.cs new file mode 100644 index 00000000..f1f34217 --- /dev/null +++ b/src/SeqCli/Forwarder/Storage/BufferReaderHead.cs @@ -0,0 +1,20 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace SeqCli.Forwarder.Storage; + +/// +/// A position in a . +/// +readonly record struct BufferReaderHead(ulong Chunk, long CommitHead); diff --git a/src/SeqCli/Forwarder/Storage/ChunkName.cs b/src/SeqCli/Forwarder/Storage/ChunkName.cs new file mode 100644 index 00000000..dcc85a78 --- /dev/null +++ b/src/SeqCli/Forwarder/Storage/ChunkName.cs @@ -0,0 +1,56 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Diagnostics.CodeAnalysis; + +namespace SeqCli.Forwarder.Storage; + +/// +/// A chunk file name with its incrementing identifier. +/// +readonly record struct ChunkName +{ + readonly string _name; + + public readonly ulong Id; + + public ChunkName(ulong id) + { + Id = id; + _name = Identifier.Format(id, ".clef"); + } + + ChunkName(ulong id, string name) + { + Id = id; + _name = name; + } + + public static bool TryParse(string name, [NotNullWhen(true)] out ChunkName? parsed) + { + if (Identifier.TryParse(name, ".clef", out var id)) + { + parsed = new ChunkName(id.Value, name); + return true; + } + + parsed = null; + return false; + } + + public override string ToString() + { + return _name; + } +} diff --git a/src/SeqCli/Forwarder/Storage/Identifier.cs b/src/SeqCli/Forwarder/Storage/Identifier.cs new file mode 100644 index 00000000..0f0ab980 --- /dev/null +++ b/src/SeqCli/Forwarder/Storage/Identifier.cs @@ -0,0 +1,60 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace SeqCli.Forwarder.Storage; + +/// +/// Utilities for parsing and formatting file names with sortable identifiers. +/// +static class Identifier +{ + /// + /// Try parse the identifier from the given name with the given extension. + /// + public static bool TryParse(string name, string extension, [NotNullWhen(true)] out ulong? parsed) + { + if (name.Length != 16 + extension.Length) + { + parsed = null; + return false; + } + + if (!name.EndsWith(extension)) + { + parsed = null; + return false; + } + + if (ulong.TryParse(name.AsSpan()[..16], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var id)) + { + parsed = id; + return true; + } + + parsed = null; + return false; + } + + /// + /// Format an identifier with the given identifier and extension. + /// + public static string Format(ulong id, string extension) + { + return $"{id:x16}{extension}"; + } +} diff --git a/src/SeqCli/Forwarder/Util/AccountRightsHelper.cs b/src/SeqCli/Forwarder/Util/AccountRightsHelper.cs new file mode 100644 index 00000000..9074c532 --- /dev/null +++ b/src/SeqCli/Forwarder/Util/AccountRightsHelper.cs @@ -0,0 +1,193 @@ +// Original interop code copyright Corinna John +// Used under CPOL. http://www.codeproject.com/info/cpol10.aspx +// http://www.codeproject.com/Articles/4863/LSA-Functions-Privileges-and-Impersonation +// Modified and reformatted. + +#if WINDOWS + +using System; +using System.Runtime.InteropServices; +using System.Text; + +// ReSharper disable FieldCanBeMadeReadOnly.Local + +namespace SeqCli.Forwarder.Util +{ + public static class AccountRightsHelper + { + [DllImport("advapi32.dll", PreserveSig = true)] + private static extern UInt32 LsaOpenPolicy( + ref LSA_UNICODE_STRING SystemName, + ref LSA_OBJECT_ATTRIBUTES ObjectAttributes, + Int32 DesiredAccess, + out IntPtr PolicyHandle + ); + + [DllImport("advapi32.dll", SetLastError = true, PreserveSig = true)] + private static extern long LsaAddAccountRights( + IntPtr PolicyHandle, + IntPtr AccountSid, + LSA_UNICODE_STRING[] UserRights, + long CountOfRights); + + [DllImport("advapi32")] + public static extern void FreeSid(IntPtr pSid); + + [DllImport("advapi32.dll", CharSet = CharSet.Auto, SetLastError = true, PreserveSig = true)] + private static extern bool LookupAccountName( + string lpSystemName, string lpAccountName, + IntPtr psid, + ref int cbsid, + StringBuilder domainName, ref int cbdomainLength, ref int use); + + [DllImport("advapi32.dll")] + private static extern long LsaClose(IntPtr ObjectHandle); + + [DllImport("kernel32.dll")] + private static extern int GetLastError(); + + [DllImport("advapi32.dll")] + private static extern long LsaNtStatusToWinError(long status); + + // define the structures + + [StructLayout(LayoutKind.Sequential)] + private struct LSA_UNICODE_STRING + { + public UInt16 Length; + public UInt16 MaximumLength; + public IntPtr Buffer; + } + + [StructLayout(LayoutKind.Sequential)] + private struct LSA_OBJECT_ATTRIBUTES + { + public int Length; + public IntPtr RootDirectory; + public LSA_UNICODE_STRING ObjectName; + public UInt32 Attributes; + public IntPtr SecurityDescriptor; + public IntPtr SecurityQualityOfService; + } + + // enum all policies + + [Flags] + private enum LSA_AccessPolicy : long + { + POLICY_VIEW_LOCAL_INFORMATION = 0x00000001L, + POLICY_VIEW_AUDIT_INFORMATION = 0x00000002L, + POLICY_GET_PRIVATE_INFORMATION = 0x00000004L, + POLICY_TRUST_ADMIN = 0x00000008L, + POLICY_CREATE_ACCOUNT = 0x00000010L, + POLICY_CREATE_SECRET = 0x00000020L, + POLICY_CREATE_PRIVILEGE = 0x00000040L, + POLICY_SET_DEFAULT_QUOTA_LIMITS = 0x00000080L, + POLICY_SET_AUDIT_REQUIREMENTS = 0x00000100L, + POLICY_AUDIT_LOG_ADMIN = 0x00000200L, + POLICY_SERVER_ADMIN = 0x00000400L, + POLICY_LOOKUP_NAMES = 0x00000800L, + POLICY_NOTIFICATION = 0x00001000L + } + + /// Adds a privilege to an account + /// Name of an account - "domain\account" or only "account" + /// Name ofthe privilege + /// The windows error code returned by LsaAddAccountRights + static long SetRight(string accountName, string privilegeName) + { + long winErrorCode; //contains the last error + + //pointer an size for the SID + IntPtr sid = IntPtr.Zero; + int sidSize = 0; + //StringBuilder and size for the domain name + StringBuilder domainName = new StringBuilder(); + int nameSize = 0; + //account-type variable for lookup + int accountType = 0; + + //get required buffer size + LookupAccountName(String.Empty, accountName, sid, ref sidSize, domainName, ref nameSize, ref accountType); + + //allocate buffers + domainName = new StringBuilder(nameSize); + sid = Marshal.AllocHGlobal(sidSize); + + //lookup the SID for the account + bool result = LookupAccountName(String.Empty, accountName, sid, ref sidSize, domainName, ref nameSize, ref accountType); + + if (!result) + { + winErrorCode = GetLastError(); + } + else + { + //initialize an empty unicode-string + LSA_UNICODE_STRING systemName = new LSA_UNICODE_STRING(); + //combine all policies + int access = (int)( + LSA_AccessPolicy.POLICY_AUDIT_LOG_ADMIN | + LSA_AccessPolicy.POLICY_CREATE_ACCOUNT | + LSA_AccessPolicy.POLICY_CREATE_PRIVILEGE | + LSA_AccessPolicy.POLICY_CREATE_SECRET | + LSA_AccessPolicy.POLICY_GET_PRIVATE_INFORMATION | + LSA_AccessPolicy.POLICY_LOOKUP_NAMES | + LSA_AccessPolicy.POLICY_NOTIFICATION | + LSA_AccessPolicy.POLICY_SERVER_ADMIN | + LSA_AccessPolicy.POLICY_SET_AUDIT_REQUIREMENTS | + LSA_AccessPolicy.POLICY_SET_DEFAULT_QUOTA_LIMITS | + LSA_AccessPolicy.POLICY_TRUST_ADMIN | + LSA_AccessPolicy.POLICY_VIEW_AUDIT_INFORMATION | + LSA_AccessPolicy.POLICY_VIEW_LOCAL_INFORMATION + ); + + //initialize a pointer for the policy handle + IntPtr policyHandle; + + //these attributes are not used, but LsaOpenPolicy wants them to exists + LSA_OBJECT_ATTRIBUTES ObjectAttributes = new LSA_OBJECT_ATTRIBUTES(); + ObjectAttributes.Length = 0; + ObjectAttributes.RootDirectory = IntPtr.Zero; + ObjectAttributes.Attributes = 0; + ObjectAttributes.SecurityDescriptor = IntPtr.Zero; + ObjectAttributes.SecurityQualityOfService = IntPtr.Zero; + + //get a policy handle + uint resultPolicy = LsaOpenPolicy(ref systemName, ref ObjectAttributes, access, out policyHandle); + winErrorCode = LsaNtStatusToWinError(resultPolicy); + + if (winErrorCode == 0) + { + //Now that we have the SID an the policy, + //we can add rights to the account. + + //initialize an unicode-string for the privilege name + LSA_UNICODE_STRING[] userRights = new LSA_UNICODE_STRING[1]; + userRights[0] = new LSA_UNICODE_STRING(); + userRights[0].Buffer = Marshal.StringToHGlobalUni(privilegeName); + userRights[0].Length = (UInt16)(privilegeName.Length * UnicodeEncoding.CharSize); + userRights[0].MaximumLength = (UInt16)((privilegeName.Length + 1) * UnicodeEncoding.CharSize); + + //add the right to the account + long res = LsaAddAccountRights(policyHandle, sid, userRights, 1); + winErrorCode = LsaNtStatusToWinError(res); + LsaClose(policyHandle); + } + + FreeSid(sid); + } + + return winErrorCode; + } + + public static void EnsureServiceLogOnRights(string accountName) + { + var err = SetRight(accountName, "SeServiceLogonRight"); + if (err != 0) + Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error()); + } + } +} + +#endif diff --git a/src/SeqCli/Forwarder/Util/CaptiveProcess.cs b/src/SeqCli/Forwarder/Util/CaptiveProcess.cs new file mode 100644 index 00000000..0c2e3e44 --- /dev/null +++ b/src/SeqCli/Forwarder/Util/CaptiveProcess.cs @@ -0,0 +1,81 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Diagnostics; +using System.Threading; + +namespace SeqCli.Forwarder.Util; + +public static class CaptiveProcess +{ + public static int Run( + string fullExePath, + string? args = null, + Action? writeStdout = null, + Action? writeStderr = null, + string? workingDirectory = null) + { + if (fullExePath == null) throw new ArgumentNullException(nameof(fullExePath)); + + args ??= ""; + writeStdout ??= delegate { }; + writeStderr ??= delegate { }; + + var startInfo = new ProcessStartInfo + { + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + ErrorDialog = false, + FileName = fullExePath, + Arguments = args + }; + + if (!string.IsNullOrEmpty(workingDirectory)) + startInfo.WorkingDirectory = workingDirectory; + + using var process = Process.Start(startInfo)!; + using var outputComplete = new ManualResetEvent(false); + using var errorComplete = new ManualResetEvent(false); + // ReSharper disable AccessToDisposedClosure + + process.OutputDataReceived += (_, e) => + { + if (e.Data == null) + outputComplete.Set(); + else + writeStdout(e.Data); + }; + process.BeginOutputReadLine(); + + process.ErrorDataReceived += (_, e) => + { + if (e.Data == null) + errorComplete.Set(); + else + writeStderr(e.Data); + }; + process.BeginErrorReadLine(); + + process.WaitForExit(); + + outputComplete.WaitOne(); + errorComplete.WaitOne(); + + return process.ExitCode; + } +} \ No newline at end of file diff --git a/src/SeqCli/Forwarder/Util/ExecutionEnvironment.cs b/src/SeqCli/Forwarder/Util/ExecutionEnvironment.cs new file mode 100644 index 00000000..4025c3eb --- /dev/null +++ b/src/SeqCli/Forwarder/Util/ExecutionEnvironment.cs @@ -0,0 +1,23 @@ +#if WINDOWS +using SeqCli.Forwarder.Util; +#endif + +namespace SeqCli.Forwarder.Util; + +static class ExecutionEnvironment +{ + public static bool SupportsStandardIO => !IsRunningAsWindowsService; + + static bool IsRunningAsWindowsService + { + get + { +#if WINDOWS + var parent = WindowsProcess.GetParentProcess(); + return parent?.ProcessName == "services"; +#else + return false; +#endif + } + } +} \ No newline at end of file diff --git a/src/SeqCli/Forwarder/Util/ServiceConfiguration.cs b/src/SeqCli/Forwarder/Util/ServiceConfiguration.cs new file mode 100644 index 00000000..598a2e3c --- /dev/null +++ b/src/SeqCli/Forwarder/Util/ServiceConfiguration.cs @@ -0,0 +1,112 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if WINDOWS + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.ServiceProcess; +using System.Text; +using SeqCli.Forwarder.Util; + +namespace SeqCli.Forwarder.Util +{ + [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] + public static class ServiceConfiguration + { + public static bool GetServiceBinaryPath(ServiceController controller, [MaybeNullWhen(false)] out string path) + { + var sc = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "sc.exe"); + + var config = new StringBuilder(); + if (0 != CaptiveProcess.Run(sc, "qc \"" + controller.ServiceName + "\"", l => config.AppendLine(l), Console.WriteLine)) + { + Console.WriteLine("Could not query service path; ignoring."); + path = null; + return false; + } + + var lines = config.ToString() + .Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.Trim()); + + var line = lines + .SingleOrDefault(l => l.StartsWith("BINARY_PATH_NAME : ")); + + if (line == null) + { + Console.WriteLine("No existing binary path could be determined."); + path = null; + return false; + } + + path = line.Replace("BINARY_PATH_NAME : ", ""); + return true; + } + + static bool GetServiceCommandLine(string serviceName, [MaybeNullWhen(false)] out string path) + { + if (serviceName == null) throw new ArgumentNullException(nameof(serviceName)); + + var sc = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "sc.exe"); + + var config = new StringBuilder(); + if (0 != CaptiveProcess.Run(sc, "qc \"" + serviceName + "\"", l => config.AppendLine(l), Console.WriteLine)) + { + Console.WriteLine("Could not query service path; ignoring."); + path = null; + return false; + } + + var lines = config.ToString() + .Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.Trim()); + + var line = lines + .SingleOrDefault(l => l.StartsWith("BINARY_PATH_NAME : ")); + + if (line == null) + { + Console.WriteLine("No existing binary path could be determined."); + path = null; + return false; + } + + path = line.Replace("BINARY_PATH_NAME : ", ""); + return true; + } + + public static bool GetServiceStoragePath(string serviceName, out string? storage) + { + if (serviceName == null) throw new ArgumentNullException(nameof(serviceName)); + + if (GetServiceCommandLine(serviceName, out var binpath) && + binpath.Contains("--storage=\"")) + { + var start = binpath.IndexOf("--storage=\"", StringComparison.Ordinal) + 11; + var chop = binpath.Substring(start); + storage = chop.Substring(0, chop.IndexOf('"')); + return true; + } + + storage = null; + return false; + } + } +} + +#endif + diff --git a/src/SeqCli/Forwarder/Util/UnclosableStreamWrapper.cs b/src/SeqCli/Forwarder/Util/UnclosableStreamWrapper.cs new file mode 100644 index 00000000..2134c49b --- /dev/null +++ b/src/SeqCli/Forwarder/Util/UnclosableStreamWrapper.cs @@ -0,0 +1,59 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.IO; + +namespace SeqCli.Forwarder.Util; + +class UnclosableStreamWrapper : Stream +{ + readonly Stream _stream; + + public UnclosableStreamWrapper(Stream stream) + { + _stream = stream ?? throw new ArgumentNullException(nameof(stream)); + } + + public override void Flush() + { + _stream.Flush(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + return _stream.Seek(offset, origin); + } + + public override void SetLength(long value) + { + _stream.SetLength(value); + } + + public override int Read(byte[] buffer, int offset, int count) + { + return _stream.Read(buffer, offset, count); + } + + public override void Write(byte[] buffer, int offset, int count) + { + _stream.Write(buffer, offset, count); + } + + public override bool CanRead => _stream.CanRead; + public override bool CanSeek => _stream.CanSeek; + public override bool CanWrite => _stream.CanWrite; + public override long Length => _stream.Length; + public override long Position { get { return _stream.Position; } set { _stream.Position = value; } } +} \ No newline at end of file diff --git a/src/SeqCli/Forwarder/Util/WindowsProcess.cs b/src/SeqCli/Forwarder/Util/WindowsProcess.cs new file mode 100644 index 00000000..8e4d96a2 --- /dev/null +++ b/src/SeqCli/Forwarder/Util/WindowsProcess.cs @@ -0,0 +1,51 @@ +#if WINDOWS + +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using Serilog; + +// ReSharper disable once InconsistentNaming + +namespace SeqCli.Forwarder.Util +{ + static class WindowsProcess + { + [StructLayout(LayoutKind.Sequential)] + readonly struct PROCESS_BASIC_INFORMATION + { + readonly IntPtr _reserved1; + readonly IntPtr _pebBaseAddress; + readonly IntPtr _reserved2_0; + readonly IntPtr _reserved2_1; + readonly IntPtr _uniqueProcessId; + public readonly IntPtr InheritedFromUniqueProcessId; + } + + [DllImport("ntdll.dll")] + static extern int NtQueryInformationProcess(IntPtr processHandle, int processInformationClass, ref PROCESS_BASIC_INFORMATION processInformation, int processInformationLength, out int returnLength); + + public static Process? GetParentProcess() + { + var currentProcess = Process.GetCurrentProcess(); + + var pbi = new PROCESS_BASIC_INFORMATION(); + var status = NtQueryInformationProcess(currentProcess.Handle, 0, ref pbi, Marshal.SizeOf(pbi), out _); + if (status != 0) + throw new Win32Exception(status); + + try + { + return Process.GetProcessById(pbi.InheritedFromUniqueProcessId.ToInt32()); + } + catch (Exception ex) + { + Log.Debug(ex, "Could not query parent process information"); + return null; + } + } + } +} + +#endif diff --git a/src/SeqCli/Forwarder/Web/Api/ApiRootEndpoints.cs b/src/SeqCli/Forwarder/Web/Api/ApiRootEndpoints.cs new file mode 100644 index 00000000..60b418f6 --- /dev/null +++ b/src/SeqCli/Forwarder/Web/Api/ApiRootEndpoints.cs @@ -0,0 +1,30 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace SeqCli.Forwarder.Web.Api; + +class ApiRootEndpoints : IMapEndpoints +{ + readonly Encoding _utf8 = new UTF8Encoding(false); + + public void MapEndpoints(WebApplication app) + { + app.MapGet("/api", + () => Results.Content("{\"Links\":{\"Events\":\"/api/events/describe\"}}", "application/json", _utf8)); + } +} \ No newline at end of file diff --git a/src/SeqCli/Forwarder/Web/Api/IMapEndpoints.cs b/src/SeqCli/Forwarder/Web/Api/IMapEndpoints.cs new file mode 100644 index 00000000..2a0812a5 --- /dev/null +++ b/src/SeqCli/Forwarder/Web/Api/IMapEndpoints.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Builder; + +namespace SeqCli.Forwarder.Web.Api; + +interface IMapEndpoints +{ + void MapEndpoints(WebApplication app); +} \ No newline at end of file diff --git a/src/SeqCli/Forwarder/Web/Api/IngestionEndpoints.cs b/src/SeqCli/Forwarder/Web/Api/IngestionEndpoints.cs new file mode 100644 index 00000000..00a44555 --- /dev/null +++ b/src/SeqCli/Forwarder/Web/Api/IngestionEndpoints.cs @@ -0,0 +1,241 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Buffers; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Net.Http.Headers; +using SeqCli.Api; +using SeqCli.Forwarder.Channel; +using SeqCli.Forwarder.Diagnostics; +using JsonException = System.Text.Json.JsonException; + +namespace SeqCli.Forwarder.Web.Api; + +// ReSharper disable UnusedMethodReturnValue.Local + +class IngestionEndpoints : IMapEndpoints +{ + static readonly Encoding Utf8 = new UTF8Encoding(false); + + readonly ForwardingChannelMap _forwardingChannels; + + public IngestionEndpoints(ForwardingChannelMap forwardingChannels) + { + _forwardingChannels = forwardingChannels; + } + + public void MapEndpoints(WebApplication app) + { + app.MapPost("ingest/clef", async context => await IngestCompactFormatAsync(context)); + app.MapPost("api/events/raw", async context => await IngestAsync(context)); + } + + async Task IngestAsync(HttpContext context) + { + var clef = DefaultedBoolQuery(context.Request, "clef"); + + if (clef) return await IngestCompactFormatAsync(context); + + var contentType = (string?)context.Request.Headers[HeaderNames.ContentType]; + const string clefMediaType = "application/vnd.serilog.clef"; + + if (contentType != null && contentType.StartsWith(clefMediaType)) return await IngestCompactFormatAsync(context); + + IngestionLog.ForClient(context.Connection.RemoteIpAddress) + .Error("Client supplied a legacy raw-format (non-CLEF) payload"); + return Results.BadRequest("Only newline-delimited JSON (CLEF) payloads are supported."); + } + + static bool DefaultedBoolQuery(HttpRequest request, string queryParameterName) + { + var parameter = request.Query[queryParameterName]; + if (parameter.Count != 1) + return false; + + var value = (string?) parameter; + + if (value == "" && ( + request.QueryString.Value!.Contains($"&{queryParameterName}=") || + request.QueryString.Value.Contains($"?{queryParameterName}="))) + { + return false; + } + + return "true".Equals(value, StringComparison.OrdinalIgnoreCase) || value == "" || value == queryParameterName; + } + + static string? GetApiKey(HttpRequest request) + { + var apiKeyHeader = request.Headers[ApiConstants.ApiKeyHeaderName]; + + if (apiKeyHeader.Count > 0) return apiKeyHeader.Last(); + return request.Query.TryGetValue("apiKey", out var apiKey) ? apiKey.Last() : null; + } + + async Task IngestCompactFormatAsync(HttpContext context) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + + var log = _forwardingChannels.Get(GetApiKey(context.Request)); + + var payload = ArrayPool.Shared.Rent(1024 * 1024 * 10); + var writeHead = 0; + var readHead = 0; + var discarding = false; + + var done = false; + while (!done) + { + // Fill our buffer + while (!done) + { + var remaining = payload.Length - writeHead; + if (remaining == 0) + { + break; + } + + var read = await context.Request.Body.ReadAsync(payload.AsMemory(writeHead, remaining), context.RequestAborted); + if (read == 0) + { + done = true; + } + + writeHead += read; + } + + // Process events + var batchStart = readHead; + var batchEnd = readHead; + while (batchEnd < writeHead) + { + var eventStart = batchEnd; + var nlIndex = payload.AsSpan()[eventStart..].IndexOf((byte)'\n'); + + if (nlIndex == -1) + { + break; + } + + var eventEnd = eventStart + nlIndex + 1; + + if (discarding) + { + batchStart = eventEnd; + batchEnd = eventEnd; + readHead = batchEnd; + + discarding = false; + } + else + { + batchEnd = eventEnd; + readHead = batchEnd; + + if (!ValidateClef(payload.AsSpan()[eventStart..batchEnd])) + { + await Write(log, ArrayPool.Shared, payload, batchStart..eventStart, cts.Token); + batchStart = batchEnd; + } + } + } + + if (batchStart != batchEnd) + { + await Write(log, ArrayPool.Shared, payload, batchStart..batchEnd, cts.Token); + } + else if (batchStart == 0) + { + readHead = payload.Length; + discarding = true; + } + + // Copy any unprocessed data into our buffer and continue + if (!done) + { + var retain = payload.Length - readHead; + payload.AsSpan()[retain..].CopyTo(payload.AsSpan()[..retain]); + readHead = retain; + writeHead = retain; + } + } + + // Exception cases are handled by `Write` + ArrayPool.Shared.Return(payload); + + return TypedResults.Content( + null, + "application/json", + Utf8, + StatusCodes.Status201Created); + } + + static bool ValidateClef(Span evt) + { + var reader = new Utf8JsonReader(evt); + + try + { + reader.Read(); + if (reader.TokenType != JsonTokenType.StartObject) + { + return false; + } + + while (reader.Read()) + { + if (reader.CurrentDepth == 1) + { + if (reader.TokenType == JsonTokenType.PropertyName) + { + var name = reader.GetString(); + + if (name != null & name!.StartsWith($"@")) + { + // Validate @ property + } + } + } + } + } + catch (JsonException) + { + return false; + } + + return true; + } + + static async Task Write(ForwardingChannel forwardingChannel, ArrayPool pool, byte[] storage, Range range, CancellationToken cancellationToken) + { + try + { + await forwardingChannel.WriteAsync(storage, range, cancellationToken); + } + catch + { + pool.Return(storage); + throw; + } + } +} \ No newline at end of file diff --git a/src/SeqCli/Forwarder/Web/Api/IngestionLogEndpoints.cs b/src/SeqCli/Forwarder/Web/Api/IngestionLogEndpoints.cs new file mode 100644 index 00000000..60a69038 --- /dev/null +++ b/src/SeqCli/Forwarder/Web/Api/IngestionLogEndpoints.cs @@ -0,0 +1,48 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.IO; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using SeqCli.Forwarder.Diagnostics; +using Serilog.Formatting.Display; + +namespace SeqCli.Forwarder.Web.Api; + +class IngestionLogEndpoints : IMapEndpoints +{ + readonly MessageTemplateTextFormatter _formatter; + readonly Encoding _utf8 = new UTF8Encoding(false); + + public IngestionLogEndpoints(MessageTemplateTextFormatter formatter) + { + _formatter = formatter; + } + + public void MapEndpoints(WebApplication app) + { + app.MapGet("api/diagnostics/ingestion", () => + { + var events = IngestionLog.Read(); + using var log = new StringWriter(); + foreach (var logEvent in events) + { + _formatter.Format(logEvent, log); + } + + return Results.Content(log.ToString(), "text/plain", _utf8); + }); + } +} \ No newline at end of file diff --git a/src/SeqCli/Forwarder/Web/Host/ServerService.cs b/src/SeqCli/Forwarder/Web/Host/ServerService.cs new file mode 100644 index 00000000..ca6545e9 --- /dev/null +++ b/src/SeqCli/Forwarder/Web/Host/ServerService.cs @@ -0,0 +1,65 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using SeqCli.Forwarder.Channel; +using SeqCli.Forwarder.Diagnostics; +using Serilog; + +namespace SeqCli.Forwarder.Web.Host; + +class ServerService +{ + readonly IHost _host; + readonly ForwardingChannelMap _forwardingChannelMap; + readonly string _listenUri; + + public ServerService(IHost host, ForwardingChannelMap forwardingChannelMap, string listenUri) + { + _host = host; + _forwardingChannelMap = forwardingChannelMap; + _listenUri = listenUri; + } + + public void Start() + { + try + { + Log.Debug("Starting HTTP server..."); + + _host.Start(); + + Log.Information("SeqCli Forwarder listening on {ListenUri}", _listenUri); + IngestionLog.Log.Debug("SeqCli Forwarder is accepting events"); + } + catch (Exception ex) + { + Log.Fatal(ex, "Error running the server application"); + throw; + } + } + + public async Task StopAsync() + { + Log.Debug("Stopping HTTP server..."); + + await _host.StopAsync(); + + Log.Information("HTTP server stopped; flushing buffers..."); + + await _forwardingChannelMap.StopAsync(); + } +} \ No newline at end of file diff --git a/src/SeqCli/Ingestion/LogShipper.cs b/src/SeqCli/Ingestion/LogShipper.cs index faeffe16..94f15298 100644 --- a/src/SeqCli/Ingestion/LogShipper.cs +++ b/src/SeqCli/Ingestion/LogShipper.cs @@ -16,6 +16,7 @@ using System.Collections.Generic; using System.IO; using System.Net.Http; +using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; using Newtonsoft.Json; @@ -31,6 +32,56 @@ namespace SeqCli.Ingestion; static class LogShipper { static readonly ITextFormatter JsonFormatter = OutputFormatter.Json(null); + + public static async Task ShipBuffer( + SeqConnection connection, + string? apiKey, + ArraySegment utf8Clef, + SendFailureHandling sendFailureHandling) + { + var content = new ByteArrayContent(utf8Clef.Array!, utf8Clef.Offset, utf8Clef.Count) + { + Headers = + { + ContentType = new MediaTypeHeaderValue(ApiConstants.ClefMediaType, "utf-8") + } + }; + + var retries = 0; + while (true) + { + var sendSucceeded = false; + try + { + sendSucceeded = await Send( + connection, + apiKey, + sendFailureHandling != SendFailureHandling.Ignore, + content); + } + catch (Exception ex) + { + if (sendFailureHandling != SendFailureHandling.Ignore) + Log.Error(ex, "Failed to send an event batch"); + } + + if (!sendSucceeded) + { + if (sendFailureHandling == SendFailureHandling.Fail) + return false; + + if (sendFailureHandling == SendFailureHandling.Retry) + { + var millisecondsDelay = (int)Math.Min(Math.Pow(2, retries) * 2000, 60000); + await Task.Delay(millisecondsDelay); + retries += 1; + continue; + } + } + + return true; + } + } public static async Task ShipEvents( SeqConnection connection, @@ -163,6 +214,11 @@ static async Task SendBatchAsync( content = new StringContent(builder.ToString(), Encoding.UTF8, ApiConstants.ClefMediaType); } + return await Send(connection, apiKey, logSendFailures, content); + } + + static async Task Send(SeqConnection connection, string? apiKey, bool logSendFailures, HttpContent content) + { var request = new HttpRequestMessage(HttpMethod.Post, ApiConstants.IngestionEndpoint) { Content = content }; if (apiKey != null) request.Headers.Add(ApiConstants.ApiKeyHeaderName, apiKey); diff --git a/src/SeqCli/PlainText/Framing/FrameReader.cs b/src/SeqCli/PlainText/Framing/FrameReader.cs index c39bdb0e..d0f769e0 100644 --- a/src/SeqCli/PlainText/Framing/FrameReader.cs +++ b/src/SeqCli/PlainText/Framing/FrameReader.cs @@ -58,7 +58,7 @@ public async Task TryReadAsync() } else if (_unawaitedNextLine != null) { - var index = Task.WaitAny(new Task[] {_unawaitedNextLine}, _trailingLineArrivalDeadline); + var index = Task.WaitAny([_unawaitedNextLine], _trailingLineArrivalDeadline); if (index == -1) return new Frame(); @@ -81,7 +81,7 @@ public async Task TryReadAsync() while (true) { readLine = readLine ?? Task.Run(_source.ReadLineAsync); - var index = Task.WaitAny(new Task[] {readLine}, _trailingLineArrivalDeadline); + var index = Task.WaitAny([readLine], _trailingLineArrivalDeadline); if (index == -1) { if (hasValue) diff --git a/src/SeqCli/PlainText/ReifiedProperties.cs b/src/SeqCli/PlainText/ReifiedProperties.cs index 98bc98f0..9b63c64d 100644 --- a/src/SeqCli/PlainText/ReifiedProperties.cs +++ b/src/SeqCli/PlainText/ReifiedProperties.cs @@ -13,10 +13,7 @@ public const string SpanId = "@sp", TraceId = "@tr"; - static readonly HashSet All = new() - { - Message, Timestamp, Level, Exception, StartTimestamp, SpanId, TraceId - }; + static readonly HashSet All = [Message, Timestamp, Level, Exception, StartTimestamp, SpanId, TraceId]; public static bool IsReifiedProperty(string name) { diff --git a/src/SeqCli/Program.cs b/src/SeqCli/Program.cs index b12c9d6c..9b4f8ffe 100644 --- a/src/SeqCli/Program.cs +++ b/src/SeqCli/Program.cs @@ -17,6 +17,7 @@ using System.Threading.Tasks; using Autofac; using SeqCli.Cli; +using SeqCli.Cli.Features; using SeqCli.Util; using Serilog; using Serilog.Core; @@ -26,6 +27,10 @@ namespace SeqCli; class Program { +#if WINDOWS + public const string BinaryName = "seqcli.exe"; +#endif + static async Task Main(string[] args) { var levelSwitch = new LoggingLevelSwitch(LogEventLevel.Error); diff --git a/src/SeqCli/Properties/launchSettings.json b/src/SeqCli/Properties/launchSettings.json index fbea9f6a..11a965f9 100644 --- a/src/SeqCli/Properties/launchSettings.json +++ b/src/SeqCli/Properties/launchSettings.json @@ -3,7 +3,7 @@ "profiles": { "SeqCli": { "commandName": "Project", - "commandLineArgs": "config -k connection.apiKey -v test" + "commandLineArgs": "help --pre" } } } diff --git a/src/SeqCli/Sample/Loader/Simulation.cs b/src/SeqCli/Sample/Loader/Simulation.cs index 63677751..0735c4f8 100644 --- a/src/SeqCli/Sample/Loader/Simulation.cs +++ b/src/SeqCli/Sample/Loader/Simulation.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/SeqCli.csproj b/src/SeqCli/SeqCli.csproj index 5eec77ce..627c4c85 100644 --- a/src/SeqCli/SeqCli.csproj +++ b/src/SeqCli/SeqCli.csproj @@ -13,6 +13,25 @@ Major latest enable + false + false + false + true + true + true + true + + + WINDOWS + + + OSX + + + LINUX + + + UNIX @@ -28,16 +47,19 @@ + + + + + + - - - diff --git a/src/SeqCli/SeqCliModule.cs b/src/SeqCli/SeqCliModule.cs index 723cb9e7..2cfc7fb7 100644 --- a/src/SeqCli/SeqCliModule.cs +++ b/src/SeqCli/SeqCliModule.cs @@ -15,8 +15,6 @@ using System.Reflection; using Autofac; using SeqCli.Cli; -using SeqCli.Config; -using SeqCli.Connection; namespace SeqCli; @@ -28,9 +26,5 @@ protected override void Load(ContainerBuilder builder) builder.RegisterAssemblyTypes(typeof(Program).GetTypeInfo().Assembly) .As() .WithMetadataFrom(); - builder.RegisterType(); - builder.Register(c => RuntimeConfigurationLoader.Load()).SingleInstance(); - builder.Register(c => c.Resolve().Connection).SingleInstance(); - builder.Register(c => c.Resolve().Output).SingleInstance(); } -} \ No newline at end of file +} diff --git a/src/SeqCli/Templates/Ast/JsonTemplate.cs b/src/SeqCli/Templates/Ast/JsonTemplate.cs index 4105f2de..06d74822 100644 --- a/src/SeqCli/Templates/Ast/JsonTemplate.cs +++ b/src/SeqCli/Templates/Ast/JsonTemplate.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/Ast/JsonTemplateArray.cs b/src/SeqCli/Templates/Ast/JsonTemplateArray.cs index d7224419..cfd63080 100644 --- a/src/SeqCli/Templates/Ast/JsonTemplateArray.cs +++ b/src/SeqCli/Templates/Ast/JsonTemplateArray.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/Ast/JsonTemplateBoolean.cs b/src/SeqCli/Templates/Ast/JsonTemplateBoolean.cs index a6599a64..1fc1bf2e 100644 --- a/src/SeqCli/Templates/Ast/JsonTemplateBoolean.cs +++ b/src/SeqCli/Templates/Ast/JsonTemplateBoolean.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/Ast/JsonTemplateCall.cs b/src/SeqCli/Templates/Ast/JsonTemplateCall.cs index ddcb93bc..1f1cd516 100644 --- a/src/SeqCli/Templates/Ast/JsonTemplateCall.cs +++ b/src/SeqCli/Templates/Ast/JsonTemplateCall.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/Ast/JsonTemplateNull.cs b/src/SeqCli/Templates/Ast/JsonTemplateNull.cs index dd9cda6c..01a44a6e 100644 --- a/src/SeqCli/Templates/Ast/JsonTemplateNull.cs +++ b/src/SeqCli/Templates/Ast/JsonTemplateNull.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/Ast/JsonTemplateNumber.cs b/src/SeqCli/Templates/Ast/JsonTemplateNumber.cs index bb149866..32887fc7 100644 --- a/src/SeqCli/Templates/Ast/JsonTemplateNumber.cs +++ b/src/SeqCli/Templates/Ast/JsonTemplateNumber.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/Ast/JsonTemplateObject.cs b/src/SeqCli/Templates/Ast/JsonTemplateObject.cs index 0c5be41b..fe11aac5 100644 --- a/src/SeqCli/Templates/Ast/JsonTemplateObject.cs +++ b/src/SeqCli/Templates/Ast/JsonTemplateObject.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/Ast/JsonTemplateString.cs b/src/SeqCli/Templates/Ast/JsonTemplateString.cs index 96d2e8d6..d7896871 100644 --- a/src/SeqCli/Templates/Ast/JsonTemplateString.cs +++ b/src/SeqCli/Templates/Ast/JsonTemplateString.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/Evaluator/JsonTemplateEvaluator.cs b/src/SeqCli/Templates/Evaluator/JsonTemplateEvaluator.cs index 4bb07eba..977b476e 100644 --- a/src/SeqCli/Templates/Evaluator/JsonTemplateEvaluator.cs +++ b/src/SeqCli/Templates/Evaluator/JsonTemplateEvaluator.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/Evaluator/JsonTemplateFunction.cs b/src/SeqCli/Templates/Evaluator/JsonTemplateFunction.cs index 173bacd6..9d06abb9 100644 --- a/src/SeqCli/Templates/Evaluator/JsonTemplateFunction.cs +++ b/src/SeqCli/Templates/Evaluator/JsonTemplateFunction.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/Import/EntityTemplate.cs b/src/SeqCli/Templates/Import/EntityTemplate.cs index eb99d3e9..fa08bde5 100644 --- a/src/SeqCli/Templates/Import/EntityTemplate.cs +++ b/src/SeqCli/Templates/Import/EntityTemplate.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/Import/EntityTemplateLoader.cs b/src/SeqCli/Templates/Import/EntityTemplateLoader.cs index 39a0f857..92975cdf 100644 --- a/src/SeqCli/Templates/Import/EntityTemplateLoader.cs +++ b/src/SeqCli/Templates/Import/EntityTemplateLoader.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/Import/GenericEntity.cs b/src/SeqCli/Templates/Import/GenericEntity.cs index 48838362..b9f93650 100644 --- a/src/SeqCli/Templates/Import/GenericEntity.cs +++ b/src/SeqCli/Templates/Import/GenericEntity.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/Import/TemplateSetImporter.cs b/src/SeqCli/Templates/Import/TemplateSetImporter.cs index 399e4bcf..9f14ec5c 100644 --- a/src/SeqCli/Templates/Import/TemplateSetImporter.cs +++ b/src/SeqCli/Templates/Import/TemplateSetImporter.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/ObjectGraphs/JsonTemplateObjectGraphConverter.cs b/src/SeqCli/Templates/ObjectGraphs/JsonTemplateObjectGraphConverter.cs index 9236e260..c23c8d2c 100644 --- a/src/SeqCli/Templates/ObjectGraphs/JsonTemplateObjectGraphConverter.cs +++ b/src/SeqCli/Templates/ObjectGraphs/JsonTemplateObjectGraphConverter.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/Parser/JsonTemplateParser.cs b/src/SeqCli/Templates/Parser/JsonTemplateParser.cs index 5a24778f..a0cdd15b 100644 --- a/src/SeqCli/Templates/Parser/JsonTemplateParser.cs +++ b/src/SeqCli/Templates/Parser/JsonTemplateParser.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/Parser/JsonTemplateTextParsers.cs b/src/SeqCli/Templates/Parser/JsonTemplateTextParsers.cs index cf28a346..41746e39 100644 --- a/src/SeqCli/Templates/Parser/JsonTemplateTextParsers.cs +++ b/src/SeqCli/Templates/Parser/JsonTemplateTextParsers.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/Parser/JsonTemplateToken.cs b/src/SeqCli/Templates/Parser/JsonTemplateToken.cs index 289b48b1..fb375455 100644 --- a/src/SeqCli/Templates/Parser/JsonTemplateToken.cs +++ b/src/SeqCli/Templates/Parser/JsonTemplateToken.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Templates/Parser/JsonTemplateTokenizer.cs b/src/SeqCli/Templates/Parser/JsonTemplateTokenizer.cs index ed3c01f1..b2da6ca4 100644 --- a/src/SeqCli/Templates/Parser/JsonTemplateTokenizer.cs +++ b/src/SeqCli/Templates/Parser/JsonTemplateTokenizer.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Util/LogEventPropertyFactory.cs b/src/SeqCli/Util/LogEventPropertyFactory.cs index 6b2a004d..89c23987 100644 --- a/src/SeqCli/Util/LogEventPropertyFactory.cs +++ b/src/SeqCli/Util/LogEventPropertyFactory.cs @@ -1,4 +1,4 @@ -// Copyright Datalust Pty Ltd and Contributors +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/SeqCli/Util/PasswordHash.cs b/src/SeqCli/Util/PasswordHash.cs new file mode 100644 index 00000000..91576624 --- /dev/null +++ b/src/SeqCli/Util/PasswordHash.cs @@ -0,0 +1,28 @@ +using System; +using System.Security.Cryptography; + +namespace SeqCli.Util; + +static class PasswordHash +{ + const int SaltSize = 16, + HashSize = 64, + HashIter = 500_000; + + public static byte[] GenerateSalt() + { + var salt = new byte[SaltSize]; + using var cp = RandomNumberGenerator.Create(); + cp.GetBytes(salt); + return salt; + } + + public static byte[] Calculate(string password, byte[] salt) + { + if (password == null) throw new ArgumentNullException(nameof(password)); + if (salt == null) throw new ArgumentNullException(nameof(salt)); + + using var algorithm = new Rfc2898DeriveBytes(password, salt, HashIter, HashAlgorithmName.SHA512); + return algorithm.GetBytes(HashSize); + } +} \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Help/MarkdownHelpTestCase.cs b/test/SeqCli.EndToEnd/Help/MarkdownHelpTestCase.cs index bfecf1ff..9177492b 100644 --- a/test/SeqCli.EndToEnd/Help/MarkdownHelpTestCase.cs +++ b/test/SeqCli.EndToEnd/Help/MarkdownHelpTestCase.cs @@ -24,6 +24,8 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun var indexOfTemplateImport = markdown.IndexOf("### `template import`", StringComparison.Ordinal); Assert.NotEqual(indexOfTemplateExport, indexOfTemplateImport); Assert.True(indexOfTemplateExport < indexOfTemplateImport); + + Assert.DoesNotContain("### `forwarder run`", markdown); return Task.CompletedTask; } diff --git a/test/SeqCli.EndToEnd/Profile/ProfileCreateListRemoveTestCase.cs b/test/SeqCli.EndToEnd/Profile/ProfileCreateListRemoveTestCase.cs index 17661e59..9adc50ed 100644 --- a/test/SeqCli.EndToEnd/Profile/ProfileCreateListRemoveTestCase.cs +++ b/test/SeqCli.EndToEnd/Profile/ProfileCreateListRemoveTestCase.cs @@ -14,13 +14,13 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun Assert.Equal(0, create); Assert.Equal(0, runner.Exec("profile list", disconnected: true)); - Assert.Contains("test (https://seq.example.com)", runner.LastRunProcess.Output); + Assert.Contains("test (https://seq.example.com)", runner.LastRunProcess!.Output); Assert.Equal(0, runner.Exec("config", disconnected: true)); Assert.Contains("profiles[test].serverUrl", runner.LastRunProcess.Output); Assert.Contains("https://seq.example.com", runner.LastRunProcess.Output); Assert.Contains("profiles[test].apiKey", runner.LastRunProcess.Output); - Assert.Contains("123", runner.LastRunProcess.Output); + Assert.Contains("pd.", runner.LastRunProcess.Output); var remove = runner.Exec("profile remove", "-n Test", disconnected: true); Assert.Equal(0, remove); diff --git a/test/SeqCli.EndToEnd/RetentionPolicy/RetentionPolicyBasicsTestCase.cs b/test/SeqCli.EndToEnd/RetentionPolicy/RetentionPolicyBasicsTestCase.cs index fdb554d7..2a64a0e8 100644 --- a/test/SeqCli.EndToEnd/RetentionPolicy/RetentionPolicyBasicsTestCase.cs +++ b/test/SeqCli.EndToEnd/RetentionPolicy/RetentionPolicyBasicsTestCase.cs @@ -1,12 +1,11 @@ using System; using System.Threading.Tasks; -using System.Linq; using Seq.Api; using SeqCli.EndToEnd.Support; using Serilog; using Xunit; -namespace SeqCli.EndToEnd.RetentionPolicies; +namespace SeqCli.EndToEnd.RetentionPolicy; // ReSharper disable once UnusedType.Global public class RetentionPolicyBasicsTestCase : ICliTestCase diff --git a/test/SeqCli.EndToEnd/Support/TestConfiguration.cs b/test/SeqCli.EndToEnd/Support/TestConfiguration.cs index 1f33b97b..a95023d9 100644 --- a/test/SeqCli.EndToEnd/Support/TestConfiguration.cs +++ b/test/SeqCli.EndToEnd/Support/TestConfiguration.cs @@ -41,7 +41,7 @@ public CaptiveProcess SpawnServerProcess(string storagePath) { var containerName = Guid.NewGuid().ToString("n"); const string containerRuntime = "docker"; - return new CaptiveProcess(containerRuntime, $"run --name {containerName} -d -e ACCEPT_EULA=Y -p {_serverListenPort}:80 datalust/seq:{imageTag}", stopCommandFullExePath: containerRuntime, stopCommandArgs: $"rm -f {containerName}"); + return new CaptiveProcess(containerRuntime, $"run --name {containerName} -d -e ACCEPT_EULA=Y -e SEQ_FIRSTRUN_NOAUTHENTICATION=True -p {_serverListenPort}:80 datalust/seq:{imageTag}", stopCommandFullExePath: containerRuntime, stopCommandArgs: $"rm -f {containerName}"); } return new CaptiveProcess("seq", commandWithArgs); diff --git a/test/SeqCli.EndToEnd/TestDriverModule.cs b/test/SeqCli.EndToEnd/TestDriverModule.cs index e0bbaf72..e52041a4 100644 --- a/test/SeqCli.EndToEnd/TestDriverModule.cs +++ b/test/SeqCli.EndToEnd/TestDriverModule.cs @@ -27,7 +27,7 @@ protected override void Load(ContainerBuilder builder) var testCases = _args.TestCases(); builder.RegisterAssemblyTypes(ThisAssembly) // ReSharper disable once AssignNullToNotNullAttribute - .Where(t => testCases == null || testCases.Length == 0 || testCases.Any(c => c.IsMatch(t.FullName))) + .Where(t => testCases.Length == 0 || testCases.Any(c => c.IsMatch(t.FullName))) .As() .WithMetadata(t => { diff --git a/test/SeqCli.Tests/Cli/CommandLineHostTests.cs b/test/SeqCli.Tests/Cli/CommandLineHostTests.cs index fdd0b28f..b17ca6bb 100644 --- a/test/SeqCli.Tests/Cli/CommandLineHostTests.cs +++ b/test/SeqCli.Tests/Cli/CommandLineHostTests.cs @@ -4,6 +4,8 @@ using System.Threading.Tasks; using Autofac.Features.Metadata; using SeqCli.Cli; +using SeqCli.Cli.Features; +using SeqCli.Tests.Support; using Serilog.Core; using Serilog.Events; using Xunit; @@ -13,7 +15,7 @@ namespace SeqCli.Tests.Cli; public class CommandLineHostTests { [Fact] - public async Task CheckCommandLineHostPicksCorrectCommand() + public async Task CommandLineHostPicksCorrectCommand() { var executed = new List(); var availableCommands = new List, CommandMetadata>> @@ -26,29 +28,49 @@ public async Task CheckCommandLineHostPicksCorrectCommand() new CommandMetadata {Name = "test2", HelpText = "help"}) }; var commandLineHost = new CommandLineHost(availableCommands); - await commandLineHost.Run(new []{ "test"},new LoggingLevelSwitch()); + await commandLineHost.Run(["test"],new LoggingLevelSwitch()); - Assert.Equal("test", executed.First()); + Assert.Equal("test", executed.Single()); + } + + [Fact] + public async Task PrereleaseCommandsAreIgnoredWithoutFlag() + { + var executed = new List(); + var availableCommands = new List, CommandMetadata>> + { + new( + new Lazy(() => new ActionCommand(() => executed.Add("test"))), + new CommandMetadata {Name = "test", HelpText = "help", IsPreview = true}), + }; + var commandLineHost = new CommandLineHost(availableCommands); + var exit = await commandLineHost.Run(["test"],new LoggingLevelSwitch()); + Assert.Equal(1, exit); + Assert.Empty(executed); + + exit = await commandLineHost.Run(["test", "--pre"],new LoggingLevelSwitch()); + Assert.Equal(0, exit); + Assert.Equal("test", executed.Single()); } [Fact] public async Task WhenMoreThanOneSubcommandAndTheUserRunsWithSubcommandEnsurePickedCorrect() { - var commandsRan = new List(); + var executed = new List(); var availableCommands = new List, CommandMetadata>> { new( - new Lazy(() => new ActionCommand(() => commandsRan.Add("test-subcommand1"))), + new Lazy(() => new ActionCommand(() => executed.Add("test-subcommand1"))), new CommandMetadata {Name = "test", SubCommand = "subcommand1", HelpText = "help"}), new( - new Lazy(() => new ActionCommand(() => commandsRan.Add("test-subcommand2"))), + new Lazy(() => new ActionCommand(() => executed.Add("test-subcommand2"))), new CommandMetadata {Name = "test", SubCommand = "subcommand2", HelpText = "help"}) }; var commandLineHost = new CommandLineHost(availableCommands); - await commandLineHost.Run(new[] { "test", "subcommand2" }, new LoggingLevelSwitch()); + await commandLineHost.Run(["test", "subcommand2"], new LoggingLevelSwitch()); - Assert.Equal("test-subcommand2", commandsRan.First()); + Assert.Equal("test-subcommand2", executed.First()); } [Fact] @@ -70,12 +92,4 @@ public async Task VerboseOptionSetsLoggingLevelToInformation() Assert.Equal(LogEventLevel.Information, levelSwitch.MinimumLevel); } - - class ActionCommand : Command - { - public ActionCommand(Action action) - { - action.Invoke(); - } - } } \ No newline at end of file diff --git a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs new file mode 100644 index 00000000..00b1762d --- /dev/null +++ b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using SeqCli.Forwarder.Filesystem; + +namespace SeqCli.Tests.Forwarder.Filesystem; + +class InMemoryStoreDirectory : StoreDirectory +{ + readonly Dictionary _files = new(); + + public IReadOnlyDictionary Files => _files; + + public override InMemoryStoreFile Create(string name) + { + if (_files.ContainsKey(name)) throw new Exception($"The file {name} already exists."); + + _files.Add(name, new InMemoryStoreFile()); + + return _files[name]; + } + + public InMemoryStoreFile Create(string name, Span contents) + { + var file = Create(name); + file.Append(contents); + + return file; + } + + public override bool TryDelete(string name) + { + return _files.Remove(name); + } + + public override InMemoryStoreFile Replace(string toReplace, string replaceWith) + { + _files[toReplace] = _files[replaceWith]; + _files.Remove(replaceWith); + + return _files[toReplace]; + } + + public override IEnumerable<(string Name, StoreFile File)> List(Func predicate) + { + return _files + .Where(kv => predicate(kv.Key)) + .Select(kv => (kv.Key, kv.Value as StoreFile)); + } +} diff --git a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFile.cs b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFile.cs new file mode 100644 index 00000000..26be3beb --- /dev/null +++ b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFile.cs @@ -0,0 +1,40 @@ +#nullable enable + +using System; +using System.Diagnostics.CodeAnalysis; +using SeqCli.Forwarder.Filesystem; + +namespace SeqCli.Tests.Forwarder.Filesystem; + +class InMemoryStoreFile : StoreFile +{ + public byte[] Contents { get; private set; } = Array.Empty(); + + public override bool TryGetLength([NotNullWhen(true)] out long? length) + { + length = Contents.Length; + return true; + } + + public void Append(Span incoming) + { + var newContents = new byte[Contents.Length + incoming.Length]; + + Contents.CopyTo(newContents.AsSpan()); + incoming.CopyTo(newContents.AsSpan()[^incoming.Length..]); + + Contents = newContents; + } + + public override bool TryOpenRead(long length, [NotNullWhen(true)] out StoreFileReader? reader) + { + reader = new InMemoryStoreFileReader(this, (int)length); + return true; + } + + public override bool TryOpenAppend([NotNullWhen(true)] out StoreFileAppender? appender) + { + appender = new InMemoryStoreFileAppender(this); + return true; + } +} diff --git a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFileAppender.cs b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFileAppender.cs new file mode 100644 index 00000000..9b078eec --- /dev/null +++ b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFileAppender.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using SeqCli.Forwarder.Filesystem; + +namespace SeqCli.Tests.Forwarder.Filesystem; + +class InMemoryStoreFileAppender : StoreFileAppender +{ + readonly List _incoming; + + readonly InMemoryStoreFile _storeFile; + + public InMemoryStoreFileAppender(InMemoryStoreFile storeFile) + { + _storeFile = storeFile; + _incoming = new List(); + } + + public override void Append(Span data) + { + _incoming.AddRange(data); + } + + public override long Commit() + { + _storeFile.Append(_incoming.ToArray()); + _incoming.Clear(); + + return _storeFile.Contents.Length; + } + + public override void Sync() + { + } + + public override void Dispose() + { + } +} diff --git a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFileReader.cs b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFileReader.cs new file mode 100644 index 00000000..d8507e6c --- /dev/null +++ b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFileReader.cs @@ -0,0 +1,29 @@ +using System; +using SeqCli.Forwarder.Filesystem; + +namespace SeqCli.Tests.Forwarder.Filesystem; + +class InMemoryStoreFileReader : StoreFileReader +{ + readonly int _length; + + readonly InMemoryStoreFile _storeFile; + + public InMemoryStoreFileReader(InMemoryStoreFile storeFile, int length) + { + _storeFile = storeFile; + _length = length; + } + + public override long CopyTo(Span buffer, long from = 0, long? length = null) + { + var span = _storeFile.Contents.AsSpan().Slice((int)from, (int?)length ?? _length); + span.CopyTo(buffer); + + return span.Length; + } + + public override void Dispose() + { + } +} diff --git a/test/SeqCli.Tests/Forwarder/Storage/BookmarkTests.cs b/test/SeqCli.Tests/Forwarder/Storage/BookmarkTests.cs new file mode 100644 index 00000000..3d67ecbd --- /dev/null +++ b/test/SeqCli.Tests/Forwarder/Storage/BookmarkTests.cs @@ -0,0 +1,83 @@ +using System.Linq; +using SeqCli.Forwarder.Storage; +using SeqCli.Tests.Forwarder.Filesystem; +using Xunit; + +namespace SeqCli.Tests.Forwarder.Storage; + +public class BookmarkTests +{ + [Fact] + public void CreateSetGet() + { + var directory = new InMemoryStoreDirectory(); + + var bookmark = Bookmark.Open(directory); + + Assert.False(bookmark.TryGet(out var value)); + Assert.Null(value); + + Assert.True(bookmark.TrySet(new BookmarkValue(42, 1))); + Assert.True(bookmark.TryGet(out value)); + Assert.Equal(new BookmarkValue(42, 1), value.Value); + + Assert.True(bookmark.TrySet(new BookmarkValue(42, int.MaxValue))); + Assert.True(bookmark.TryGet(out value)); + Assert.Equal(new BookmarkValue(42, int.MaxValue), value.Value); + } + + [Fact] + public void OpenDeletesOldBookmarks() + { + var directory = new InMemoryStoreDirectory(); + + directory.Create($"{1L:x16}.bookmark", new BookmarkValue(3, 3478).Encode()); + directory.Create($"{3L:x16}.bookmark", new BookmarkValue(42, 17).Encode()); + + Assert.Equal(2, directory.Files.Count); + + var bookmark = Bookmark.Open(directory); + + Assert.Equal($"{3L:x16}.bookmark", directory.Files.Single().Key); + + Assert.True(bookmark.TryGet(out var value)); + Assert.Equal(new BookmarkValue(42, 17), value); + } + + [Fact] + public void OpenDeletesCorruptedBookmarks() + { + var directory = new InMemoryStoreDirectory(); + + directory.Create($"{1L:x16}.bookmark", new BookmarkValue(3, 3478).Encode()); + + // This bookmark is invalid + directory.Create($"{3L:x16}.bookmark", new byte[] { 1, 2, 3 }); + + var bookmark = Bookmark.Open(directory); + + Assert.Empty(directory.Files); + + Assert.True(bookmark.TrySet(new BookmarkValue(42, 17))); + + Assert.Equal($"{4L:x16}.bookmark", directory.Files.Single().Key); + } + + [Fact] + public void OpenDeletesMisnamedBookmarks() + { + var directory = new InMemoryStoreDirectory(); + + directory.Create($"{1L:x16}.bookmark", new BookmarkValue(3, 3478).Encode()); + + // This bookmark is invalid + directory.Create($"ff{3L:x16}.bookmark", new BookmarkValue(42, 17).Encode()); + + var bookmark = Bookmark.Open(directory); + + Assert.Single(directory.Files); + + Assert.True(bookmark.TryGet(out var value)); + Assert.Equal(new BookmarkValue(3, 3478), value); + } +} diff --git a/test/SeqCli.Tests/Forwarder/Storage/BufferTests.cs b/test/SeqCli.Tests/Forwarder/Storage/BufferTests.cs new file mode 100644 index 00000000..86129275 --- /dev/null +++ b/test/SeqCli.Tests/Forwarder/Storage/BufferTests.cs @@ -0,0 +1,283 @@ +using System.Linq; +using SeqCli.Forwarder.Storage; +using SeqCli.Tests.Forwarder.Filesystem; +using Xunit; + +namespace SeqCli.Tests.Forwarder.Storage; + +public class BufferTests +{ + [Fact] + public void OpenAppendRead() + { + var directory = new InMemoryStoreDirectory(); + + using var writer = BufferAppender.Open(directory); + var reader = BufferReader.Open(directory); + + Assert.Empty(directory.Files); + + // Append a payload + Assert.True(writer.TryAppend("{\"id\":1}\n"u8.ToArray(), long.MaxValue)); + Assert.Single(directory.Files); + + // Read the payload + Assert.False(reader.TryFillBatch(10, out _)); + Assert.True(reader.TryFillBatch(10, out var batch)); + var batchBuffer = batch.Value; + Assert.Equal(new BufferReaderHead(1, 9), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":1}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + + // Advance the reader + reader.AdvanceTo(batchBuffer.ReaderHead); + Assert.False(reader.TryFillBatch(10, out batch)); + + // Append another payload + Assert.True(writer.TryAppend("{\"id\":2}\n"u8.ToArray(), long.MaxValue)); + Assert.Single(directory.Files); + + // Read the payload + Assert.True(reader.TryFillBatch(10, out batch)); + batchBuffer = batch.Value; + Assert.Equal(new BufferReaderHead(1, 18), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":2}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + + // Advance the reader + reader.AdvanceTo(batchBuffer.ReaderHead); + Assert.False(reader.TryFillBatch(10, out batch)); + } + + [Fact] + public void ReadWaitsUntilCompleteDataOnLastChunk() + { + var directory = new InMemoryStoreDirectory(); + + var chunk = directory.Create(new ChunkName(1).ToString(), "{\"id\":1"u8.ToArray()); + + var reader = BufferReader.Open(directory); + + Assert.False(reader.TryFillBatch(512, out _)); + + chunk.Append("}"u8.ToArray()); + + Assert.False(reader.TryFillBatch(512, out _)); + + chunk.Append("\n"u8.ToArray()); + + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; + + Assert.Equal(new BufferReaderHead(1, 9), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":1}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + } + + [Fact] + public void ReadDiscardsPreviouslyReadChunks() + { + var directory = new InMemoryStoreDirectory(); + + directory.Create(new ChunkName(1).ToString(), "{\"id\":1}\n"u8.ToArray()); + directory.Create(new ChunkName(2).ToString(), "{\"id\":2}\n"u8.ToArray()); + + var reader = BufferReader.Open(directory); + + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; + Assert.Equal(new BufferReaderHead(2, 9), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":1}\n{\"id\":2}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + + Assert.Equal(2, directory.Files.Count); + + reader.AdvanceTo(batchBuffer.ReaderHead); + + Assert.Single(directory.Files); + + directory.Create(new ChunkName(1).ToString(), "{\"id\":1}\n"u8.ToArray()); + directory.Create(new ChunkName(3).ToString(), "{\"id\":3}\n"u8.ToArray()); + + Assert.Equal(3, directory.Files.Count); + + Assert.False(reader.TryFillBatch(512, out _)); + Assert.True(reader.TryFillBatch(512, out batch)); + batchBuffer = batch.Value; + Assert.Equal(new BufferReaderHead(3, 9), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":3}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + + reader.AdvanceTo(batchBuffer.ReaderHead); + + Assert.Single(directory.Files); + } + + [Fact] + public void ReadDiscardsOversizePayloads() + { + var directory = new InMemoryStoreDirectory(); + + using var writer = BufferAppender.Open(directory); + + Assert.True(writer.TryAppend("{\"id\":1}\n"u8.ToArray(), long.MaxValue)); + + var reader = BufferReader.Open(directory); + + Assert.False(reader.TryFillBatch(5, out _)); + Assert.False(reader.TryFillBatch(512, out _)); + } + + [Fact] + public void ReadDoesNotDiscardAcrossFiles() + { + var directory = new InMemoryStoreDirectory(); + + directory.Create(new ChunkName(1).ToString(), "{\"id\":1}"u8.ToArray()); + directory.Create(new ChunkName(2).ToString(), "{\"id\":2}\n"u8.ToArray()); + + var reader = BufferReader.Open(directory); + + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; + + Assert.Equal(new BufferReaderHead(2, 9), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":2}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + } + + [Fact] + public void ReadStopsDiscardingOnExternalDelete() + { + var directory = new InMemoryStoreDirectory(); + + directory.Create(new ChunkName(1).ToString(), "{\"id\":1}"u8.ToArray()); + + var reader = BufferReader.Open(directory); + + Assert.False(reader.TryFillBatch(5, out _)); + + // Deleting the file here will cause our discarding chunk to change + Assert.True(directory.TryDelete(new ChunkName(1).ToString())); + directory.Create(new ChunkName(2).ToString(), "{\"id\":2}\n"u8.ToArray()); + + Assert.False(reader.TryFillBatch(512, out _)); + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; + + Assert.Equal(new BufferReaderHead(2, 9), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":2}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + } + + [Fact] + public void ReadStopsDiscardingOnExternalCreate() + { + var directory = new InMemoryStoreDirectory(); + + directory.Create(new ChunkName(1).ToString(), "{\"id\":1}"u8.ToArray()); + + var reader = BufferReader.Open(directory); + + Assert.False(reader.TryFillBatch(5, out _)); + + // Creating a new file here will cause a new one to be created + directory.Create(new ChunkName(2).ToString(), "{\"id\":2}\n"u8.ToArray()); + + Assert.False(reader.TryFillBatch(512, out _)); + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; + + Assert.Equal(new BufferReaderHead(2, 9), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":2}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + } + + [Fact] + public void AppendRolloverOnWrite() + { + var directory = new InMemoryStoreDirectory(); + + using var writer = BufferAppender.Open(directory); + var reader = BufferReader.Open(directory); + + Assert.Empty(directory.Files); + + Assert.True(writer.TryAppend("{\"id\":1}\n"u8.ToArray(), 17)); + Assert.True(writer.TryAppend("{\"id\":2}\n"u8.ToArray(), 17)); + + Assert.Single(directory.Files); + + Assert.True(writer.TryAppend("{\"id\":3}\n"u8.ToArray(), 17)); + + Assert.Equal(2, directory.Files.Count); + + Assert.False(reader.TryFillBatch(512, out _)); + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; + + Assert.Equal(new BufferReaderHead(2, 9), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":1}\n{\"id\":2}\n{\"id\":3}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + + reader.AdvanceTo(batchBuffer.ReaderHead); + + Assert.Single(directory.Files); + } + + [Fact] + public void ExistingFilesAreReadonly() + { + var directory = new InMemoryStoreDirectory(); + + directory.Create(new ChunkName(0).ToString()); + + using var writer = BufferAppender.Open(directory); + var reader = BufferReader.Open(directory); + + Assert.Single(directory.Files); + + Assert.True(writer.TryAppend("{\"id\":1}\n"u8.ToArray(), long.MaxValue)); + + Assert.Equal(2, directory.Files.Count); + + Assert.False(reader.TryFillBatch(512, out _)); + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; + + Assert.Equal(new BufferReaderHead(1, 9), batchBuffer.ReaderHead); + } + + [Fact] + public void OpenReadAcrossChunks() + { + var directory = new InMemoryStoreDirectory(); + + directory.Create(new ChunkName(0).ToString(), "{\"id\":1}\n"u8.ToArray()); + directory.Create(new ChunkName(1).ToString(), "{\"id\":2}\n"u8.ToArray()); + directory.Create(new ChunkName(2).ToString(), "{\"id\":3}\n"u8.ToArray()); + + var reader = BufferReader.Open(directory); + + Assert.Equal(3, directory.Files.Count); + + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; + + Assert.Equal("{\"id\":1}\n{\"id\":2}\n{\"id\":3}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + + reader.AdvanceTo(batchBuffer.ReaderHead); + + Assert.Single(directory.Files); + } + + [Fact] + public void MaxChunksOnAppender() + { + var directory = new InMemoryStoreDirectory(); + + using var appender = BufferAppender.Open(directory); + + for (var i = 0; i < 10; i++) Assert.True(appender.TryAppend("{\"id\":1}\n"u8.ToArray(), 5, 3)); + + var files = directory.Files.Select(f => f.Key).ToList(); + files.Sort(); + + Assert.Equal([ + "0000000000000008.clef", + "0000000000000009.clef", + "000000000000000a.clef" + ], files); + } +} diff --git a/test/SeqCli.Tests/Forwarder/Storage/IdentifierTests.cs b/test/SeqCli.Tests/Forwarder/Storage/IdentifierTests.cs new file mode 100644 index 00000000..b5d1f752 --- /dev/null +++ b/test/SeqCli.Tests/Forwarder/Storage/IdentifierTests.cs @@ -0,0 +1,31 @@ +using SeqCli.Forwarder.Storage; +using Xunit; + +namespace SeqCli.Tests.Forwarder.Storage; + +public class IdentifierTests +{ + [Theory] + [InlineData("0000000000000000.clef", 0)] + [InlineData("0000000000000001.clef", 1)] + [InlineData("000000000000000a.clef", 10)] + [InlineData("ffffffffffffffff.clef", ulong.MaxValue)] + public void ParseValid(string name, ulong expected) + { + Assert.True(ChunkName.TryParse(name, out var actual)); + + Assert.Equal(expected, actual.Value.Id); + Assert.Equal(name, actual.Value.ToString()); + } + + [Theory] + [InlineData("0.clef")] + [InlineData("one.clef")] + [InlineData("00000000000.clef.value")] + [InlineData("0ffffffffffffffff.clef")] + [InlineData("0xffffffffffffff.clef")] + public void ParseInvalid(string name) + { + Assert.False(ChunkName.TryParse(name, out _)); + } +} diff --git a/test/SeqCli.Tests/SeqCli.Tests.csproj b/test/SeqCli.Tests/SeqCli.Tests.csproj index e515788c..dc93e152 100644 --- a/test/SeqCli.Tests/SeqCli.Tests.csproj +++ b/test/SeqCli.Tests/SeqCli.Tests.csproj @@ -5,7 +5,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/SeqCli.Tests/Signals/SignalExpressionParserTests.cs b/test/SeqCli.Tests/Signals/SignalExpressionParserTests.cs index 92eba39d..6b9e2fad 100644 --- a/test/SeqCli.Tests/Signals/SignalExpressionParserTests.cs +++ b/test/SeqCli.Tests/Signals/SignalExpressionParserTests.cs @@ -17,15 +17,15 @@ public void ParseSuccessfully((string, string) inputs) } public static IEnumerable _sources = new []{ - new object[] { ("signal-1 ", "signal-1") }, - - new object[] { ("(signal-1)", "signal-1") }, - - new object[] { ("signal-1 ,signal-2", "signal-1,signal-2") }, - - new object[] { (" signal-1,signal-2~ signal-3", "(signal-1,signal-2)~signal-3") }, - - new object[] { ("signal-1,signal-2,(signal-3~signal-4)", "(signal-1,signal-2),(signal-3~signal-4)") }, + [("signal-1 ", "signal-1")], + + [("(signal-1)", "signal-1")], + + [("signal-1 ,signal-2", "signal-1,signal-2")], + + [(" signal-1,signal-2~ signal-3", "(signal-1,signal-2)~signal-3")], + + [("signal-1,signal-2,(signal-3~signal-4)", "(signal-1,signal-2),(signal-3~signal-4)")], new object[] { ("signal-1~( (signal-2~signal-3) ,signal-4)", "signal-1~((signal-2~signal-3),signal-4)") } }; diff --git a/test/SeqCli.Tests/Support/ActionCommand.cs b/test/SeqCli.Tests/Support/ActionCommand.cs new file mode 100644 index 00000000..bf7ae4c9 --- /dev/null +++ b/test/SeqCli.Tests/Support/ActionCommand.cs @@ -0,0 +1,12 @@ +using System; +using SeqCli.Cli; + +namespace SeqCli.Tests.Support; + +class ActionCommand : Command +{ + public ActionCommand(Action action) + { + action.Invoke(); + } +} \ No newline at end of file diff --git a/test/SeqCli.Tests/Support/Some.cs b/test/SeqCli.Tests/Support/Some.cs index 17d1f829..4d6f5e3b 100644 --- a/test/SeqCli.Tests/Support/Some.cs +++ b/test/SeqCli.Tests/Support/Some.cs @@ -1,12 +1,17 @@ using System; using System.Linq; +using System.Security.Cryptography; using Serilog.Events; using Serilog.Parsing; namespace SeqCli.Tests.Support; +#nullable enable + static class Some { + static readonly RandomNumberGenerator Rng = RandomNumberGenerator.Create(); + public static LogEvent LogEvent() { return new LogEvent( @@ -26,4 +31,16 @@ public static string UriString() { return "http://example.com"; } + + public static byte[] Bytes(int count) + { + var bytes = new byte[count]; + Rng.GetBytes(bytes); + return bytes; + } + + public static string ApiKey() + { + return string.Join("", Bytes(8).Select(v => v.ToString("x2")).ToArray()); + } } \ No newline at end of file diff --git a/test/SeqCli.Tests/Support/TempFolder.cs b/test/SeqCli.Tests/Support/TempFolder.cs new file mode 100644 index 00000000..8a5e85a0 --- /dev/null +++ b/test/SeqCli.Tests/Support/TempFolder.cs @@ -0,0 +1,50 @@ +#nullable enable + +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; + +namespace SeqCli.Tests.Support; + +class TempFolder : IDisposable +{ + static readonly Guid Session = Guid.NewGuid(); + + public TempFolder(string name) + { + Path = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "SeqCli.Forwarder.Tests", + Session.ToString("n"), + name); + + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) + Directory.Delete(Path, true); + } + catch (Exception ex) + { + Debug.WriteLine(ex); + } + } + + public static TempFolder ForCaller([CallerMemberName] string? caller = null) + { + if (caller == null) throw new ArgumentNullException(nameof(caller)); + return new TempFolder(caller); + } + + public string AllocateFilename(string? ext = null) + { + return System.IO.Path.Combine(Path, Guid.NewGuid().ToString("n") + "." + (ext ?? "tmp")); + } +} \ No newline at end of file diff --git a/test/SeqCli.Tests/Templates/TemplateWriterTests.cs b/test/SeqCli.Tests/Templates/TemplateWriterTests.cs index 49629e07..84707b47 100644 --- a/test/SeqCli.Tests/Templates/TemplateWriterTests.cs +++ b/test/SeqCli.Tests/Templates/TemplateWriterTests.cs @@ -29,8 +29,8 @@ public async Task WritesTemplates() Id = "test-stuff", Name = "Test Stuff", ReferencedId = "test-ref", - Numbers = new List { 1, 2, 3 }, - Strings = new List { "test" }, + Numbers = [1, 2, 3], + Strings = ["test"], Dictionary = new Dictionary{ ["First"] = "a" } };