From c779f866e3c151f8f481b070fecaac60728951b8 Mon Sep 17 00:00:00 2001 From: Victor Chang Date: Thu, 23 Sep 2021 16:40:39 -0700 Subject: [PATCH 1/2] Docker support for CLI `start/stop/restart` --- .gitignore | 3 + .vscode/launch.json | 2 +- Dockerfile | 2 +- src/Api/FileStorageInfo.cs | 22 +- src/CLI/Commands/AetCommand.cs | 24 +- src/CLI/Commands/CommandBase.cs | 27 +- src/CLI/Commands/ConfigCommand.cs | 102 ++++++-- src/CLI/Commands/DestinationCommand.cs | 24 +- src/CLI/Commands/RestartCommand.cs | 7 +- src/CLI/Commands/SourceCommand.cs | 24 +- src/CLI/Commands/StartCommand.cs | 18 +- src/CLI/Commands/StatusCommand.cs | 8 +- src/CLI/Commands/StopCommand.cs | 11 +- src/CLI/ExitCodes.cs | 1 + ...Monai.Deploy.InformaticsGateway.CLI.csproj | 5 + src/CLI/Options/Common.cs | 12 +- src/CLI/Program.cs | 3 + src/CLI/Services/ConfigurationService.cs | 241 ++++++++++++++++-- src/CLI/Services/ConfirmationPrompt.cs | 2 +- src/CLI/Services/ContainerRunnerFactory.cs | 41 +++ src/CLI/Services/ControlService.cs | 54 +++- src/CLI/Services/DockerRunner.cs | 160 ++++++++++++ src/CLI/Services/IContainerRunner.cs | 84 ++++++ .../IContainerRunnerFactory.cs} | 15 +- src/Client.Common/GuardExtensions.cs | 15 ++ src/Client/Services/AeTitle{T}Service.cs | 12 +- src/Client/Services/HealthService.cs | 6 +- src/Client/Services/InferenceService.cs | 6 +- src/Configuration/ConfigurationValidator.cs | 10 +- .../MonaiWorkloadManagerConfiguration.cs | 12 +- .../Test/ConfigurationValidatorTest.cs | 24 +- src/Database/FileStorageInfoConfiguration.cs | 7 +- ... 20210923225957_R1_Initialize.Designer.cs} | 19 +- ...ize.cs => 20210923225957_R1_Initialize.cs} | 11 +- .../InformaticsGatewayContextModelSnapshot.cs | 17 +- .../InformaticsGatewayRepository.cs | 8 +- .../Repositories/WorkloadManagerApi.cs | 8 +- .../Connectors/DataRetrievalService.cs | 6 +- .../WorkloadManagerNotificationService.cs | 14 +- .../Services/Scp/ApplicationEntityManager.cs | 7 +- .../Scp/FileStoredNotificationQueue.cs | 33 ++- .../Scp/IFileStoredNotificationQueue.cs | 5 +- .../Scp/FileStoredNotificationQueueTest.cs | 11 +- src/InformaticsGateway/appsettings.json | 24 +- 44 files changed, 907 insertions(+), 240 deletions(-) create mode 100644 src/CLI/Services/ContainerRunnerFactory.cs create mode 100644 src/CLI/Services/DockerRunner.cs create mode 100644 src/CLI/Services/IContainerRunner.cs rename src/CLI/{Options/ConfigurationOptions.cs => Services/IContainerRunnerFactory.cs} (59%) rename src/Database/Migrations/{20210819223503_R1_Initialize.Designer.cs => 20210923225957_R1_Initialize.Designer.cs} (89%) rename src/Database/Migrations/{20210819223503_R1_Initialize.cs => 20210923225957_R1_Initialize.cs} (93%) diff --git a/.gitignore b/.gitignore index 1fc441d74..b6bd1b66c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ # Created by https://www.toptal.com/developers/gitignore/api/aspnetcore,dotnetcore,visualstudio,visualstudiocode # Edit at https://www.toptal.com/developers/gitignore?templates=aspnetcore,dotnetcore,visualstudio,visualstudiocode +# MIG +cli/ + # Database *.db diff --git a/.vscode/launch.json b/.vscode/launch.json index 42ddaf7d6..c5dcb8039 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,7 @@ "request": "launch", "preLaunchTask": "build-cli", "program": "${workspaceFolder}/src/CLI/bin/Debug/net5.0/linux-x64/mig-cli", - "args": ["aet", "add", "-a", "TEST", "--apps", "1,2,3"], + "args": ["config", "endpoint", "http://localhost:4500"], "cwd": "${workspaceFolder}/src/CLI/bin/Debug/net5.0/linux-x64", "stopAtEntry": true, "console": "internalConsole" diff --git a/Dockerfile b/Dockerfile index c10f1d02d..b83099d20 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ RUN echo "Building MONAI Deploy Informatics Gateway $Version ($FileVersion)..." RUN dotnet publish -c Release -o out --nologo /p:Version=$Version /p:FileVersion=$FileVersion src/InformaticsGateway/Monai.Deploy.InformaticsGateway.csproj # Build runtime image -FROM mcr.microsoft.com/dotnet/runtime:5.0-focal +FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal ENV DEBIAN_FRONTEND=noninteractive diff --git a/src/Api/FileStorageInfo.cs b/src/Api/FileStorageInfo.cs index 646a174c6..148d77c69 100644 --- a/src/Api/FileStorageInfo.cs +++ b/src/Api/FileStorageInfo.cs @@ -10,6 +10,7 @@ // limitations under the License. using Ardalis.GuardClauses; +using System; using System.IO.Abstractions; namespace Monai.Deploy.InformaticsGateway.Api @@ -21,6 +22,11 @@ public record FileStorageInfo { private readonly IFileSystem _fileSystem; + /// + /// Gets the unique ID of the file. + /// + public Guid Id { get; init; } + /// /// Gets the correlation ID of the file. /// For SCP received DICOM instances: use internally generated unique association ID. @@ -44,7 +50,19 @@ public record FileStorageInfo public string[] Applications { get; private set; } /// - /// Gets or set the number of attempts to upload. + /// Gets or sets the DateTime that the file was received. + /// + /// + public DateTime Received { get; set; } + + /// + /// Gets or set database row versioning info. + /// + /// + public byte[] Timestamp { get; set; } + + /// + /// Gets or sets the number of attempts to upload. /// public int TryCount { get; set; } = 0; @@ -67,8 +85,10 @@ public FileStorageInfo(string correlationId, string storageRootPath, string mess } _fileSystem = fileSystem; + Id = Guid.NewGuid(); CorrelationId = correlationId; StorageRootPath = storageRootPath; + Received = DateTime.UtcNow; FilePath = GenerateStoragePath(storageRootPath, correlationId, messageId, fileExtension); } diff --git a/src/CLI/Commands/AetCommand.cs b/src/CLI/Commands/AetCommand.cs index 63466b2db..c04725575 100644 --- a/src/CLI/Commands/AetCommand.cs +++ b/src/CLI/Commands/AetCommand.cs @@ -86,22 +86,22 @@ private async Task ListAeTitlehandlerAsync(IHost host, bool verbose, Cancel this.LogVerbose(verbose, host, "Configuring services..."); var console = host.Services.GetRequiredService(); - var configService = host.Services.GetRequiredService(); + var config = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var consoleRegion = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); Guard.Against.Null(console, nameof(console), "Console service is unavailable."); - Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); + Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); Guard.Against.Null(consoleRegion, nameof(consoleRegion), "Console region is unavailable."); IReadOnlyList items = null; try { - ConfigurationOptions config = LoadConfiguration(verbose, configService, client); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.Endpoint}..."); + client.ConfigureServiceUris(config.InformaticsGatewayServerUri); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); this.LogVerbose(verbose, host, $"Retrieving MONAI SCP AE Titles..."); items = await client.MonaiScpAeTitle.List(cancellationToken); } @@ -143,18 +143,18 @@ private async Task ListAeTitlehandlerAsync(IHost host, bool verbose, Cancel private async Task RemoveAeTitlehandlerAsync(string name, IHost host, bool verbose, CancellationToken cancellationToken) { this.LogVerbose(verbose, host, "Configuring services..."); - var configService = host.Services.GetRequiredService(); + var config = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); - Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); + Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); try { - ConfigurationOptions config = LoadConfiguration(verbose, configService, client); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.Endpoint}..."); + client.ConfigureServiceUris(config.InformaticsGatewayServerUri); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); this.LogVerbose(verbose, host, $"Deleting MONAI SCP AE Title {name}..."); _ = await client.MonaiScpAeTitle.Delete(name, cancellationToken); logger.Log(LogLevel.Information, $"MONAI SCP AE Title '{name}' deleted."); @@ -175,19 +175,19 @@ private async Task RemoveAeTitlehandlerAsync(string name, IHost host, bool private async Task AddAeTitlehandlerAsync(MonaiApplicationEntity entity, IHost host, bool verbose, CancellationToken cancellationToken) { this.LogVerbose(verbose, host, "Configuring services..."); - var configService = host.Services.GetRequiredService(); + var config = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); - Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); + Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); try { - ConfigurationOptions config = LoadConfiguration(verbose, configService, client); + client.ConfigureServiceUris(config.InformaticsGatewayServerUri); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.Endpoint}..."); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); var result = await client.MonaiScpAeTitle.Create(entity, cancellationToken); logger.Log(LogLevel.Information, "New MONAI Deploy SCP Application Entity created:"); diff --git a/src/CLI/Commands/CommandBase.cs b/src/CLI/Commands/CommandBase.cs index 2b45616c7..19d40694a 100644 --- a/src/CLI/Commands/CommandBase.cs +++ b/src/CLI/Commands/CommandBase.cs @@ -48,33 +48,12 @@ protected void LogVerbose(bool verbose, IHost host, string message) } } - protected ConfigurationOptions LoadConfiguration(bool verbose, IConfigurationService configurationService, IInformaticsGatewayClient client) - { - Guard.Against.Null(configurationService, nameof(configurationService)); - Guard.Against.Null(client, nameof(client)); - - var configuration = LoadConfiguration(verbose, configurationService); - client.ConfigureServiceUris(new Uri(configuration.Endpoint)); - return configuration; - } - - protected ConfigurationOptions LoadConfiguration(bool verbose, IConfigurationService configurationService) - { - Guard.Against.Null(configurationService, nameof(configurationService)); - - if (configurationService.ConfigurationExists()) - { - var config = configurationService.Load(verbose); - return config; - } - - throw new ConfigurationException($"{Strings.ApplicationName} endpoint not configured. Please run 'config` first."); - } + protected void AddConfirmationOption() => AddConfirmationOption(this); - protected void AddConfirmationOption() + protected void AddConfirmationOption(Command command) { var confirmationOption = new Option(new[] { "-y", "--yes" }, "Automatic yes to prompts"); - this.AddOption(confirmationOption); + command.AddOption(confirmationOption); } } } diff --git a/src/CLI/Commands/ConfigCommand.cs b/src/CLI/Commands/ConfigCommand.cs index 2014f0563..b434d390e 100644 --- a/src/CLI/Commands/ConfigCommand.cs +++ b/src/CLI/Commands/ConfigCommand.cs @@ -9,13 +9,16 @@ // See the License for the specific language governing permissions and // limitations under the License. +using Ardalis.GuardClauses; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Monai.Deploy.InformaticsGateway.CLI.Services; using System; using System.CommandLine; using System.CommandLine.Invocation; using System.Threading; +using System.Threading.Tasks; namespace Monai.Deploy.InformaticsGateway.CLI { @@ -23,34 +26,68 @@ public class ConfigCommand : CommandBase { public ConfigCommand() : base("config", "Configure the CLI endpoint") { - var endpointOption = new Option(new[] { "-e", "--endpoint" }, $"URL to the {Strings.ApplicationName} API. E.g. http://localhost:5000") { IsRequired = true }; - this.AddOption(endpointOption); + AddCommandEndpoint(); + AddCommandRunner(); - this.Handler = CommandHandler.Create(ConfigCommandHandler); + SetupInitCommand(); + SetupShowConfigCommand(); + } + + private void AddCommandRunner() + { + var endpointCommand = new Command("runner", $"Default container runner/orchestration engine to run {Strings.ApplicationName}."); + this.Add(endpointCommand); + + endpointCommand.AddArgument(new Argument("runner")); + endpointCommand.Handler = CommandHandler.Create((Runner runner, IHost host, bool verbose) => + ConfigUpdateHandler(runner, host, verbose, (IConfigurationService options) => + { + options.Runner = runner; + }) + ); + } + + private void AddCommandEndpoint() + { + var endpointCommand = new Command("endpoint", $"URL to the {Strings.ApplicationName} API. E.g. http://localhost:5000"); + this.Add(endpointCommand); + + endpointCommand.AddArgument(new Argument("uri")); + endpointCommand.Handler = CommandHandler.Create((string uri, IHost host, bool verbose) => + ConfigUpdateHandler(uri, host, verbose, (IConfigurationService options) => + { + options.InformaticsGatewayServer = uri; + }) + ); + } + + private void SetupInitCommand() + { + var listCommand = new Command("init", $"Initialize with default configuration options"); + this.AddCommand(listCommand); - SetupShowConfigCmmand(); + listCommand.Handler = CommandHandler.Create(InitHandlerAsync); + this.AddConfirmationOption(listCommand); } - private void SetupShowConfigCmmand() + private void SetupShowConfigCommand() { var showCommand = new Command("show", "Show configurations"); this.AddCommand(showCommand); - showCommand.Handler = CommandHandler.Create(ShowConfiguratonHandler); + showCommand.Handler = CommandHandler.Create(ShowConfigurationHandler); } - private int ShowConfiguratonHandler(IHost host, bool verbose, CancellationToken cancellationToken) + private int ShowConfigurationHandler(IHost host, bool verbose, CancellationToken cancellationToken) { this.LogVerbose(verbose, host, "Configuring services..."); var logger = CreateLogger(host); var configService = host.Services.GetRequiredService(); + Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); try { - var service = host.Services.GetRequiredService(); - ConfigurationOptions config = LoadConfiguration(verbose, configService); - - logger.Log(LogLevel.Information, $"Endpoint: {config.Endpoint}"); + logger.Log(LogLevel.Information, $"Server: {configService.InformaticsGatewayServer}"); } catch (Exception ex) { @@ -60,19 +97,17 @@ private int ShowConfiguratonHandler(IHost host, bool verbose, CancellationToken return ExitCodes.Success; } - private int ConfigCommandHandler(ConfigurationOptions options, IHost host, bool verbose) + private int ConfigUpdateHandler(T argument, IHost host, bool verbose, Action updater) { var logger = CreateLogger(host); + var config = host.Services.GetRequiredService(); + + Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); try { - options.Validate(); - var service = host.Services.GetRequiredService(); - service.CreateConfigDirectoryIfNotExist(); - - var configuration = service.Load(verbose); - configuration.Endpoint = options.Endpoint; - service.Save(configuration); + updater(config); + logger.Log(LogLevel.Information, "Configuration updated successfully."); } catch (ArgumentNullException) { @@ -85,5 +120,34 @@ private int ConfigCommandHandler(ConfigurationOptions options, IHost host, bool } return ExitCodes.Success; } + + private async Task InitHandlerAsync(IHost host, bool verbose, bool yes, CancellationToken cancellationToken) + { + var logger = CreateLogger(host); + var configService = host.Services.GetRequiredService(); + var confirmation = host.Services.GetRequiredService(); + Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); + Guard.Against.Null(confirmation, nameof(confirmation), "Confirmation prompt is unavailable."); + + if (!yes) + { + if (configService.IsConfigExists && !confirmation.ShowConfirmationPrompt($"Existing application configuration file already exists. Do you want to overwrite it?")) + { + logger.Log(LogLevel.Warning, "Action cancelled."); + return ExitCodes.Stop_Cancelled; + } + } + + try + { + await configService.Initialize(); + } + catch (Exception ex) + { + logger.Log(LogLevel.Error, ex.Message); + return ExitCodes.Config_ErrorInitializing; + } + return ExitCodes.Success; + } } } diff --git a/src/CLI/Commands/DestinationCommand.cs b/src/CLI/Commands/DestinationCommand.cs index dc9811393..3c3da4189 100644 --- a/src/CLI/Commands/DestinationCommand.cs +++ b/src/CLI/Commands/DestinationCommand.cs @@ -84,22 +84,22 @@ private async Task ListDestinationHandlerAsync(DestinationApplicationEntity this.LogVerbose(verbose, host, "Configuring services..."); var console = host.Services.GetRequiredService(); - var configService = host.Services.GetRequiredService(); + var config = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var consoleRegion = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); Guard.Against.Null(console, nameof(console), "Console service is unavailable."); - Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); + Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); Guard.Against.Null(consoleRegion, nameof(consoleRegion), "Console region is unavailable."); IReadOnlyList items = null; try { - ConfigurationOptions config = LoadConfiguration(verbose, configService, client); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.Endpoint}..."); + client.ConfigureServiceUris(config.InformaticsGatewayServerUri); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); this.LogVerbose(verbose, host, $"Retrieving DICOM destinations..."); items = await client.DicomDestinations.List(cancellationToken); } @@ -137,18 +137,18 @@ private async Task ListDestinationHandlerAsync(DestinationApplicationEntity private async Task RemoveDestinationHandlerAsync(string name, IHost host, bool verbose, CancellationToken cancellationToken) { this.LogVerbose(verbose, host, "Configuring services..."); - var configService = host.Services.GetRequiredService(); + var config = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); - Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); + Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); try { - ConfigurationOptions config = LoadConfiguration(verbose, configService, client); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.Endpoint}..."); + client.ConfigureServiceUris(config.InformaticsGatewayServerUri); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); this.LogVerbose(verbose, host, $"Deleting DICOM destination {name}..."); _ = await client.DicomDestinations.Delete(name, cancellationToken); logger.Log(LogLevel.Information, $"DICOM destination '{name}' deleted."); @@ -164,19 +164,19 @@ private async Task RemoveDestinationHandlerAsync(string name, IHost host, b private async Task AddDestinationHandlerAsync(DestinationApplicationEntity entity, IHost host, bool verbose, CancellationToken cancellationToken) { this.LogVerbose(verbose, host, "Configuring services..."); - var configService = host.Services.GetRequiredService(); + var config = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); - Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); + Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); try { - ConfigurationOptions config = LoadConfiguration(verbose, configService, client); + client.ConfigureServiceUris(config.InformaticsGatewayServerUri); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.Endpoint}..."); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); var result = await client.DicomDestinations.Create(entity, cancellationToken); logger.Log(LogLevel.Information, "New DICOM destination created:"); diff --git a/src/CLI/Commands/RestartCommand.cs b/src/CLI/Commands/RestartCommand.cs index 0932078e2..f9e3ca193 100644 --- a/src/CLI/Commands/RestartCommand.cs +++ b/src/CLI/Commands/RestartCommand.cs @@ -16,6 +16,7 @@ using Monai.Deploy.InformaticsGateway.CLI.Services; using System; using System.CommandLine.Invocation; +using System.Threading; using System.Threading.Tasks; namespace Monai.Deploy.InformaticsGateway.CLI @@ -25,10 +26,10 @@ public class RestartCommand : CommandBase public RestartCommand() : base("restart", $"Restart the {Strings.ApplicationName} service") { this.AddConfirmationOption(); - this.Handler = CommandHandler.Create(RestartCommandHandler); + this.Handler = CommandHandler.Create(RestartCommandHandler); } - private async Task RestartCommandHandler(IHost host, bool yes, bool verbose) + private async Task RestartCommandHandler(IHost host, bool yes, bool verbose, CancellationToken cancellationToken) { var service = host.Services.GetRequiredService(); var confirmation = host.Services.GetRequiredService(); @@ -49,7 +50,7 @@ private async Task RestartCommandHandler(IHost host, bool yes, bool verbose try { - await service.Restart(); + await service.Restart(cancellationToken); } catch (Exception ex) { diff --git a/src/CLI/Commands/SourceCommand.cs b/src/CLI/Commands/SourceCommand.cs index 8c35414b4..ea705f45f 100644 --- a/src/CLI/Commands/SourceCommand.cs +++ b/src/CLI/Commands/SourceCommand.cs @@ -81,22 +81,22 @@ private async Task ListSourceHandlerAsync(SourceApplicationEntity entity, I this.LogVerbose(verbose, host, "Configuring services..."); var console = host.Services.GetRequiredService(); - var configService = host.Services.GetRequiredService(); + var config = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var consoleRegion = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); Guard.Against.Null(console, nameof(console), "Console service is unavailable."); - Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); + Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); Guard.Against.Null(consoleRegion, nameof(consoleRegion), "Console region is unavailable."); IReadOnlyList items = null; try { - ConfigurationOptions config = LoadConfiguration(verbose, configService, client); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.Endpoint}..."); + client.ConfigureServiceUris(config.InformaticsGatewayServerUri); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); this.LogVerbose(verbose, host, $"Retrieving DICOM sources..."); items = await client.DicomSources.List(cancellationTokena); } @@ -138,18 +138,18 @@ private async Task ListSourceHandlerAsync(SourceApplicationEntity entity, I private async Task RemoveSourceHandlerAsync(string name, IHost host, bool verbose, CancellationToken cancellationTokena) { this.LogVerbose(verbose, host, "Configuring services..."); - var configService = host.Services.GetRequiredService(); + var config = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); - Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); + Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); try { - ConfigurationOptions config = LoadConfiguration(verbose, configService, client); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.Endpoint}..."); + client.ConfigureServiceUris(config.InformaticsGatewayServerUri); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); this.LogVerbose(verbose, host, $"Deleting DICOM source {name}..."); _ = await client.DicomSources.Delete(name, cancellationTokena); logger.Log(LogLevel.Information, $"DICOM source '{name}' deleted."); @@ -170,18 +170,18 @@ private async Task RemoveSourceHandlerAsync(string name, IHost host, bool v private async Task AddSourceHandlerAsync(SourceApplicationEntity entity, IHost host, bool verbose, CancellationToken cancellationTokena) { this.LogVerbose(verbose, host, "Configuring services..."); - var configService = host.Services.GetRequiredService(); + var config = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); - Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); + Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); try { - ConfigurationOptions config = LoadConfiguration(verbose, configService, client); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.Endpoint}..."); + client.ConfigureServiceUris(config.InformaticsGatewayServerUri); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); this.LogVerbose(verbose, host, $"Creating new DICOM source {entity.AeTitle}..."); var result = await client.DicomSources.Create(entity, cancellationTokena); diff --git a/src/CLI/Commands/StartCommand.cs b/src/CLI/Commands/StartCommand.cs index ce0452606..7c9b4f9bb 100644 --- a/src/CLI/Commands/StartCommand.cs +++ b/src/CLI/Commands/StartCommand.cs @@ -16,6 +16,7 @@ using Monai.Deploy.InformaticsGateway.CLI.Services; using System; using System.CommandLine.Invocation; +using System.Threading; using System.Threading.Tasks; namespace Monai.Deploy.InformaticsGateway.CLI @@ -25,10 +26,10 @@ public class StartCommand : CommandBase public StartCommand() : base("start", $"Start the {Strings.ApplicationName} service") { this.AddConfirmationOption(); - this.Handler = CommandHandler.Create(StartCommandHandler); + this.Handler = CommandHandler.Create(StartCommandHandler); } - private async Task StartCommandHandler(IHost host, bool yes, bool verbose) + private async Task StartCommandHandler(IHost host, bool verbose, CancellationToken cancellationToken) { var service = host.Services.GetRequiredService(); var confirmation = host.Services.GetRequiredService(); @@ -38,22 +39,13 @@ private async Task StartCommandHandler(IHost host, bool yes, bool verbose) Guard.Against.Null(service, nameof(service), "Control service is unavailable."); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); - if (!yes) - { - if (!confirmation.ShowConfirmationPrompt($"Do you want to restart {Strings.ApplicationName}?")) - { - logger.Log(LogLevel.Warning, "Action cancelled."); - return ExitCodes.Start_Cancelled; - } - } - try { - await service.Start(); + await service.Start(cancellationToken); } catch (Exception ex) { - logger.Log(LogLevel.Critical, $"Error starting {Strings.ApplicationName}: {ex.Message}"); + logger.Log(LogLevel.Critical, ex.Message); return ExitCodes.Start_Error; } return ExitCodes.Success; diff --git a/src/CLI/Commands/StatusCommand.cs b/src/CLI/Commands/StatusCommand.cs index 2130dcc27..3b263b54c 100644 --- a/src/CLI/Commands/StatusCommand.cs +++ b/src/CLI/Commands/StatusCommand.cs @@ -33,20 +33,20 @@ private async Task StatusCommandHandlerAsync(IHost host, bool verbose, Canc { this.LogVerbose(verbose, host, "Configuring services..."); - var configService = host.Services.GetRequiredService(); + var config = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); - Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); + Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); HealthStatusResponse response = null; try { - ConfigurationOptions config = LoadConfiguration(verbose, configService, client); + client.ConfigureServiceUris(config.InformaticsGatewayServerUri); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.Endpoint}..."); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); this.LogVerbose(verbose, host, $"Retrieving service status..."); response = await client.Health.Status(cancellationToken); } diff --git a/src/CLI/Commands/StopCommand.cs b/src/CLI/Commands/StopCommand.cs index 018b24d4a..e7e57e73f 100644 --- a/src/CLI/Commands/StopCommand.cs +++ b/src/CLI/Commands/StopCommand.cs @@ -16,6 +16,7 @@ using Monai.Deploy.InformaticsGateway.CLI.Services; using System; using System.CommandLine.Invocation; +using System.Threading; using System.Threading.Tasks; namespace Monai.Deploy.InformaticsGateway.CLI @@ -25,10 +26,10 @@ public class StopCommand : CommandBase public StopCommand() : base("stop", $"Stop the {Strings.ApplicationName} service") { this.AddConfirmationOption(); - this.Handler = CommandHandler.Create(StopCommandHandler); + this.Handler = CommandHandler.Create(StopCommandHandler); } - private async Task StopCommandHandler(IHost host, bool yes, bool verbose) + private async Task StopCommandHandler(IHost host, bool yes, bool verbose, CancellationToken cancellationToken) { var service = host.Services.GetRequiredService(); var confirmation = host.Services.GetRequiredService(); @@ -40,7 +41,7 @@ private async Task StopCommandHandler(IHost host, bool yes, bool verbose) if (!yes) { - if (!confirmation.ShowConfirmationPrompt($"Do you want to restart {Strings.ApplicationName}?")) + if (!confirmation.ShowConfirmationPrompt($"Do you want to stop {Strings.ApplicationName}?")) { logger.Log(LogLevel.Warning, "Action cancelled."); return ExitCodes.Stop_Cancelled; @@ -49,11 +50,11 @@ private async Task StopCommandHandler(IHost host, bool yes, bool verbose) try { - await service.Stop(); + await service.Stop(cancellationToken); } catch (Exception ex) { - logger.Log(LogLevel.Critical, $"Error stopping {Strings.ApplicationName}: {ex.Message}"); + logger.Log(LogLevel.Critical, ex, ex.Message); return ExitCodes.Stop_Error; } return ExitCodes.Success; diff --git a/src/CLI/ExitCodes.cs b/src/CLI/ExitCodes.cs index c151c34ad..59f8d2d28 100644 --- a/src/CLI/ExitCodes.cs +++ b/src/CLI/ExitCodes.cs @@ -17,6 +17,7 @@ public static class ExitCodes public const int Config_NotConfigured = 100; public const int Config_ErrorSaving = 101; + public const int Config_ErrorInitializing = 102; public const int MonaiScp_ErrorList = 200; public const int MonaiScp_ErrorDelete = 201; diff --git a/src/CLI/Monai.Deploy.InformaticsGateway.CLI.csproj b/src/CLI/Monai.Deploy.InformaticsGateway.CLI.csproj index e9e324b3a..20725a0ad 100644 --- a/src/CLI/Monai.Deploy.InformaticsGateway.CLI.csproj +++ b/src/CLI/Monai.Deploy.InformaticsGateway.CLI.csproj @@ -33,9 +33,14 @@ + + + + + diff --git a/src/CLI/Options/Common.cs b/src/CLI/Options/Common.cs index 084a5b17d..cb2a93aec 100644 --- a/src/CLI/Options/Common.cs +++ b/src/CLI/Options/Common.cs @@ -11,12 +11,20 @@ using System; using System.IO; +using System.Linq; +using System.Reflection; namespace Monai.Deploy.InformaticsGateway.CLI { public class Common { - public static readonly string MigDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".mig"); - public static readonly string CliConfigFilePath = Path.Combine(MigDirectory, "cli.config"); + public static readonly string HomeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + public static readonly string MigDirectory = Path.Combine(HomeDir, ".mig"); + public static readonly string DatabaseDirectory = Path.Combine(MigDirectory, "database"); + public static readonly string ContainerApplicationRootPath = "/opt/monai/ig"; + public static readonly string MountedConfigFilePath = Path.Combine(ContainerApplicationRootPath, "appsettings.json"); + public static readonly string MountedDatabasePath = "/database"; + public static readonly string ConfigFilePath = Path.Combine(MigDirectory, "appsettings.json"); + public static readonly string AppSettingsResourceName = $"{Assembly.GetEntryAssembly().GetTypes().First().Namespace}.Resources.appsettings.json"; } } diff --git a/src/CLI/Program.cs b/src/CLI/Program.cs index bde8ee330..ef64d59fe 100644 --- a/src/CLI/Program.cs +++ b/src/CLI/Program.cs @@ -55,6 +55,9 @@ private static async Task Main(string[] args) services.AddSingleton(p => p.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + }); }) .AddGlobalOption(verboseOption) diff --git a/src/CLI/Services/ConfigurationService.cs b/src/CLI/Services/ConfigurationService.cs index b3dc12388..b469e6e10 100644 --- a/src/CLI/Services/ConfigurationService.cs +++ b/src/CLI/Services/ConfigurationService.cs @@ -9,31 +9,59 @@ // See the License for the specific language governing permissions and // limitations under the License. +using Ardalis.GuardClauses; using Microsoft.Extensions.Logging; +using Monai.Deploy.InformaticsGateway.Client.Common; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using System; using System.IO.Abstractions; +using System.Reflection; +using System.Threading.Tasks; namespace Monai.Deploy.InformaticsGateway.CLI { - public interface IConfigurationService + public enum Runner { - void CreateConfigDirectoryIfNotExist(); - - bool ConfigurationExists(); + Docker, + Kubernetes, + Helm, + } - ConfigurationOptions Load(); + public interface IConfigurationService + { + string TempStoragePath { get; } + string LogStoragePath { get; } + string HostDataStorageMount { get; } + string HostDatabaseStorageMount { get; } + string HostLogsStorageMount { get; } + string InformaticsGatewayServer { get; set; } + Uri InformaticsGatewayServerUri { get; } + string WorkloadManagerRestEndpoint { get; set; } + string WorkloadManagerGrpcEndpoint { get; set; } + int DicomListeningPort { get; set; } + int InformaticsGatewayServerPort { get; } + Runner Runner { get; set; } + string DockerImagePrefix { get; } - ConfigurationOptions Load(bool verbose); + bool IsConfigExists { get; } + bool IsInitialized { get; } + Task Initialize(); + void CreateConfigDirectoryIfNotExist(); - void Save(ConfigurationOptions options); } public class ConfigurationService : IConfigurationService { + private static readonly Object SyncLock = new object(); private readonly ILogger _logger; private readonly IFileSystem _fileSystem; + public bool IsInitialized => _fileSystem.Directory.Exists(Common.MigDirectory) && + IsConfigExists; + + public bool IsConfigExists => _fileSystem.File.Exists(Common.ConfigFilePath); + public ConfigurationService(ILogger logger, IFileSystem fileSystem) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -48,45 +76,206 @@ public void CreateConfigDirectoryIfNotExist() } } - public bool ConfigurationExists() + + public async Task Initialize() + { + this._logger.Log(LogLevel.Debug, $"Reading default application configurations..."); + using var stream = this.GetType().Assembly.GetManifestResourceStream(Common.AppSettingsResourceName); + + if (stream is null) + { + _logger.Log(LogLevel.Debug, $"Available manifest names {string.Join(",", Assembly.GetExecutingAssembly().GetManifestResourceNames())}"); + throw new Exception($"Default configuration '{Common.AppSettingsResourceName}' could not be loaded."); + } + CreateConfigDirectoryIfNotExist(); + + this._logger.Log(LogLevel.Information, $"Saving appsettings.json to {Common.ConfigFilePath}..."); + using var fileStream = _fileSystem.File.Create(Common.ConfigFilePath); + await stream.CopyToAsync(fileStream); + this._logger.Log(LogLevel.Information, $"{Common.ConfigFilePath} updated successfully."); + } + public string InformaticsGatewayServer + { + get + { + return GetValueFromJsonPath("Cli.InformaticsGatewayServerEndpoint"); + } + set + { + Guard.Against.MalformUri(value, nameof(InformaticsGatewayServer)); + var jObject = ReadConfigurationFile(); + jObject["Cli"]["InformaticsGatewayServerEndpoint"] = value; + SaveConfigurationFile(jObject); + } + } + + public Uri InformaticsGatewayServerUri { - return _fileSystem.File.Exists(Common.CliConfigFilePath); + get + { + return new Uri(InformaticsGatewayServer); + } } - public ConfigurationOptions Load() => Load(false); + public int InformaticsGatewayServerPort + { + get + { + return InformaticsGatewayServerUri.Port; + } + } - public ConfigurationOptions Load(bool verbose) + public string WorkloadManagerRestEndpoint { - try + get { - if (verbose) + return GetValueFromJsonPath("InformaticsGateway.workloadManager.restEndpoint"); + } + set + { + Guard.Against.MalformUri(value, nameof(InformaticsGatewayServer)); + var jObject = ReadConfigurationFile(); + jObject["InformaticsGateway"]["workloadManager"]["restEndpoint"] = value; + SaveConfigurationFile(jObject); + } + } + + public string WorkloadManagerGrpcEndpoint + { + get + { + return GetValueFromJsonPath("InformaticsGateway.workloadManager.grpcEndpoint"); + } + set + { + Guard.Against.MalformUri(value, nameof(InformaticsGatewayServer)); + var jObject = ReadConfigurationFile(); + jObject["InformaticsGateway"]["workloadManager"]["grpcEndpoint"] = value; + SaveConfigurationFile(jObject); + } + } + public string DockerImagePrefix + { + get + { + return GetValueFromJsonPath("Cli.DockerImagePrefix"); + } + } + + public int DicomListeningPort + { + get + { + return GetValueFromJsonPath("InformaticsGateway.dicom.scp.port"); + } + set + { + Guard.Against.OutOfRangePort(value, nameof(InformaticsGatewayServer)); + var jObject = ReadConfigurationFile(); + jObject["InformaticsGateway"]["dicom"]["scp"]["port"] = value; + SaveConfigurationFile(jObject); + } + } + + public Runner Runner + { + get + { + var runner = GetValueFromJsonPath("Cli.Runner"); + return (Runner)Enum.Parse(typeof(Runner), runner); + } + set + { + var jObject = ReadConfigurationFile(); + jObject["Cli"]["Runner"] = value.ToString(); + SaveConfigurationFile(jObject); + } + } + + public string HostDataStorageMount + { + get + { + var path = GetValueFromJsonPath("Cli.HostDataStorageMount"); + if (path.StartsWith("~/")) { - this._logger.Log(LogLevel.Debug, "Loading configuration file from {0}", Common.CliConfigFilePath); + path = path.Replace("~/", $"{Common.HomeDir}/"); } + return path; + } + } - using (var file = _fileSystem.File.OpenText(Common.CliConfigFilePath)) + public string HostDatabaseStorageMount + { + get + { + var path = GetValueFromJsonPath("Cli.HostDatabaseStorageMount"); + if (path.StartsWith("~/")) { - var serializer = new JsonSerializer(); - return serializer.Deserialize(file, typeof(ConfigurationOptions)) as ConfigurationOptions; + path = path.Replace("~/", $"{Common.HomeDir}/"); } + return path; } - catch (Exception) + } + + public string HostLogsStorageMount + { + get { - this._logger.Log(LogLevel.Warning, "Existing configuration file may be corrupted, createing a new one."); - return new ConfigurationOptions(); + var path = GetValueFromJsonPath("Cli.HostLogsStorageMount"); + if (path.StartsWith("~/")) + { + path = path.Replace("~/", $"{Common.HomeDir}/"); + } + return path; + } + } + + public string TempStoragePath + { + get + { + return GetValueFromJsonPath("InformaticsGateway.storage.temporary"); + } + } + + public string LogStoragePath + { + get + { + var logPath = GetValueFromJsonPath("Logging.File.BasePath"); + if(logPath.StartsWith("/")) + { + return logPath; + } + return _fileSystem.Path.Combine(Common.ContainerApplicationRootPath, logPath); } } - public void Save(ConfigurationOptions options) + private T GetValueFromJsonPath(string jsonPath) + { + return ReadConfigurationFile().SelectToken(jsonPath).Value(); + } + + private JObject ReadConfigurationFile() { - using (var file = _fileSystem.File.CreateText(Common.CliConfigFilePath)) + lock (SyncLock) { - var serializer = new JsonSerializer(); - serializer.Formatting = Formatting.Indented; - serializer.Serialize(file, options); + return JObject.Parse(_fileSystem.File.ReadAllText(Common.ConfigFilePath)); } + } - this._logger.Log(LogLevel.Information, $"Configuration file {Common.CliConfigFilePath} updated successfully."); + private void SaveConfigurationFile(JObject jObject) + { + lock (SyncLock) + { + using (var file = _fileSystem.File.CreateText(Common.ConfigFilePath)) + using (var writer = new JsonTextWriter(file)) + { + writer.Formatting = Formatting.Indented; + jObject.WriteTo(writer, new Newtonsoft.Json.Converters.StringEnumConverter()); + } + } } } } diff --git a/src/CLI/Services/ConfirmationPrompt.cs b/src/CLI/Services/ConfirmationPrompt.cs index 8c223bb1e..5fbb75902 100644 --- a/src/CLI/Services/ConfirmationPrompt.cs +++ b/src/CLI/Services/ConfirmationPrompt.cs @@ -22,7 +22,7 @@ internal class ConfirmationPrompt : IConfirmationPrompt { public bool ShowConfirmationPrompt(string message) { - Console.Write($"{message} [y/N]"); + Console.Write($"{message} [y/N]: "); var key = Console.ReadKey(); Console.WriteLine(); if (key.Key == ConsoleKey.Y) diff --git a/src/CLI/Services/ContainerRunnerFactory.cs b/src/CLI/Services/ContainerRunnerFactory.cs new file mode 100644 index 000000000..1054c9681 --- /dev/null +++ b/src/CLI/Services/ContainerRunnerFactory.cs @@ -0,0 +1,41 @@ +// Copyright 2021 MONAI Consortium +// 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 Microsoft.Extensions.DependencyInjection; + +namespace Monai.Deploy.InformaticsGateway.CLI +{ + public class ContainerRunnerFactory : IContainerRunnerFactory + { + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly IConfigurationService _configurationService; + + public ContainerRunnerFactory(IServiceScopeFactory serviceScopeFactory, IConfigurationService configurationService) + { + _serviceScopeFactory = serviceScopeFactory ?? throw new System.ArgumentNullException(nameof(serviceScopeFactory)); + _configurationService = configurationService ?? throw new System.ArgumentNullException(nameof(configurationService)); + } + + public IContainerRunner GetContainerRunner() + { + var scope = _serviceScopeFactory.CreateScope(); + switch (_configurationService.Runner) + { + case Runner.Docker: + return scope.ServiceProvider.GetRequiredService(); + default: + throw new NotSupportedException($"The configured runner isn't yet supported '{_configurationService.Runner}'"); + } + } + } +} diff --git a/src/CLI/Services/ControlService.cs b/src/CLI/Services/ControlService.cs index 785c78259..0e8296ac9 100644 --- a/src/CLI/Services/ControlService.cs +++ b/src/CLI/Services/ControlService.cs @@ -9,44 +9,78 @@ // See the License for the specific language governing permissions and // limitations under the License. +using Ardalis.GuardClauses; +using Docker.DotNet; +using Docker.DotNet.Models; using Microsoft.Extensions.Logging; using System; +using System.Collections.Generic; +using System.IO.Abstractions; +using System.Linq; +using System.Threading; using System.Threading.Tasks; namespace Monai.Deploy.InformaticsGateway.CLI { public interface IControlService { - Task Start(); + Task Start(CancellationToken cancellationToken = default); - Task Stop(); + Task Stop(CancellationToken cancellationToken = default); - Task Restart(); + Task Restart(CancellationToken cancellationToken = default); } public class ControlService : IControlService { + private readonly IContainerRunnerFactory _containerRunnerFactory; private readonly ILogger _logger; + private readonly IConfigurationService _configurationService; - public ControlService(ILogger logger) + public ControlService(IContainerRunnerFactory containerRunnerFactory, ILogger logger, IConfigurationService configService) { + _containerRunnerFactory = containerRunnerFactory ?? throw new ArgumentNullException(nameof(containerRunnerFactory)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _configurationService = configService ?? throw new ArgumentNullException(nameof(configService)); } - public async Task Restart() + public async Task Restart(CancellationToken cancellationToken = default) { await Stop(); - await Start(); + await Start(cancellationToken); } - public Task Start() + public async Task Start(CancellationToken cancellationToken = default) { - throw new NotImplementedException(); + var runner = _containerRunnerFactory.GetContainerRunner(); + + var applicationVersion = await runner.GetApplicationVersion(cancellationToken); + var runnerState = await runner.IsApplicationRunning(applicationVersion, cancellationToken); + + if (runnerState.IsRunning) + { + throw new Exception($"{Strings.ApplicationName} is already running in container ID {runnerState.IdShort}."); + } + + await runner.StartApplication(applicationVersion, cancellationToken); } - public Task Stop() + public async Task Stop(CancellationToken cancellationToken = default) { - throw new NotImplementedException(); + var runner = _containerRunnerFactory.GetContainerRunner(); + var applicationVersions = await runner.GetApplicationVersions(cancellationToken); + + foreach (var applicationVersion in applicationVersions) + { + var runnerState = await runner.IsApplicationRunning(applicationVersion, cancellationToken); + + if (runnerState.IsRunning) + { + await runner.StopApplication(runnerState, cancellationToken); + return; + } + } + _logger.Log(LogLevel.Warning, $"{Strings.ApplicationName} has not started. To start, execute `{System.AppDomain.CurrentDomain.FriendlyName} start`."); } } } diff --git a/src/CLI/Services/DockerRunner.cs b/src/CLI/Services/DockerRunner.cs new file mode 100644 index 000000000..d5dcc90f4 --- /dev/null +++ b/src/CLI/Services/DockerRunner.cs @@ -0,0 +1,160 @@ +// Copyright 2021 MONAI Consortium +// 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.Abstractions; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Ardalis.GuardClauses; +using Docker.DotNet; +using Docker.DotNet.Models; +using Microsoft.Extensions.Logging; +using Monai.Deploy.InformaticsGateway.Common; + +namespace Monai.Deploy.InformaticsGateway.CLI +{ + public class DockerRunner : IContainerRunner + { + private readonly ILogger _logger; + private readonly IConfigurationService _configurationService; + public readonly DockerClient _dockerClient; + private readonly IFileSystem _fileSystem; + + public DockerRunner(ILogger logger, IConfigurationService configurationService, IFileSystem fileSystem) + { + _logger = logger ?? throw new System.ArgumentNullException(nameof(logger)); + _configurationService = configurationService ?? throw new ArgumentNullException(nameof(configurationService)); + _dockerClient = new DockerClientConfiguration().CreateClient(); + _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + } + + public async Task IsApplicationRunning(ImageVersion imageVersion, CancellationToken cancellationToken = default) + { + Guard.Against.Null(imageVersion, nameof(imageVersion)); + + _logger.Log(LogLevel.Debug, $"Checking for existing {Strings.ApplicationName} ({imageVersion.Version}) containers..."); + var parameters = new ContainersListParameters(); + parameters.Filters = new Dictionary> + { + ["ancestor"] = new Dictionary + { + [imageVersion.Id] = true + } + }; + var matches = await _dockerClient.Containers.ListContainersAsync(parameters); + if (matches is null || matches.Count() == 0) + { + return new RunnerState { IsRunning = false }; + } + + return new RunnerState { IsRunning = true, Id = matches.First().ID }; + } + + public async Task> GetApplicationVersions(CancellationToken cancellationToken = default) + { + _logger.Log(LogLevel.Debug, "Connecting to Docker..."); + var parameters = new ImagesListParameters(); + _logger.Log(LogLevel.Debug, "Retrieving images from Docker..."); + var images = await _dockerClient.Images.ListImagesAsync(parameters, cancellationToken); + + return images.Select(p => new ImageVersion { Version = p.RepoTags.First(), Id = p.ID }).ToList(); + + } + public async Task GetApplicationVersion(CancellationToken cancellationToken = default) + => await GetApplicationVersion(_configurationService.DockerImagePrefix, cancellationToken); + + public async Task GetApplicationVersion(string label, CancellationToken cancellationToken = default) + { + _logger.Log(LogLevel.Debug, "Connecting to Docker..."); + var parameters = new ImagesListParameters(); + parameters.Filters = new Dictionary> + { + ["reference"] = new Dictionary + { + [label] = true + } + }; + _logger.Log(LogLevel.Debug, "Retrieving images from Docker..."); + var images = await _dockerClient.Images.ListImagesAsync(parameters, cancellationToken); + var latestImage = images.OrderByDescending(p => p.Created).FirstOrDefault(); + if (latestImage is null) + { + throw new Exception($"No {Strings.ApplicationName} Docker images with prefix `{label}` found."); + } + return new ImageVersion + { + Version = latestImage.RepoTags.FirstOrDefault(), + Id = latestImage.ID + }; + } + + public async Task StartApplication(ImageVersion imageVersion, CancellationToken cancellationToken = default) + { + _logger.Log(LogLevel.Information, $"Creating container {Strings.ApplicationName} - {imageVersion.Version} ({imageVersion.IdShort})..."); + var createContainerParams = new CreateContainerParameters() { Image = imageVersion.Id, HostConfig = new HostConfig() }; + + createContainerParams.ExposedPorts = new Dictionary(); + createContainerParams.HostConfig.PortBindings = new Dictionary>(); + + _logger.Log(LogLevel.Information, $"\tPort binding: {_configurationService.DicomListeningPort}/tcp"); + createContainerParams.ExposedPorts.Add($"{_configurationService.DicomListeningPort}/tcp", new EmptyStruct()); + createContainerParams.HostConfig.PortBindings.Add($"{_configurationService.DicomListeningPort}/tcp", new List { new PortBinding { HostPort = $"{_configurationService.DicomListeningPort}" } }); + + _logger.Log(LogLevel.Information, $"\tPort binding: {_configurationService.InformaticsGatewayServerPort}/tcp"); + createContainerParams.ExposedPorts.Add($"{_configurationService.InformaticsGatewayServerPort}/tcp", new EmptyStruct()); + createContainerParams.HostConfig.PortBindings.Add($"{_configurationService.InformaticsGatewayServerPort}/tcp", new List { new PortBinding { HostPort = $"{_configurationService.InformaticsGatewayServerPort}" } }); + + createContainerParams.HostConfig.Mounts = new List(); + _logger.Log(LogLevel.Information, $"\tMount (configuration file): {Common.ConfigFilePath} => {Common.MountedConfigFilePath}"); + createContainerParams.HostConfig.Mounts.Add(new Mount { Type = "bind", ReadOnly = true, Source = Common.ConfigFilePath, Target = Common.MountedConfigFilePath }); + + _logger.Log(LogLevel.Information, $"\tMount (database file): {_configurationService.HostDatabaseStorageMount} => {Common.MountedDatabasePath}"); + _fileSystem.Directory.CreateDirectoryIfNotExists(Common.DatabaseDirectory); + createContainerParams.HostConfig.Mounts.Add(new Mount { Type = "bind", ReadOnly = false, Source = Common.DatabaseDirectory, Target = Common.MountedDatabasePath }); + + _logger.Log(LogLevel.Information, $"\tMount (temporary storage): {_configurationService.HostDataStorageMount} => {_configurationService.TempStoragePath}"); + _fileSystem.Directory.CreateDirectoryIfNotExists(_configurationService.HostDataStorageMount); + createContainerParams.HostConfig.Mounts.Add(new Mount { Type = "bind", ReadOnly = false, Source = _configurationService.HostDataStorageMount, Target = _configurationService.TempStoragePath }); + + _logger.Log(LogLevel.Information, $"\tMount (application logs): {_configurationService.HostLogsStorageMount} => {_configurationService.LogStoragePath}"); + _fileSystem.Directory.CreateDirectoryIfNotExists(_configurationService.HostLogsStorageMount); + createContainerParams.HostConfig.Mounts.Add(new Mount { Type = "bind", ReadOnly = false, Source = _configurationService.HostLogsStorageMount, Target = _configurationService.LogStoragePath }); + + var response = await _dockerClient.Containers.CreateContainerAsync(createContainerParams, cancellationToken); + _logger.Log(LogLevel.Debug, $"{Strings.ApplicationName} created with container ID {response.ID.Substring(0, 12)}"); + if (response.Warnings.Any()) + { + _logger.Log(LogLevel.Warning, $"Warnings: {string.Join(",", response.Warnings)}"); + } + + _logger.Log(LogLevel.Debug, $"Starting container {response.ID.Substring(0, 12)}..."); + var containerStartParams = new ContainerStartParameters(); + if (!await _dockerClient.Containers.StartContainerAsync(response.ID, containerStartParams, cancellationToken)) + { + _logger.Log(LogLevel.Error, $"Error starting container {response.ID.Substring(0, 12)}"); + } + else + { + _logger.Log(LogLevel.Information, $"{Strings.ApplicationName} started with container ID {response.ID.Substring(0, 12)}"); + } + } + + public async Task StopApplication(RunnerState runnerState, CancellationToken cancellationToken = default) + { + _logger.Log(LogLevel.Debug, $"Stopping {Strings.ApplicationName} with container ID {runnerState.IdShort}."); + await _dockerClient.Containers.StopContainerAsync(runnerState.Id, new ContainerStopParameters() { WaitBeforeKillSeconds = 60 }, cancellationToken); + _logger.Log(LogLevel.Information, $"{Strings.ApplicationName} with container ID {runnerState.IdShort} stopped."); + } + } +} diff --git a/src/CLI/Services/IContainerRunner.cs b/src/CLI/Services/IContainerRunner.cs new file mode 100644 index 000000000..b4450fec4 --- /dev/null +++ b/src/CLI/Services/IContainerRunner.cs @@ -0,0 +1,84 @@ +// Copyright 2021 MONAI Consortium +// 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.Threading; +using System.Threading.Tasks; + +namespace Monai.Deploy.InformaticsGateway.CLI +{ + /// + /// Represents the state of a running application in the orchestration engine. + /// + public class RunnerState + { + /// + /// Indicates whether the application is running or not. + /// + public bool IsRunning { get; set; } + + /// + /// ID of the running application provided by the orchestration engine. + /// + public string Id { get; set; } + + /// + /// Shorter version of the ID, with 12 characters. + /// + public string IdShort + { + get + { + return Id.Substring(0, Math.Min(12, Id.Length)); + } + } + } + + /// + /// Represents the image version the container runner detected + /// + public class ImageVersion + { + /// + /// Version or label of the application/image detected. + /// + public string Version { get; set; } + + /// + /// Unique ID provided by the orchestration engine. + /// + /// + public string Id { get; set; } + + /// + /// Shorter version of the ID, with 12 characters. + /// + public string IdShort + { + get + { + var id = Id.Replace("sha256:", ""); + return id.Substring(0, Math.Min(12, id.Length)); + } + } + } + public interface IContainerRunner + { + Task IsApplicationRunning(ImageVersion imageVersion, CancellationToken cancellationToken = default); + Task> GetApplicationVersions(CancellationToken cancellationToken = default); + Task GetApplicationVersion(CancellationToken cancellationToken = default); + Task GetApplicationVersion(string version, CancellationToken cancellationToken = default); + Task StartApplication(ImageVersion imageVersion, CancellationToken cancellationToken = default); + Task StopApplication(RunnerState runnerState, CancellationToken cancellationToken = default); + } +} diff --git a/src/CLI/Options/ConfigurationOptions.cs b/src/CLI/Services/IContainerRunnerFactory.cs similarity index 59% rename from src/CLI/Options/ConfigurationOptions.cs rename to src/CLI/Services/IContainerRunnerFactory.cs index c7b83fe49..30c51ff58 100644 --- a/src/CLI/Options/ConfigurationOptions.cs +++ b/src/CLI/Services/IContainerRunnerFactory.cs @@ -9,22 +9,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -using Ardalis.GuardClauses; -using System; namespace Monai.Deploy.InformaticsGateway.CLI { - public class ConfigurationOptions + public interface IContainerRunnerFactory { - public string Endpoint { get; set; } - - public void Validate() - { - Guard.Against.NullOrEmpty(Endpoint, nameof(Endpoint)); - if (!Uri.IsWellFormedUriString(Endpoint, UriKind.Absolute)) - { - throw new ArgumentException($"--endpoint '{Endpoint}' is not a valid URI."); - } - } + IContainerRunner GetContainerRunner(); } } diff --git a/src/Client.Common/GuardExtensions.cs b/src/Client.Common/GuardExtensions.cs index 82a6e17a2..588670c95 100644 --- a/src/Client.Common/GuardExtensions.cs +++ b/src/Client.Common/GuardExtensions.cs @@ -36,5 +36,20 @@ public static void MalformUri(this IGuardClause guardClause, Uri input, string p throw new ArgumentException("invalid scheme in uri", parameterName); } } + + public static void MalformUri(this IGuardClause guardClause, string input, string parameterName) + { + Guard.Against.NullOrWhiteSpace(input, parameterName); + Guard.Against.MalformUri(new Uri(input), parameterName); + + } + + public static void OutOfRangePort(this IGuardClause guardClause, int port, string parameterName) + { + if (port <= 0 || port > 65535) + { + throw new ArgumentException("invalid port number", parameterName); + } + } } } diff --git a/src/Client/Services/AeTitle{T}Service.cs b/src/Client/Services/AeTitle{T}Service.cs index 6b562b8fa..0c4e1b144 100644 --- a/src/Client/Services/AeTitle{T}Service.cs +++ b/src/Client/Services/AeTitle{T}Service.cs @@ -56,9 +56,8 @@ public async Task Create(T item, CancellationToken cancellationToken) await response.EnsureSuccessStatusCodeWithProblemDetails(_logger); return await response.Content.ReadAsAsync(cancellationToken); } - catch (Exception ex) + catch { - _logger.Log(LogLevel.Error, ex, "Error sending request"); throw; } } @@ -74,9 +73,8 @@ public async Task Delete(string name, CancellationToken cancellationToken) await response.EnsureSuccessStatusCodeWithProblemDetails(_logger); return await response.Content.ReadAsAsync(cancellationToken); } - catch (Exception ex) + catch { - _logger.Log(LogLevel.Error, ex, "Error sending request"); throw; } } @@ -92,9 +90,8 @@ public async Task Get(string name, CancellationToken cancellationToken) await response.EnsureSuccessStatusCodeWithProblemDetails(_logger); return await response.Content.ReadAsAsync(cancellationToken); } - catch (Exception ex) + catch { - _logger.Log(LogLevel.Error, ex, "Error sending request"); throw; } } @@ -109,9 +106,8 @@ public async Task> List(CancellationToken cancellationToken) var list = await response.Content.ReadAsAsync>(cancellationToken); return list.ToList().AsReadOnly(); } - catch (Exception ex) + catch { - _logger.Log(LogLevel.Error, ex, "Error sending request"); throw; } } diff --git a/src/Client/Services/HealthService.cs b/src/Client/Services/HealthService.cs index 9058a6dda..08787c546 100644 --- a/src/Client/Services/HealthService.cs +++ b/src/Client/Services/HealthService.cs @@ -51,9 +51,8 @@ public async Task Status(CancellationToken cancellationTok await response.EnsureSuccessStatusCodeWithProblemDetails(_logger); return await response.Content.ReadAsAsync(cancellationToken); } - catch (Exception ex) + catch { - _logger.Log(LogLevel.Error, ex, "Error sending request"); throw; } } @@ -67,9 +66,8 @@ private async Task LiveReady(string uriPath, CancellationToken cancellat await response.EnsureSuccessStatusCodeWithProblemDetails(_logger); return await response.Content.ReadAsStringAsync(cancellationToken); } - catch (Exception ex) + catch { - _logger.Log(LogLevel.Error, ex, "Error sending request"); throw; } } diff --git a/src/Client/Services/InferenceService.cs b/src/Client/Services/InferenceService.cs index 4e81a1e4e..a34c09173 100644 --- a/src/Client/Services/InferenceService.cs +++ b/src/Client/Services/InferenceService.cs @@ -44,9 +44,8 @@ public async Task New(InferenceRequest request, Cancel await response.EnsureSuccessStatusCodeWithProblemDetails(_logger); return await response.Content.ReadAsAsync(cancellationToken); } - catch (Exception ex) + catch { - _logger.Log(LogLevel.Error, ex, "Error sending request"); throw; } } @@ -60,9 +59,8 @@ public async Task Status(string transactionId, Cancella await response.EnsureSuccessStatusCodeWithProblemDetails(_logger); return await response.Content.ReadAsAsync(cancellationToken); } - catch (Exception ex) + catch { - _logger.Log(LogLevel.Error, ex, "Error sending request"); throw; } } diff --git a/src/Configuration/ConfigurationValidator.cs b/src/Configuration/ConfigurationValidator.cs index ba6237bc0..3e87a8f1c 100644 --- a/src/Configuration/ConfigurationValidator.cs +++ b/src/Configuration/ConfigurationValidator.cs @@ -115,9 +115,15 @@ private bool IsWorkloadManagerValid(MonaiWorkloadManagerConfiguration configurat { var valid = true; - if (string.IsNullOrWhiteSpace(configuration.Endpoint)) + if (string.IsNullOrWhiteSpace(configuration.RestEndpoint)) { - _validationErrors.Add("MONAI Workload Manager API endpoint is not configured: InformaticsGateway>workloadManager>endpoint."); + _validationErrors.Add("MONAI Workload Manager API REST endpoint is not configured: InformaticsGateway>workloadManager>restEndpoint."); + valid = false; + } + + if (string.IsNullOrWhiteSpace(configuration.GrpcEndpoint)) + { + _validationErrors.Add("MONAI Workload Manager API gRPC endpoint is not configured: InformaticsGateway>workloadManager>grpcEndpoint."); valid = false; } diff --git a/src/Configuration/MonaiWorkloadManagerConfiguration.cs b/src/Configuration/MonaiWorkloadManagerConfiguration.cs index 6ee1fb885..3856252e8 100644 --- a/src/Configuration/MonaiWorkloadManagerConfiguration.cs +++ b/src/Configuration/MonaiWorkloadManagerConfiguration.cs @@ -18,10 +18,16 @@ public class MonaiWorkloadManagerConfiguration public static int DefaultClientTimeout = 300; /// - /// Gets or sets the URI of the Platform API. + /// Gets or sets the URI of the Workload Manager RESTful API. /// - [JsonProperty(PropertyName = "endpoint")] - public string Endpoint { get; set; } + [JsonProperty(PropertyName = "restEndpoint")] + public string RestEndpoint { get; set; } + + /// + /// Gets or sets the URI of the Workload Manager gRPC API. + /// + [JsonProperty(PropertyName = "grpcEndpoint")] + public string GrpcEndpoint { get; set; } /// /// Gets or sets maximum number of concurrent uploads to the Paylodas Service. diff --git a/src/Configuration/Test/ConfigurationValidatorTest.cs b/src/Configuration/Test/ConfigurationValidatorTest.cs index cb7729063..89ddf3375 100644 --- a/src/Configuration/Test/ConfigurationValidatorTest.cs +++ b/src/Configuration/Test/ConfigurationValidatorTest.cs @@ -61,15 +61,28 @@ public void InvalidScpMaxAssociations() logger.VerifyLogging(validationMessage, LogLevel.Error, Times.Once()); } - [Fact(DisplayName = "ConfigurationValidator test with missing Workload Manager endpoint")] - public void ServicesWithMissingPlatformEndpoint() + [Fact(DisplayName = "ConfigurationValidator test with missing Workload Manager RESTful endpoint")] + public void ServicesWithMissingPlatformRestEndpoint() { var config = MockValidConfiguration(); - config.WorkloadManager.Endpoint = null; + config.WorkloadManager.RestEndpoint = null; var valid = new ConfigurationValidator(logger.Object).Validate("", config); - var validationMessage = $"MONAI Workload Manager API endpoint is not configured: InformaticsGateway>workloadManager>endpoint."; + var validationMessage = $"MONAI Workload Manager API REST endpoint is not configured: InformaticsGateway>workloadManager>endpoint."; + Assert.Equal(validationMessage, valid.FailureMessage); + logger.VerifyLogging(validationMessage, LogLevel.Error, Times.Once()); + } + + [Fact(DisplayName = "ConfigurationValidator test with missing Workload Manager gRPC endpoint")] + public void ServicesWithMissingPlatformGrpcEndpoint() + { + var config = MockValidConfiguration(); + config.WorkloadManager.GrpcEndpoint = null; + + var valid = new ConfigurationValidator(logger.Object).Validate("", config); + + var validationMessage = $"MONAI Workload Manager API gRPC endpoint is not configured: InformaticsGateway>workloadManager>endpoint."; Assert.Equal(validationMessage, valid.FailureMessage); logger.VerifyLogging(validationMessage, LogLevel.Error, Times.Once()); } @@ -103,7 +116,8 @@ public void StorageWithInvalidReservedSpace() private InformaticsGatewayConfiguration MockValidConfiguration() { var config = new InformaticsGatewayConfiguration(); - config.WorkloadManager.Endpoint = "http://1.2.3.4:8080/bla-bla"; + config.WorkloadManager.RestEndpoint = "http://1.2.3.4:8080/bla-bla"; + config.WorkloadManager.GrpcEndpoint = "http://1.2.3.4:8081/bla-bla"; config.Dicom.Scp.RejectUnknownSources = true; config.Storage.Watermark = 50; return config; diff --git a/src/Database/FileStorageInfoConfiguration.cs b/src/Database/FileStorageInfoConfiguration.cs index abba883b8..bc5ae0252 100644 --- a/src/Database/FileStorageInfoConfiguration.cs +++ b/src/Database/FileStorageInfoConfiguration.cs @@ -28,10 +28,13 @@ public void Configure(EntityTypeBuilder builder) c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), c => c.ToArray()); var jsonSerializerSettings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - builder.HasKey(j => j.FilePath); - + + builder.HasKey(j => j.Id); + builder.Property(j => j.FilePath).IsRequired(); builder.Property(j => j.CorrelationId).IsRequired(); builder.Property(j => j.StorageRootPath).IsRequired(); + builder.Property(j => j.Received).IsRequired(); + builder.Property(j => j.Timestamp).IsRowVersion(); builder.Property(j => j.Applications).HasConversion( v => JsonConvert.SerializeObject(v, jsonSerializerSettings), diff --git a/src/Database/Migrations/20210819223503_R1_Initialize.Designer.cs b/src/Database/Migrations/20210923225957_R1_Initialize.Designer.cs similarity index 89% rename from src/Database/Migrations/20210819223503_R1_Initialize.Designer.cs rename to src/Database/Migrations/20210923225957_R1_Initialize.Designer.cs index 9673b9e47..ce1392841 100644 --- a/src/Database/Migrations/20210819223503_R1_Initialize.Designer.cs +++ b/src/Database/Migrations/20210923225957_R1_Initialize.Designer.cs @@ -9,7 +9,7 @@ namespace Monai.Deploy.InformaticsGateway.Database.Migrations { [DbContext(typeof(InformaticsGatewayContext))] - [Migration("20210819223503_R1_Initialize")] + [Migration("20210923225957_R1_Initialize")] partial class R1_Initialize { protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -41,7 +41,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Monai.Deploy.InformaticsGateway.Api.FileStorageInfo", b => { - b.Property("FilePath") + b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("TEXT"); b.Property("Applications") @@ -51,14 +52,26 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); + b.Property("FilePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Received") + .HasColumnType("TEXT"); + b.Property("StorageRootPath") .IsRequired() .HasColumnType("TEXT"); + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("BLOB"); + b.Property("TryCount") .HasColumnType("INTEGER"); - b.HasKey("FilePath"); + b.HasKey("Id"); b.ToTable("FileStorageInfo"); }); diff --git a/src/Database/Migrations/20210819223503_R1_Initialize.cs b/src/Database/Migrations/20210923225957_R1_Initialize.cs similarity index 93% rename from src/Database/Migrations/20210819223503_R1_Initialize.cs rename to src/Database/Migrations/20210923225957_R1_Initialize.cs index f73f7ec31..4c0ccbb36 100644 --- a/src/Database/Migrations/20210819223503_R1_Initialize.cs +++ b/src/Database/Migrations/20210923225957_R1_Initialize.cs @@ -1,5 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using System; +using System; +using Microsoft.EntityFrameworkCore.Migrations; namespace Monai.Deploy.InformaticsGateway.Database.Migrations { @@ -25,15 +25,18 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "FileStorageInfo", columns: table => new { - FilePath = table.Column(type: "TEXT", nullable: false), + Id = table.Column(type: "TEXT", nullable: false), CorrelationId = table.Column(type: "TEXT", nullable: false), StorageRootPath = table.Column(type: "TEXT", nullable: false), + FilePath = table.Column(type: "TEXT", nullable: false), Applications = table.Column(type: "TEXT", nullable: true), + Received = table.Column(type: "TEXT", nullable: false), + Timestamp = table.Column(type: "BLOB", rowVersion: true, nullable: true), TryCount = table.Column(type: "INTEGER", nullable: false) }, constraints: table => { - table.PrimaryKey("PK_FileStorageInfo", x => x.FilePath); + table.PrimaryKey("PK_FileStorageInfo", x => x.Id); }); migrationBuilder.CreateTable( diff --git a/src/Database/Migrations/InformaticsGatewayContextModelSnapshot.cs b/src/Database/Migrations/InformaticsGatewayContextModelSnapshot.cs index be712f26a..b4485dce0 100644 --- a/src/Database/Migrations/InformaticsGatewayContextModelSnapshot.cs +++ b/src/Database/Migrations/InformaticsGatewayContextModelSnapshot.cs @@ -39,7 +39,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Monai.Deploy.InformaticsGateway.Api.FileStorageInfo", b => { - b.Property("FilePath") + b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("TEXT"); b.Property("Applications") @@ -49,14 +50,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); + b.Property("FilePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Received") + .HasColumnType("TEXT"); + b.Property("StorageRootPath") .IsRequired() .HasColumnType("TEXT"); + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("BLOB"); + b.Property("TryCount") .HasColumnType("INTEGER"); - b.HasKey("FilePath"); + b.HasKey("Id"); b.ToTable("FileStorageInfo"); }); diff --git a/src/InformaticsGateway/Repositories/InformaticsGatewayRepository.cs b/src/InformaticsGateway/Repositories/InformaticsGatewayRepository.cs index 96e7d5c72..6f23cb029 100644 --- a/src/InformaticsGateway/Repositories/InformaticsGatewayRepository.cs +++ b/src/InformaticsGateway/Repositories/InformaticsGatewayRepository.cs @@ -45,14 +45,14 @@ public IQueryable AsQueryable() public async Task> ToListAsync() { - return await _informaticsGatewayContext.Set().ToListAsync(); + return await _informaticsGatewayContext.Set().ToListAsync().ConfigureAwait(false); } public async Task FindAsync(params object[] keyValues) { Guard.Against.Null(keyValues, nameof(keyValues)); - return await _informaticsGatewayContext.FindAsync(keyValues); + return await _informaticsGatewayContext.FindAsync(keyValues).ConfigureAwait(false); } public EntityEntry Update(T entity) @@ -71,14 +71,14 @@ public EntityEntry Remove(T entity) public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { - return await _informaticsGatewayContext.SaveChangesAsync(cancellationToken); + return await _informaticsGatewayContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } public async Task> AddAsync(T item, CancellationToken cancellationToken = default) { Guard.Against.Null(item, nameof(item)); - return await _informaticsGatewayContext.AddAsync(item, cancellationToken); + return await _informaticsGatewayContext.AddAsync(item, cancellationToken).ConfigureAwait(false); } public T FirstOrDefault(Func func) diff --git a/src/InformaticsGateway/Repositories/WorkloadManagerApi.cs b/src/InformaticsGateway/Repositories/WorkloadManagerApi.cs index 614358b31..368d08814 100644 --- a/src/InformaticsGateway/Repositories/WorkloadManagerApi.cs +++ b/src/InformaticsGateway/Repositories/WorkloadManagerApi.cs @@ -27,22 +27,22 @@ public Task Download(string applicaton, Guid fileId, CancellationToken c public Task> GetPendingJobs(string agent, int count, CancellationToken cancellationToken) { - throw new NotImplementedException(); + return Task.FromResult(default(IList)); } public Task ReportFailure(Guid taskId, bool retryLater, CancellationToken cancellationToken) { - throw new NotImplementedException(); + return Task.CompletedTask; } public Task ReportSuccess(Guid taskId, CancellationToken cancellationToken) { - throw new NotImplementedException(); + return Task.CompletedTask; } public Task Upload(FileStorageInfo file, CancellationToken cancellationToken) { - throw new NotImplementedException(); + return Task.CompletedTask; } } } diff --git a/src/InformaticsGateway/Services/Connectors/DataRetrievalService.cs b/src/InformaticsGateway/Services/Connectors/DataRetrievalService.cs index 804c9d9ab..f64edb4bb 100644 --- a/src/InformaticsGateway/Services/Connectors/DataRetrievalService.cs +++ b/src/InformaticsGateway/Services/Connectors/DataRetrievalService.cs @@ -183,10 +183,10 @@ private async Task ProcessRequest(InferenceRequest inferenceRequest, Cancellatio } } - NotifyNewInstance(inferenceRequest, retrievedFiles); + await NotifyNewInstance(inferenceRequest, retrievedFiles); } - private void NotifyNewInstance(InferenceRequest inferenceRequest, Dictionary retrievedFiles) + private async Task NotifyNewInstance(InferenceRequest inferenceRequest, Dictionary retrievedFiles) { Guard.Against.Null(inferenceRequest, nameof(inferenceRequest)); @@ -201,7 +201,7 @@ private void NotifyNewInstance(InferenceRequest inferenceRequest, Dictionary + Status = ServiceStatus.Running; + + var task = Task.Run(async () => { - BackgroundProcessing(cancellationToken); + await BackgroundProcessing(cancellationToken); }); - Status = ServiceStatus.Running; if (task.IsCompleted) return task; return Task.CompletedTask; diff --git a/src/InformaticsGateway/Services/Scp/ApplicationEntityManager.cs b/src/InformaticsGateway/Services/Scp/ApplicationEntityManager.cs index db5ee9cc3..c658b14dc 100644 --- a/src/InformaticsGateway/Services/Scp/ApplicationEntityManager.cs +++ b/src/InformaticsGateway/Services/Scp/ApplicationEntityManager.cs @@ -134,15 +134,14 @@ public async Task HandleCStoreRequest(DicomCStoreRequest request, string calledA _fileSystem.Directory.CreateDirectoryIfNotExists(info.StorageRootPath); - //TODO: encrypt await SaveDicomInstance(request, info.FilePath); - NotifyStoredInstance(info); + await NotifyStoredInstance(info); } - private void NotifyStoredInstance(FileStorageInfo file) + private async Task NotifyStoredInstance(FileStorageInfo file) { - _fileStoredNotificationQueue.Queue(file); + await _fileStoredNotificationQueue.Queue(file); _logger.Log(LogLevel.Information, $"Instance queued for upload: {file.FilePath}"); } diff --git a/src/InformaticsGateway/Services/Scp/FileStoredNotificationQueue.cs b/src/InformaticsGateway/Services/Scp/FileStoredNotificationQueue.cs index e268d2978..333dfdbd2 100644 --- a/src/InformaticsGateway/Services/Scp/FileStoredNotificationQueue.cs +++ b/src/InformaticsGateway/Services/Scp/FileStoredNotificationQueue.cs @@ -10,13 +10,16 @@ // limitations under the License. using Ardalis.GuardClauses; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Monai.Deploy.InformaticsGateway.Api; using Monai.Deploy.InformaticsGateway.Repositories; using System; using System.Collections.Concurrent; +using System.Linq; using System.Threading; +using System.Threading.Tasks; namespace Monai.Deploy.InformaticsGateway.Services.Scp { @@ -28,7 +31,7 @@ public sealed class FileStoredNotificationQueue : IFileStoredNotificationQueue { private readonly BlockingCollection _workItems; private readonly ILogger _logger; - private readonly IInformaticsGatewayRepository _repository; + private readonly IServiceScopeFactory _serviceScopeFactory; public FileStoredNotificationQueue( ILogger logger, @@ -36,17 +39,17 @@ public FileStoredNotificationQueue( { _workItems = new BlockingCollection(); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _repository = serviceScopeFactory.CreateScope().ServiceProvider.GetRequiredService>(); + _serviceScopeFactory = serviceScopeFactory ?? throw new ArgumentNullException(nameof(serviceScopeFactory)); LoadExistingStoredFilesFromDatabase(); } private void LoadExistingStoredFilesFromDatabase() { - foreach (var item in _repository.AsQueryable()) + var repository = _serviceScopeFactory.CreateScope().ServiceProvider.GetRequiredService>(); + foreach (var item in repository.AsQueryable()) { - //TODO: encrypt log? _logger.Log(LogLevel.Debug, "Adding existing file to queue: {0}", item.FilePath); - this.Queue(item); + _workItems.Add(item); } } @@ -54,12 +57,15 @@ private void LoadExistingStoredFilesFromDatabase() /// Queues a new instance of FileStorageInfo. /// /// Instance to be queued - public void Queue(FileStorageInfo file) + public async Task Queue(FileStorageInfo file) { Guard.Against.Null(file, nameof(file)); + var repository = _serviceScopeFactory.CreateScope().ServiceProvider.GetRequiredService>(); + + await repository.AddAsync(file); + await repository.SaveChangesAsync(); _workItems.Add(file); - _repository.AddAsync(file); _logger.Log(LogLevel.Debug, "File added to cleanup queue {0}. Queue size: {1}", file.FilePath, _workItems.Count); } @@ -69,10 +75,19 @@ public void Queue(FileStorageInfo file) /// /// Instance of cancellation token /// Instance of FileStorageInfo - public FileStorageInfo Dequeue(CancellationToken cancellationToken) + public async Task Dequeue(CancellationToken cancellationToken) { var item = _workItems.Take(cancellationToken); - _repository.Remove(item); + var repository = _serviceScopeFactory.CreateScope().ServiceProvider.GetRequiredService>(); + try + { + repository.Remove(item); + await repository.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + _logger.Log(LogLevel.Warning, $"Error deleting {item.FilePath} from database; conflict detected."); + } return item; } } diff --git a/src/InformaticsGateway/Services/Scp/IFileStoredNotificationQueue.cs b/src/InformaticsGateway/Services/Scp/IFileStoredNotificationQueue.cs index dea706e3c..9973c11d8 100644 --- a/src/InformaticsGateway/Services/Scp/IFileStoredNotificationQueue.cs +++ b/src/InformaticsGateway/Services/Scp/IFileStoredNotificationQueue.cs @@ -11,6 +11,7 @@ using Monai.Deploy.InformaticsGateway.Api; using System.Threading; +using System.Threading.Tasks; namespace Monai.Deploy.InformaticsGateway.Services.Scp { @@ -23,13 +24,13 @@ public interface IFileStoredNotificationQueue /// Queue a new file to be cleaned up. /// /// Path to the file to be removed. - void Queue(FileStorageInfo file); + Task Queue(FileStorageInfo file); /// /// Dequeue a file from the queue for notifying and uploading to MONAI Workload Manager. /// The default implementation blocks the call until a file is available from the queue. /// /// Propagates notification that operations should be canceled. - FileStorageInfo Dequeue(CancellationToken cancellationToken); + Task Dequeue(CancellationToken cancellationToken); } } diff --git a/src/InformaticsGateway/Test/Services/Scp/FileStoredNotificationQueueTest.cs b/src/InformaticsGateway/Test/Services/Scp/FileStoredNotificationQueueTest.cs index 42906e5c2..b4da0edf2 100644 --- a/src/InformaticsGateway/Test/Services/Scp/FileStoredNotificationQueueTest.cs +++ b/src/InformaticsGateway/Test/Services/Scp/FileStoredNotificationQueueTest.cs @@ -10,6 +10,7 @@ using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading; +using System.Threading.Tasks; using xRetry; using Xunit; @@ -57,27 +58,27 @@ public void ShallLoadExistingStoredFileRecords() } [RetryFact(5, 250, DisplayName = "Queue and Dequeue")] - public void QueueAndDequeue() + public async Task QueueAndDequeue() { var queue = new FileStoredNotificationQueue(_logger.Object, _serviceScopeFactory.Object); var expected = new FileStorageInfo(Guid.NewGuid().ToString(), "/storage", "message1", ".ext", _fileSystem); - queue.Queue(expected); + await queue.Queue(expected); var cancellationTokenSource = new CancellationTokenSource(); - var queuedItem = queue.Dequeue(cancellationTokenSource.Token); + var queuedItem = await queue.Dequeue(cancellationTokenSource.Token); Assert.Equal(expected, queuedItem); } [RetryFact(5, 250, DisplayName = "Dequeue - is cancellable")] - public void Dequeue_CanBeCancelled() + public async Task Dequeue_CanBeCancelled() { var queue = new FileStoredNotificationQueue(_logger.Object, _serviceScopeFactory.Object); var cancellationTokenSource = new CancellationTokenSource(); cancellationTokenSource.CancelAfter(50); - Assert.Throws(() => queue.Dequeue(cancellationTokenSource.Token)); + await Assert.ThrowsAsync(async () => await queue.Dequeue(cancellationTokenSource.Token)); } } } diff --git a/src/InformaticsGateway/appsettings.json b/src/InformaticsGateway/appsettings.json index 415dcc6dc..c00a8d577 100644 --- a/src/InformaticsGateway/appsettings.json +++ b/src/InformaticsGateway/appsettings.json @@ -1,11 +1,11 @@ { "ConnectionStrings": { - "InformaticsGatewayDatabase": "Data Source=mig.db" + "InformaticsGatewayDatabase": "Data Source=/database/mig.db" }, "InformaticsGateway": { "dicom": { "scp": { - "port": 1104, + "port": 104, "logDimseDatasets": false, "rejectUnknownSources": true }, @@ -17,6 +17,10 @@ }, "storage": { "temporary": "/payloads" + }, + "workloadManager": { + "restEndpoint": "http://localhost:6000", + "grpcEndpoint": "http://localhost:6001" } }, "Logging": { @@ -41,7 +45,7 @@ } }, "File": { - "BasePath": "Logs", + "BasePath": "logs", "FileEncodingName": "utf-8", "DateFormat": "yyyyMMdd", "CounterFormat": "000", @@ -59,9 +63,17 @@ "Kestrel": { "EndPoints": { "Http": { - "Url": "http://localhost:5000" + "Url": "http://+:5000" } } }, - "AllowedHosts": "*" -} + "AllowedHosts": "*", + "Cli": { + "Runner": "Docker", + "HostDataStorageMount": "~/.mig/data", + "HostDatabaseStorageMount": "~/.mig/database", + "HostLogsStorageMount": "~/.mig/logs", + "InformaticsGatewayServerEndpoint": "http://localhost:5000", + "DockerImagePrefix": "monai/informatics-gateway" + } +} \ No newline at end of file From 7081f499812128cad3cf91914c9cc51c62445703 Mon Sep 17 00:00:00 2001 From: Victor Chang Date: Mon, 27 Sep 2021 14:04:48 -0700 Subject: [PATCH 2/2] Update unit tests --- .gitignore | 2 +- src/CLI/AssemblyInfo.cs | 14 + src/CLI/Commands/AetCommand.cs | 35 ++- src/CLI/Commands/CommandBase.cs | 18 +- src/CLI/Commands/ConfigCommand.cs | 64 +++- src/CLI/Commands/ConfigurationException.cs | 2 +- src/CLI/Commands/DestinationCommand.cs | 46 ++- src/CLI/Commands/RestartCommand.cs | 2 + src/CLI/Commands/SourceCommand.cs | 36 ++- src/CLI/Commands/StartCommand.cs | 9 +- src/CLI/Commands/StatusCommand.cs | 11 +- src/CLI/Commands/StopCommand.cs | 2 + src/CLI/ControlException.cs | 14 + src/CLI/ExitCodes.cs | 3 + .../Logging/ConsoleLoggerFactoryExtensions.cs | 8 +- ...Monai.Deploy.InformaticsGateway.CLI.csproj | 4 +- src/CLI/Options/Common.cs | 1 - src/CLI/Program.cs | 4 +- .../Services/ConfigurationOptionAccessor.cs | 285 ++++++++++++++++++ src/CLI/Services/ConfigurationService.cs | 246 ++------------- src/CLI/Services/ConfirmationPrompt.cs | 3 + src/CLI/Services/ContainerRunnerFactory.cs | 8 +- src/CLI/Services/ControlService.cs | 48 +-- src/CLI/Services/DockerRunner.cs | 96 +++--- src/CLI/Services/EmbeddedResource.cs | 30 ++ src/CLI/Services/IConfigurationService.cs | 46 +++ src/CLI/Services/IContainerRunner.cs | 22 +- src/CLI/Services/IContainerRunnerFactory.cs | 1 - .../Runner.cs} | 17 +- src/CLI/Strings.cs | 1 + src/CLI/Test/AetCommandTest.cs | 68 ++++- src/CLI/Test/ConfigCommandTest.cs | 222 ++++++++++++-- src/CLI/Test/ConfigurationServiceTest.cs | 133 ++++---- src/CLI/Test/ContainerRunnerFactoryTest.cs | 72 +++++ src/CLI/Test/ControlServiceTest.cs | 179 +++++++++++ src/CLI/Test/DestinationCommandTest.cs | 68 ++++- src/CLI/Test/DockerRunnerTest.cs | 194 ++++++++++++ ....Deploy.InformaticsGateway.CLI.Test.csproj | 1 + src/CLI/Test/RestartCommandTest.cs | 11 +- src/CLI/Test/SourceCommandTest.cs | 68 ++++- src/CLI/Test/StartCommandTest.cs | 34 ++- src/CLI/Test/StatusCommandTest.cs | 22 +- src/CLI/Test/StopCommandTest.cs | 11 +- src/Client.Common/GuardExtensions.cs | 1 - src/Client.Common/ProblemDetails.cs | 2 +- src/Client.Common/ProblemException.cs | 2 +- src/Client.Common/Test/GuardExtensionsTest.cs | 16 +- src/Client/Services/HealthService.cs | 1 - src/Client/Services/InferenceService.cs | 3 +- src/Client/Test/AeTitleServiceTest.cs | 9 - src/Client/Test/HealthServiceTest.cs | 7 - src/Client/Test/InferenceServiceTest.cs | 5 - src/Configuration/ConfigurationValidator.cs | 2 +- .../Test/ConfigurationValidatorTest.cs | 4 +- .../Test/ValidationExtensionsTest.cs | 1 - src/Database/FileStorageInfoConfiguration.cs | 2 +- .../20210923225957_R1_Initialize.cs | 4 +- .../API/UnsupportedReturnTypeException.cs | 1 - .../Logging/FileLoggingTextFormatter.cs | 14 +- .../Repositories/IMonaiServiceLocator.cs | 2 +- .../Services/Http/HealthController.cs | 3 - .../Scp/FileStoredNotificationQueue.cs | 1 - .../Logging/FileLoggingTextFormatterTest.cs | 7 +- .../Repositories/MonaiServiceLocatorTest.cs | 4 - .../WorkloadManagerNotificationServiceTest.cs | 6 +- .../Http/ExceptionHandlingMiddlewareTest.cs | 5 +- .../Services/Http/HealthControllerTest.cs | 3 - .../MonaiAeChangedNotificationServiceTest.cs | 1 - src/InformaticsGateway/appsettings.json | 2 +- 69 files changed, 1656 insertions(+), 613 deletions(-) create mode 100644 src/CLI/AssemblyInfo.cs create mode 100644 src/CLI/ControlException.cs create mode 100644 src/CLI/Services/ConfigurationOptionAccessor.cs create mode 100644 src/CLI/Services/EmbeddedResource.cs create mode 100644 src/CLI/Services/IConfigurationService.cs rename src/CLI/{Test/ConfigurationOptionsTest.cs => Services/Runner.cs} (59%) create mode 100644 src/CLI/Test/ContainerRunnerFactoryTest.cs create mode 100644 src/CLI/Test/ControlServiceTest.cs create mode 100644 src/CLI/Test/DockerRunnerTest.cs diff --git a/.gitignore b/.gitignore index b6bd1b66c..6a4d7fa53 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ # Edit at https://www.toptal.com/developers/gitignore?templates=aspnetcore,dotnetcore,visualstudio,visualstudiocode # MIG -cli/ +/cli # Database *.db diff --git a/src/CLI/AssemblyInfo.cs b/src/CLI/AssemblyInfo.cs new file mode 100644 index 000000000..a33bb495b --- /dev/null +++ b/src/CLI/AssemblyInfo.cs @@ -0,0 +1,14 @@ +// Copyright 2021 MONAI Consortium +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Monai.Deploy.InformaticsGateway.CLI.Test")] diff --git a/src/CLI/Commands/AetCommand.cs b/src/CLI/Commands/AetCommand.cs index c04725575..97592bffe 100644 --- a/src/CLI/Commands/AetCommand.cs +++ b/src/CLI/Commands/AetCommand.cs @@ -83,25 +83,28 @@ private void SetupAddAetCommand() private async Task ListAeTitlehandlerAsync(IHost host, bool verbose, CancellationToken cancellationToken) { + Guard.Against.Null(host, nameof(host)); + this.LogVerbose(verbose, host, "Configuring services..."); var console = host.Services.GetRequiredService(); - var config = host.Services.GetRequiredService(); + var configService = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var consoleRegion = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); Guard.Against.Null(console, nameof(console), "Console service is unavailable."); - Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); + Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); Guard.Against.Null(consoleRegion, nameof(consoleRegion), "Console region is unavailable."); IReadOnlyList items = null; try { - client.ConfigureServiceUris(config.InformaticsGatewayServerUri); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); + CheckConfiguration(configService); + client.ConfigureServiceUris(configService.Configurations.InformaticsGatewayServerUri); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {configService.Configurations.InformaticsGatewayServerEndpoint}..."); this.LogVerbose(verbose, host, $"Retrieving MONAI SCP AE Titles..."); items = await client.MonaiScpAeTitle.List(cancellationToken); } @@ -142,19 +145,23 @@ private async Task ListAeTitlehandlerAsync(IHost host, bool verbose, Cancel private async Task RemoveAeTitlehandlerAsync(string name, IHost host, bool verbose, CancellationToken cancellationToken) { + Guard.Against.NullOrWhiteSpace(name, nameof(name)); + Guard.Against.Null(host, nameof(host)); + this.LogVerbose(verbose, host, "Configuring services..."); - var config = host.Services.GetRequiredService(); + var configService = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); - Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); + Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); try { - client.ConfigureServiceUris(config.InformaticsGatewayServerUri); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); + CheckConfiguration(configService); + client.ConfigureServiceUris(configService.Configurations.InformaticsGatewayServerUri); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {configService.Configurations.InformaticsGatewayServerEndpoint}..."); this.LogVerbose(verbose, host, $"Deleting MONAI SCP AE Title {name}..."); _ = await client.MonaiScpAeTitle.Delete(name, cancellationToken); logger.Log(LogLevel.Information, $"MONAI SCP AE Title '{name}' deleted."); @@ -174,20 +181,24 @@ private async Task RemoveAeTitlehandlerAsync(string name, IHost host, bool private async Task AddAeTitlehandlerAsync(MonaiApplicationEntity entity, IHost host, bool verbose, CancellationToken cancellationToken) { + Guard.Against.Null(entity, nameof(entity)); + Guard.Against.Null(host, nameof(host)); + this.LogVerbose(verbose, host, "Configuring services..."); - var config = host.Services.GetRequiredService(); + var configService = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); - Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); + Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); try { - client.ConfigureServiceUris(config.InformaticsGatewayServerUri); + CheckConfiguration(configService); + client.ConfigureServiceUris(configService.Configurations.InformaticsGatewayServerUri); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {configService.Configurations.InformaticsGatewayServerEndpoint}..."); var result = await client.MonaiScpAeTitle.Create(entity, cancellationToken); logger.Log(LogLevel.Information, "New MONAI Deploy SCP Application Entity created:"); diff --git a/src/CLI/Commands/CommandBase.cs b/src/CLI/Commands/CommandBase.cs index 19d40694a..68955d844 100644 --- a/src/CLI/Commands/CommandBase.cs +++ b/src/CLI/Commands/CommandBase.cs @@ -14,7 +14,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Monai.Deploy.InformaticsGateway.Client; using System; using System.CommandLine; @@ -28,12 +27,17 @@ public CommandBase(string name, string description) : base(name, description) protected ILogger CreateLogger(IHost host) { + Guard.Against.Null(host, nameof(host)); + var loggerFactory = host.Services.GetService(); return loggerFactory?.CreateLogger(); } protected void LogVerbose(bool verbose, IHost host, string message) { + Guard.Against.Null(host, nameof(host)); + Guard.Against.NullOrWhiteSpace(message, nameof(message)); + if (verbose) { var logger = CreateLogger(host); @@ -52,8 +56,20 @@ protected void LogVerbose(bool verbose, IHost host, string message) protected void AddConfirmationOption(Command command) { + Guard.Against.Null(command, nameof(command)); + var confirmationOption = new Option(new[] { "-y", "--yes" }, "Automatic yes to prompts"); command.AddOption(confirmationOption); } + + protected void CheckConfiguration(IConfigurationService configService) + { + Guard.Against.Null(configService, nameof(configService)); + + if (!configService.IsInitialized) + { + throw new ConfigurationException($"Please execute `{AppDomain.CurrentDomain.FriendlyName} config init` to intialize Informatics Gateway."); + } + } } } diff --git a/src/CLI/Commands/ConfigCommand.cs b/src/CLI/Commands/ConfigCommand.cs index b434d390e..f2f8a4f8a 100644 --- a/src/CLI/Commands/ConfigCommand.cs +++ b/src/CLI/Commands/ConfigCommand.cs @@ -28,11 +28,41 @@ public ConfigCommand() : base("config", "Configure the CLI endpoint") { AddCommandEndpoint(); AddCommandRunner(); + AddCommandWorkloadManagerRest(); + AddCommandWorkloadManagerGrpc(); SetupInitCommand(); SetupShowConfigCommand(); } + private void AddCommandWorkloadManagerGrpc() + { + var wmgrpcCommand = new Command("wmgrpc", $"RESTful endpoint for the {Strings.WorkloadManagerName}."); + this.Add(wmgrpcCommand); + + wmgrpcCommand.AddArgument(new Argument("uri")); + wmgrpcCommand.Handler = CommandHandler.Create((string uri, IHost host, bool verbose) => + ConfigUpdateHandler(uri, host, verbose, (IConfigurationService options) => + { + options.Configurations.WorkloadManagerGrpcEndpoint = uri; + }) + ); + } + + private void AddCommandWorkloadManagerRest() + { + var wmRestCommand = new Command("wmrest", $"RESTful endpoint for the {Strings.WorkloadManagerName}."); + this.Add(wmRestCommand); + + wmRestCommand.AddArgument(new Argument("uri")); + wmRestCommand.Handler = CommandHandler.Create((string uri, IHost host, bool verbose) => + ConfigUpdateHandler(uri, host, verbose, (IConfigurationService options) => + { + options.Configurations.WorkloadManagerRestEndpoint = uri; + }) + ); + } + private void AddCommandRunner() { var endpointCommand = new Command("runner", $"Default container runner/orchestration engine to run {Strings.ApplicationName}."); @@ -42,7 +72,7 @@ private void AddCommandRunner() endpointCommand.Handler = CommandHandler.Create((Runner runner, IHost host, bool verbose) => ConfigUpdateHandler(runner, host, verbose, (IConfigurationService options) => { - options.Runner = runner; + options.Configurations.Runner = runner; }) ); } @@ -56,7 +86,7 @@ private void AddCommandEndpoint() endpointCommand.Handler = CommandHandler.Create((string uri, IHost host, bool verbose) => ConfigUpdateHandler(uri, host, verbose, (IConfigurationService options) => { - options.InformaticsGatewayServer = uri; + options.Configurations.InformaticsGatewayServerEndpoint = uri; }) ); } @@ -80,6 +110,8 @@ private void SetupShowConfigCommand() private int ShowConfigurationHandler(IHost host, bool verbose, CancellationToken cancellationToken) { + Guard.Against.Null(host, nameof(host)); + this.LogVerbose(verbose, host, "Configuring services..."); var logger = CreateLogger(host); var configService = host.Services.GetRequiredService(); @@ -87,18 +119,35 @@ private int ShowConfigurationHandler(IHost host, bool verbose, CancellationToken try { - logger.Log(LogLevel.Information, $"Server: {configService.InformaticsGatewayServer}"); + CheckConfiguration(configService); + logger.Log(LogLevel.Information, $"Informatics Gateway API: {configService.Configurations.InformaticsGatewayServerEndpoint}"); + logger.Log(LogLevel.Information, $"DICOM SCP Listening Port: {configService.Configurations.DicomListeningPort}"); + logger.Log(LogLevel.Information, $"Container Runner: {configService.Configurations.Runner}"); + logger.Log(LogLevel.Information, $"Host:"); + logger.Log(LogLevel.Information, $" Database storage mount: {configService.Configurations.HostDatabaseStorageMount}"); + logger.Log(LogLevel.Information, $" Data storage mount: {configService.Configurations.HostDataStorageMount}"); + logger.Log(LogLevel.Information, $" Logs storage mount: {configService.Configurations.HostLogsStorageMount}"); + logger.Log(LogLevel.Information, $"Workload Manager:"); + logger.Log(LogLevel.Information, $" REST API: {configService.Configurations.WorkloadManagerRestEndpoint}"); + logger.Log(LogLevel.Information, $" gRPC API: {configService.Configurations.WorkloadManagerGrpcEndpoint}"); + } + catch (ConfigurationException) + { + return ExitCodes.Config_NotConfigured; } catch (Exception ex) { logger.Log(LogLevel.Error, ex.Message); - return ExitCodes.Config_NotConfigured; + return ExitCodes.Config_ErrorShowing; } return ExitCodes.Success; } private int ConfigUpdateHandler(T argument, IHost host, bool verbose, Action updater) { + Guard.Against.Null(host, nameof(host)); + Guard.Against.Null(updater, nameof(updater)); + var logger = CreateLogger(host); var config = host.Services.GetRequiredService(); @@ -106,10 +155,11 @@ private int ConfigUpdateHandler(T argument, IHost host, bool verbose, Action< try { + CheckConfiguration(config); updater(config); logger.Log(LogLevel.Information, "Configuration updated successfully."); } - catch (ArgumentNullException) + catch (ConfigurationException) { return ExitCodes.Config_NotConfigured; } @@ -123,6 +173,8 @@ private int ConfigUpdateHandler(T argument, IHost host, bool verbose, Action< private async Task InitHandlerAsync(IHost host, bool verbose, bool yes, CancellationToken cancellationToken) { + Guard.Against.Null(host, nameof(host)); + var logger = CreateLogger(host); var configService = host.Services.GetRequiredService(); var confirmation = host.Services.GetRequiredService(); @@ -140,7 +192,7 @@ private async Task InitHandlerAsync(IHost host, bool verbose, bool yes, Can try { - await configService.Initialize(); + await configService.Initialize(cancellationToken); } catch (Exception ex) { diff --git a/src/CLI/Commands/ConfigurationException.cs b/src/CLI/Commands/ConfigurationException.cs index 0a41d1ae5..557f9e490 100644 --- a/src/CLI/Commands/ConfigurationException.cs +++ b/src/CLI/Commands/ConfigurationException.cs @@ -14,7 +14,7 @@ namespace Monai.Deploy.InformaticsGateway.CLI { [Serializable] - internal class ConfigurationException : Exception + public class ConfigurationException : Exception { public ConfigurationException(string message) : base(message) { diff --git a/src/CLI/Commands/DestinationCommand.cs b/src/CLI/Commands/DestinationCommand.cs index 3c3da4189..476f9bc5c 100644 --- a/src/CLI/Commands/DestinationCommand.cs +++ b/src/CLI/Commands/DestinationCommand.cs @@ -81,28 +81,37 @@ private void SetupAddDestinationCommand() private async Task ListDestinationHandlerAsync(DestinationApplicationEntity entity, IHost host, bool verbose, CancellationToken cancellationToken) { + Guard.Against.Null(entity, nameof(entity)); + Guard.Against.Null(host, nameof(host)); + this.LogVerbose(verbose, host, "Configuring services..."); var console = host.Services.GetRequiredService(); - var config = host.Services.GetRequiredService(); + var configService = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var consoleRegion = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); Guard.Against.Null(console, nameof(console), "Console service is unavailable."); - Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); + Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); Guard.Against.Null(consoleRegion, nameof(consoleRegion), "Console region is unavailable."); IReadOnlyList items = null; try { - client.ConfigureServiceUris(config.InformaticsGatewayServerUri); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); + CheckConfiguration(configService); + client.ConfigureServiceUris(configService.Configurations.InformaticsGatewayServerUri); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {configService.Configurations.InformaticsGatewayServerEndpoint}..."); this.LogVerbose(verbose, host, $"Retrieving DICOM destinations..."); items = await client.DicomDestinations.List(cancellationToken); } + catch (ConfigurationException ex) + { + logger.Log(LogLevel.Critical, ex.Message); + return ExitCodes.Config_NotConfigured; + } catch (Exception ex) { logger.Log(LogLevel.Critical, $"Error retrieving DICOM destinations: {ex.Message}"); @@ -136,23 +145,32 @@ private async Task ListDestinationHandlerAsync(DestinationApplicationEntity private async Task RemoveDestinationHandlerAsync(string name, IHost host, bool verbose, CancellationToken cancellationToken) { + Guard.Against.NullOrWhiteSpace(name, nameof(name)); + Guard.Against.Null(host, nameof(host)); + this.LogVerbose(verbose, host, "Configuring services..."); - var config = host.Services.GetRequiredService(); + var configService = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); - Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); + Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); try { - client.ConfigureServiceUris(config.InformaticsGatewayServerUri); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); + CheckConfiguration(configService); + client.ConfigureServiceUris(configService.Configurations.InformaticsGatewayServerUri); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {configService.Configurations.InformaticsGatewayServerEndpoint}..."); this.LogVerbose(verbose, host, $"Deleting DICOM destination {name}..."); _ = await client.DicomDestinations.Delete(name, cancellationToken); logger.Log(LogLevel.Information, $"DICOM destination '{name}' deleted."); } + catch (ConfigurationException ex) + { + logger.Log(LogLevel.Critical, ex.Message); + return ExitCodes.Config_NotConfigured; + } catch (Exception ex) { logger.Log(LogLevel.Critical, $"Error deleting DICOM destination {name}: {ex.Message}"); @@ -163,20 +181,24 @@ private async Task RemoveDestinationHandlerAsync(string name, IHost host, b private async Task AddDestinationHandlerAsync(DestinationApplicationEntity entity, IHost host, bool verbose, CancellationToken cancellationToken) { + Guard.Against.Null(entity, nameof(entity)); + Guard.Against.Null(host, nameof(host)); + this.LogVerbose(verbose, host, "Configuring services..."); - var config = host.Services.GetRequiredService(); + var configService = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); - Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); + Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); try { - client.ConfigureServiceUris(config.InformaticsGatewayServerUri); + CheckConfiguration(configService); + client.ConfigureServiceUris(configService.Configurations.InformaticsGatewayServerUri); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {configService.Configurations.InformaticsGatewayServerEndpoint}..."); var result = await client.DicomDestinations.Create(entity, cancellationToken); logger.Log(LogLevel.Information, "New DICOM destination created:"); diff --git a/src/CLI/Commands/RestartCommand.cs b/src/CLI/Commands/RestartCommand.cs index f9e3ca193..211291854 100644 --- a/src/CLI/Commands/RestartCommand.cs +++ b/src/CLI/Commands/RestartCommand.cs @@ -31,6 +31,8 @@ public RestartCommand() : base("restart", $"Restart the {Strings.ApplicationName private async Task RestartCommandHandler(IHost host, bool yes, bool verbose, CancellationToken cancellationToken) { + Guard.Against.Null(host, nameof(host)); + var service = host.Services.GetRequiredService(); var confirmation = host.Services.GetRequiredService(); var logger = CreateLogger(host); diff --git a/src/CLI/Commands/SourceCommand.cs b/src/CLI/Commands/SourceCommand.cs index ea705f45f..e687966c7 100644 --- a/src/CLI/Commands/SourceCommand.cs +++ b/src/CLI/Commands/SourceCommand.cs @@ -78,25 +78,29 @@ private void SetupAddSourceCommand() private async Task ListSourceHandlerAsync(SourceApplicationEntity entity, IHost host, bool verbose, CancellationToken cancellationTokena) { + Guard.Against.Null(entity, nameof(entity)); + Guard.Against.Null(host, nameof(host)); + this.LogVerbose(verbose, host, "Configuring services..."); var console = host.Services.GetRequiredService(); - var config = host.Services.GetRequiredService(); + var configService = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var consoleRegion = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); Guard.Against.Null(console, nameof(console), "Console service is unavailable."); - Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); + Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); Guard.Against.Null(consoleRegion, nameof(consoleRegion), "Console region is unavailable."); IReadOnlyList items = null; try { - client.ConfigureServiceUris(config.InformaticsGatewayServerUri); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); + CheckConfiguration(configService); + client.ConfigureServiceUris(configService.Configurations.InformaticsGatewayServerUri); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {configService.Configurations.InformaticsGatewayServerEndpoint}..."); this.LogVerbose(verbose, host, $"Retrieving DICOM sources..."); items = await client.DicomSources.List(cancellationTokena); } @@ -137,19 +141,23 @@ private async Task ListSourceHandlerAsync(SourceApplicationEntity entity, I private async Task RemoveSourceHandlerAsync(string name, IHost host, bool verbose, CancellationToken cancellationTokena) { + Guard.Against.NullOrWhiteSpace(name, nameof(name)); + Guard.Against.Null(host, nameof(host)); + this.LogVerbose(verbose, host, "Configuring services..."); - var config = host.Services.GetRequiredService(); + var configService = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); - Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); + Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); try { - client.ConfigureServiceUris(config.InformaticsGatewayServerUri); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); + CheckConfiguration(configService); + client.ConfigureServiceUris(configService.Configurations.InformaticsGatewayServerUri); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {configService.Configurations.InformaticsGatewayServerEndpoint}..."); this.LogVerbose(verbose, host, $"Deleting DICOM source {name}..."); _ = await client.DicomSources.Delete(name, cancellationTokena); logger.Log(LogLevel.Information, $"DICOM source '{name}' deleted."); @@ -169,19 +177,23 @@ private async Task RemoveSourceHandlerAsync(string name, IHost host, bool v private async Task AddSourceHandlerAsync(SourceApplicationEntity entity, IHost host, bool verbose, CancellationToken cancellationTokena) { + Guard.Against.Null(entity, nameof(entity)); + Guard.Against.Null(host, nameof(host)); + this.LogVerbose(verbose, host, "Configuring services..."); - var config = host.Services.GetRequiredService(); + var configService = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); - Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); + Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); try { - client.ConfigureServiceUris(config.InformaticsGatewayServerUri); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); + CheckConfiguration(configService); + client.ConfigureServiceUris(configService.Configurations.InformaticsGatewayServerUri); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {configService.Configurations.InformaticsGatewayServerEndpoint}..."); this.LogVerbose(verbose, host, $"Creating new DICOM source {entity.AeTitle}..."); var result = await client.DicomSources.Create(entity, cancellationTokena); diff --git a/src/CLI/Commands/StartCommand.cs b/src/CLI/Commands/StartCommand.cs index 7c9b4f9bb..d57539aad 100644 --- a/src/CLI/Commands/StartCommand.cs +++ b/src/CLI/Commands/StartCommand.cs @@ -30,7 +30,9 @@ public StartCommand() : base("start", $"Start the {Strings.ApplicationName} serv } private async Task StartCommandHandler(IHost host, bool verbose, CancellationToken cancellationToken) - { + { + Guard.Against.Null(host, nameof(host)); + var service = host.Services.GetRequiredService(); var confirmation = host.Services.GetRequiredService(); var logger = CreateLogger(host); @@ -43,6 +45,11 @@ private async Task StartCommandHandler(IHost host, bool verbose, Cancellati { await service.Start(cancellationToken); } + catch (ControlException ex) when (ex.ErrorCode == ExitCodes.Start_Error_ApplicationAlreadyRunning) + { + logger.Log(LogLevel.Warning, ex.Message); + return ex.ErrorCode; + } catch (Exception ex) { logger.Log(LogLevel.Critical, ex.Message); diff --git a/src/CLI/Commands/StatusCommand.cs b/src/CLI/Commands/StatusCommand.cs index 3b263b54c..141bc0e96 100644 --- a/src/CLI/Commands/StatusCommand.cs +++ b/src/CLI/Commands/StatusCommand.cs @@ -31,22 +31,25 @@ public StatusCommand() : base("status", $"{Strings.ApplicationName} service stat private async Task StatusCommandHandlerAsync(IHost host, bool verbose, CancellationToken cancellationToken) { + Guard.Against.Null(host, nameof(host)); + this.LogVerbose(verbose, host, "Configuring services..."); - var config = host.Services.GetRequiredService(); + var configService = host.Services.GetRequiredService(); var client = host.Services.GetRequiredService(); var logger = CreateLogger(host); Guard.Against.Null(logger, nameof(logger), "Logger is unavailable."); - Guard.Against.Null(config, nameof(config), "Configuration service is unavailable."); + Guard.Against.Null(configService, nameof(configService), "Configuration service is unavailable."); Guard.Against.Null(client, nameof(client), $"{Strings.ApplicationName} client is unavailable."); HealthStatusResponse response = null; try { - client.ConfigureServiceUris(config.InformaticsGatewayServerUri); + CheckConfiguration(configService); + client.ConfigureServiceUris(configService.Configurations.InformaticsGatewayServerUri); - this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {config.InformaticsGatewayServer}..."); + this.LogVerbose(verbose, host, $"Connecting to {Strings.ApplicationName} at {configService.Configurations.InformaticsGatewayServerEndpoint}..."); this.LogVerbose(verbose, host, $"Retrieving service status..."); response = await client.Health.Status(cancellationToken); } diff --git a/src/CLI/Commands/StopCommand.cs b/src/CLI/Commands/StopCommand.cs index e7e57e73f..ef6762cde 100644 --- a/src/CLI/Commands/StopCommand.cs +++ b/src/CLI/Commands/StopCommand.cs @@ -31,6 +31,8 @@ public StopCommand() : base("stop", $"Stop the {Strings.ApplicationName} service private async Task StopCommandHandler(IHost host, bool yes, bool verbose, CancellationToken cancellationToken) { + Guard.Against.Null(host, nameof(host)); + var service = host.Services.GetRequiredService(); var confirmation = host.Services.GetRequiredService(); var logger = CreateLogger(host); diff --git a/src/CLI/ControlException.cs b/src/CLI/ControlException.cs new file mode 100644 index 000000000..06cb42247 --- /dev/null +++ b/src/CLI/ControlException.cs @@ -0,0 +1,14 @@ +using System; + +namespace Monai.Deploy.InformaticsGateway.CLI +{ + public class ControlException : Exception + { + public int ErrorCode { get; } + + public ControlException(int errorCode, string message) : base(message) + { + ErrorCode = errorCode; + } + } +} diff --git a/src/CLI/ExitCodes.cs b/src/CLI/ExitCodes.cs index 59f8d2d28..63788b5f8 100644 --- a/src/CLI/ExitCodes.cs +++ b/src/CLI/ExitCodes.cs @@ -18,6 +18,7 @@ public static class ExitCodes public const int Config_NotConfigured = 100; public const int Config_ErrorSaving = 101; public const int Config_ErrorInitializing = 102; + public const int Config_ErrorShowing = 103; public const int MonaiScp_ErrorList = 200; public const int MonaiScp_ErrorDelete = 201; @@ -36,6 +37,8 @@ public static class ExitCodes public static int Start_Cancelled = 600; public static int Start_Error = 601; + public static int Start_Error_ApplicationNotFound = 602; + public static int Start_Error_ApplicationAlreadyRunning = 603; public static int Stop_Cancelled = 700; public static int Stop_Error = 701; diff --git a/src/CLI/Logging/ConsoleLoggerFactoryExtensions.cs b/src/CLI/Logging/ConsoleLoggerFactoryExtensions.cs index 6df00a1e6..e6b2ec5e5 100644 --- a/src/CLI/Logging/ConsoleLoggerFactoryExtensions.cs +++ b/src/CLI/Logging/ConsoleLoggerFactoryExtensions.cs @@ -9,6 +9,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using Ardalis.GuardClauses; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -26,11 +27,8 @@ public static class ConsoleLoggerFactoryExtensions { public static ILoggingBuilder AddInformaticsGatewayConsole(this ILoggingBuilder builder, Action configure) { - if (configure is null) - { - throw new ArgumentNullException(nameof(configure)); - } - + Guard.Against.Null(configure, nameof(configure)); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); LoggerProviderOptions.RegisterProviderOptions(builder.Services); diff --git a/src/CLI/Monai.Deploy.InformaticsGateway.CLI.csproj b/src/CLI/Monai.Deploy.InformaticsGateway.CLI.csproj index 20725a0ad..8a5e1d165 100644 --- a/src/CLI/Monai.Deploy.InformaticsGateway.CLI.csproj +++ b/src/CLI/Monai.Deploy.InformaticsGateway.CLI.csproj @@ -33,7 +33,7 @@ - + @@ -49,4 +49,4 @@ - \ No newline at end of file + diff --git a/src/CLI/Options/Common.cs b/src/CLI/Options/Common.cs index cb2a93aec..a2c7b8c54 100644 --- a/src/CLI/Options/Common.cs +++ b/src/CLI/Options/Common.cs @@ -20,7 +20,6 @@ public class Common { public static readonly string HomeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); public static readonly string MigDirectory = Path.Combine(HomeDir, ".mig"); - public static readonly string DatabaseDirectory = Path.Combine(MigDirectory, "database"); public static readonly string ContainerApplicationRootPath = "/opt/monai/ig"; public static readonly string MountedConfigFilePath = Path.Combine(ContainerApplicationRootPath, "appsettings.json"); public static readonly string MountedDatabasePath = "/database"; diff --git a/src/CLI/Program.cs b/src/CLI/Program.cs index ef64d59fe..1f12ce085 100644 --- a/src/CLI/Program.cs +++ b/src/CLI/Program.cs @@ -9,6 +9,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using Docker.DotNet; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -56,8 +57,9 @@ private static async Task Main(string[] args) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); - + services.AddTransient(p => new DockerClientConfiguration().CreateClient()); }); }) .AddGlobalOption(verboseOption) diff --git a/src/CLI/Services/ConfigurationOptionAccessor.cs b/src/CLI/Services/ConfigurationOptionAccessor.cs new file mode 100644 index 000000000..cf98b1481 --- /dev/null +++ b/src/CLI/Services/ConfigurationOptionAccessor.cs @@ -0,0 +1,285 @@ +// Copyright 2021 MONAI Consortium +// 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 Ardalis.GuardClauses; +using Monai.Deploy.InformaticsGateway.Client.Common; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.IO.Abstractions; + +namespace Monai.Deploy.InformaticsGateway.CLI +{ + public interface IConfigurationOptionAccessor + { + /// + /// Gets or sets the DICOM SCP listening port from appsettings.json. + /// + int DicomListeningPort { get; set; } + + /// + /// Gets or sets the Docker image prefix from appsettings.json. + /// This is used to query the Informatics Gateway Docker containers that are installed. + /// + string DockerImagePrefix { get; } + + /// + /// Gets the database storage location on the host system from appsettings.json. + /// + string HostDatabaseStorageMount { get; } + + /// + /// Gets the temprary data storage location on the host system from appsettings.json. + /// + string HostDataStorageMount { get; } + + /// + /// Gets the logs storages location on the host system from appsettings.json. + /// + string HostLogsStorageMount { get; } + + /// + /// Gets or sets the endpoint of the Informatics Gateway. + /// + string InformaticsGatewayServerEndpoint { get; set; } + + /// + /// Gets the port number of the Informatics Gateway server. + /// + int InformaticsGatewayServerPort { get; } + + /// + /// Gets the endpoint of the Informatics Gateway as Uri object. + /// + Uri InformaticsGatewayServerUri { get; } + + /// + /// Gets the log storage path from appsettings.json. + /// + string LogStoragePath { get; } + + /// + /// Gets or set the type of container runner from appsettings.json. + /// + Runner Runner { get; set; } + + /// + /// Gets the temporary storage path from appsettings.json. + /// + string TempStoragePath { get; } + + /// + /// Gets or sets the endpoint to the gRPC API of the Workload Manager from appsettings.json. + /// + string WorkloadManagerGrpcEndpoint { get; set; } + + /// + /// Gets or sets the endpoint to the RESTful API of the Workload Manager from appsettings.json. + /// + string WorkloadManagerRestEndpoint { get; set; } + } + + public class ConfigurationOptionAccessor : IConfigurationOptionAccessor + { + private static readonly Object SyncLock = new object(); + private readonly IFileSystem _fileSystem; + + public ConfigurationOptionAccessor(IFileSystem fileSystem) + { + _fileSystem = fileSystem; + } + + public int DicomListeningPort + { + get + { + return GetValueFromJsonPath("InformaticsGateway.dicom.scp.port"); + } + set + { + Guard.Against.OutOfRangePort(value, nameof(InformaticsGatewayServerEndpoint)); + var jObject = ReadConfigurationFile(); + jObject["InformaticsGateway"]["dicom"]["scp"]["port"] = value; + SaveConfigurationFile(jObject); + } + } + + public string DockerImagePrefix + { + get + { + return GetValueFromJsonPath("Cli.DockerImagePrefix"); + } + } + + public string HostDatabaseStorageMount + { + get + { + var path = GetValueFromJsonPath("Cli.HostDatabaseStorageMount"); + if (path.StartsWith("~/")) + { + path = path.Replace("~/", $"{Common.HomeDir}/"); + } + return path; + } + } + + public string HostDataStorageMount + { + get + { + var path = GetValueFromJsonPath("Cli.HostDataStorageMount"); + if (path.StartsWith("~/")) + { + path = path.Replace("~/", $"{Common.HomeDir}/"); + } + return path; + } + } + + public string HostLogsStorageMount + { + get + { + var path = GetValueFromJsonPath("Cli.HostLogsStorageMount"); + if (path.StartsWith("~/")) + { + path = path.Replace("~/", $"{Common.HomeDir}/"); + } + return path; + } + } + + public string InformaticsGatewayServerEndpoint + { + get + { + return GetValueFromJsonPath("Cli.InformaticsGatewayServerEndpoint"); + } + set + { + Guard.Against.MalformUri(value, nameof(InformaticsGatewayServerEndpoint)); + var jObject = ReadConfigurationFile(); + jObject["Cli"]["InformaticsGatewayServerEndpoint"] = value; + SaveConfigurationFile(jObject); + } + } + + public int InformaticsGatewayServerPort + { + get + { + return InformaticsGatewayServerUri.Port; + } + } + + public Uri InformaticsGatewayServerUri + { + get + { + return new Uri(InformaticsGatewayServerEndpoint); + } + } + + public string LogStoragePath + { + get + { + var logPath = GetValueFromJsonPath("Logging.File.BasePath"); + if (logPath.StartsWith("/")) + { + return logPath; + } + return _fileSystem.Path.Combine(Common.ContainerApplicationRootPath, logPath); + } + } + + public Runner Runner + { + get + { + var runner = GetValueFromJsonPath("Cli.Runner"); + return (Runner)Enum.Parse(typeof(Runner), runner); + } + set + { + var jObject = ReadConfigurationFile(); + jObject["Cli"]["Runner"] = value.ToString(); + SaveConfigurationFile(jObject); + } + } + + public string TempStoragePath + { + get + { + return GetValueFromJsonPath("InformaticsGateway.storage.temporary"); + } + } + + public string WorkloadManagerGrpcEndpoint + { + get + { + return GetValueFromJsonPath("InformaticsGateway.workloadManager.grpcEndpoint"); + } + set + { + Guard.Against.MalformUri(value, nameof(InformaticsGatewayServerEndpoint)); + var jObject = ReadConfigurationFile(); + jObject["InformaticsGateway"]["workloadManager"]["grpcEndpoint"] = value; + SaveConfigurationFile(jObject); + } + } + + public string WorkloadManagerRestEndpoint + { + get + { + return GetValueFromJsonPath("InformaticsGateway.workloadManager.restEndpoint"); + } + set + { + Guard.Against.MalformUri(value, nameof(InformaticsGatewayServerEndpoint)); + var jObject = ReadConfigurationFile(); + jObject["InformaticsGateway"]["workloadManager"]["restEndpoint"] = value; + SaveConfigurationFile(jObject); + } + } + + private T GetValueFromJsonPath(string jsonPath) + { + return ReadConfigurationFile().SelectToken(jsonPath).Value(); + } + + private JObject ReadConfigurationFile() + { + lock (SyncLock) + { + return JObject.Parse(_fileSystem.File.ReadAllText(Common.ConfigFilePath)); + } + } + + private void SaveConfigurationFile(JObject jObject) + { + lock (SyncLock) + { + using (var file = _fileSystem.File.CreateText(Common.ConfigFilePath)) + using (var writer = new JsonTextWriter(file)) + { + writer.Formatting = Formatting.Indented; + jObject.WriteTo(writer, new Newtonsoft.Json.Converters.StringEnumConverter()); + } + } + } + } +} diff --git a/src/CLI/Services/ConfigurationService.cs b/src/CLI/Services/ConfigurationService.cs index b469e6e10..bb1d7a5ec 100644 --- a/src/CLI/Services/ConfigurationService.cs +++ b/src/CLI/Services/ConfigurationService.cs @@ -9,63 +9,34 @@ // See the License for the specific language governing permissions and // limitations under the License. -using Ardalis.GuardClauses; using Microsoft.Extensions.Logging; -using Monai.Deploy.InformaticsGateway.Client.Common; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using System; +using System.IO; using System.IO.Abstractions; using System.Reflection; +using System.Threading; using System.Threading.Tasks; namespace Monai.Deploy.InformaticsGateway.CLI { - public enum Runner - { - Docker, - Kubernetes, - Helm, - } - - public interface IConfigurationService - { - string TempStoragePath { get; } - string LogStoragePath { get; } - string HostDataStorageMount { get; } - string HostDatabaseStorageMount { get; } - string HostLogsStorageMount { get; } - string InformaticsGatewayServer { get; set; } - Uri InformaticsGatewayServerUri { get; } - string WorkloadManagerRestEndpoint { get; set; } - string WorkloadManagerGrpcEndpoint { get; set; } - int DicomListeningPort { get; set; } - int InformaticsGatewayServerPort { get; } - Runner Runner { get; set; } - string DockerImagePrefix { get; } - - bool IsConfigExists { get; } - bool IsInitialized { get; } - Task Initialize(); - void CreateConfigDirectoryIfNotExist(); - - } - public class ConfigurationService : IConfigurationService { - private static readonly Object SyncLock = new object(); private readonly ILogger _logger; private readonly IFileSystem _fileSystem; + private readonly IEmbeddedResource _embeddedResource; - public bool IsInitialized => _fileSystem.Directory.Exists(Common.MigDirectory) && - IsConfigExists; + public bool IsInitialized => _fileSystem.Directory.Exists(Common.MigDirectory) && IsConfigExists; public bool IsConfigExists => _fileSystem.File.Exists(Common.ConfigFilePath); - public ConfigurationService(ILogger logger, IFileSystem fileSystem) + public IConfigurationOptionAccessor Configurations { get; } + + public ConfigurationService(ILogger logger, IFileSystem fileSystem, IEmbeddedResource embeddedResource) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + _embeddedResource = embeddedResource ?? throw new ArgumentNullException(nameof(embeddedResource)); + Configurations = new ConfigurationOptionAccessor(fileSystem); } public void CreateConfigDirectoryIfNotExist() @@ -76,206 +47,25 @@ public void CreateConfigDirectoryIfNotExist() } } - - public async Task Initialize() + public async Task Initialize(CancellationToken cancellationToken) { - this._logger.Log(LogLevel.Debug, $"Reading default application configurations..."); - using var stream = this.GetType().Assembly.GetManifestResourceStream(Common.AppSettingsResourceName); + _logger.Log(LogLevel.Debug, $"Reading default application configurations..."); + using var stream = _embeddedResource.GetManifestResourceStream(Common.AppSettingsResourceName); if (stream is null) { _logger.Log(LogLevel.Debug, $"Available manifest names {string.Join(",", Assembly.GetExecutingAssembly().GetManifestResourceNames())}"); - throw new Exception($"Default configuration '{Common.AppSettingsResourceName}' could not be loaded."); + throw new ConfigurationException($"Default configuration file could not be loaded, please reinstall the CLI."); } CreateConfigDirectoryIfNotExist(); - this._logger.Log(LogLevel.Information, $"Saving appsettings.json to {Common.ConfigFilePath}..."); - using var fileStream = _fileSystem.File.Create(Common.ConfigFilePath); - await stream.CopyToAsync(fileStream); - this._logger.Log(LogLevel.Information, $"{Common.ConfigFilePath} updated successfully."); - } - public string InformaticsGatewayServer - { - get - { - return GetValueFromJsonPath("Cli.InformaticsGatewayServerEndpoint"); - } - set - { - Guard.Against.MalformUri(value, nameof(InformaticsGatewayServer)); - var jObject = ReadConfigurationFile(); - jObject["Cli"]["InformaticsGatewayServerEndpoint"] = value; - SaveConfigurationFile(jObject); - } - } - - public Uri InformaticsGatewayServerUri - { - get - { - return new Uri(InformaticsGatewayServer); - } - } - - public int InformaticsGatewayServerPort - { - get - { - return InformaticsGatewayServerUri.Port; - } - } - - public string WorkloadManagerRestEndpoint - { - get - { - return GetValueFromJsonPath("InformaticsGateway.workloadManager.restEndpoint"); - } - set - { - Guard.Against.MalformUri(value, nameof(InformaticsGatewayServer)); - var jObject = ReadConfigurationFile(); - jObject["InformaticsGateway"]["workloadManager"]["restEndpoint"] = value; - SaveConfigurationFile(jObject); - } - } - - public string WorkloadManagerGrpcEndpoint - { - get - { - return GetValueFromJsonPath("InformaticsGateway.workloadManager.grpcEndpoint"); - } - set - { - Guard.Against.MalformUri(value, nameof(InformaticsGatewayServer)); - var jObject = ReadConfigurationFile(); - jObject["InformaticsGateway"]["workloadManager"]["grpcEndpoint"] = value; - SaveConfigurationFile(jObject); - } - } - public string DockerImagePrefix - { - get - { - return GetValueFromJsonPath("Cli.DockerImagePrefix"); - } - } - - public int DicomListeningPort - { - get - { - return GetValueFromJsonPath("InformaticsGateway.dicom.scp.port"); - } - set - { - Guard.Against.OutOfRangePort(value, nameof(InformaticsGatewayServer)); - var jObject = ReadConfigurationFile(); - jObject["InformaticsGateway"]["dicom"]["scp"]["port"] = value; - SaveConfigurationFile(jObject); - } - } - - public Runner Runner - { - get - { - var runner = GetValueFromJsonPath("Cli.Runner"); - return (Runner)Enum.Parse(typeof(Runner), runner); - } - set - { - var jObject = ReadConfigurationFile(); - jObject["Cli"]["Runner"] = value.ToString(); - SaveConfigurationFile(jObject); - } - } - - public string HostDataStorageMount - { - get - { - var path = GetValueFromJsonPath("Cli.HostDataStorageMount"); - if (path.StartsWith("~/")) - { - path = path.Replace("~/", $"{Common.HomeDir}/"); - } - return path; - } - } - - public string HostDatabaseStorageMount - { - get - { - var path = GetValueFromJsonPath("Cli.HostDatabaseStorageMount"); - if (path.StartsWith("~/")) - { - path = path.Replace("~/", $"{Common.HomeDir}/"); - } - return path; - } - } - - public string HostLogsStorageMount - { - get - { - var path = GetValueFromJsonPath("Cli.HostLogsStorageMount"); - if (path.StartsWith("~/")) - { - path = path.Replace("~/", $"{Common.HomeDir}/"); - } - return path; - } - } - - public string TempStoragePath - { - get - { - return GetValueFromJsonPath("InformaticsGateway.storage.temporary"); - } - } - - public string LogStoragePath - { - get - { - var logPath = GetValueFromJsonPath("Logging.File.BasePath"); - if(logPath.StartsWith("/")) - { - return logPath; - } - return _fileSystem.Path.Combine(Common.ContainerApplicationRootPath, logPath); - } - } - - private T GetValueFromJsonPath(string jsonPath) - { - return ReadConfigurationFile().SelectToken(jsonPath).Value(); - } - - private JObject ReadConfigurationFile() - { - lock (SyncLock) - { - return JObject.Parse(_fileSystem.File.ReadAllText(Common.ConfigFilePath)); - } - } - - private void SaveConfigurationFile(JObject jObject) - { - lock (SyncLock) + _logger.Log(LogLevel.Information, $"Saving appsettings.json to {Common.ConfigFilePath}..."); + using (var fileStream = _fileSystem.FileStream.Create(Common.ConfigFilePath, FileMode.Create)) { - using (var file = _fileSystem.File.CreateText(Common.ConfigFilePath)) - using (var writer = new JsonTextWriter(file)) - { - writer.Formatting = Formatting.Indented; - jObject.WriteTo(writer, new Newtonsoft.Json.Converters.StringEnumConverter()); - } + await stream.CopyToAsync(fileStream, cancellationToken); + await fileStream.FlushAsync(cancellationToken); } + this._logger.Log(LogLevel.Information, $"{Common.ConfigFilePath} updated successfully."); } } } diff --git a/src/CLI/Services/ConfirmationPrompt.cs b/src/CLI/Services/ConfirmationPrompt.cs index 5fbb75902..b5b2701e1 100644 --- a/src/CLI/Services/ConfirmationPrompt.cs +++ b/src/CLI/Services/ConfirmationPrompt.cs @@ -9,6 +9,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using Ardalis.GuardClauses; using System; namespace Monai.Deploy.InformaticsGateway.CLI.Services @@ -22,6 +23,8 @@ internal class ConfirmationPrompt : IConfirmationPrompt { public bool ShowConfirmationPrompt(string message) { + Guard.Against.NullOrWhiteSpace(message, nameof(message)); + Console.Write($"{message} [y/N]: "); var key = Console.ReadKey(); Console.WriteLine(); diff --git a/src/CLI/Services/ContainerRunnerFactory.cs b/src/CLI/Services/ContainerRunnerFactory.cs index 1054c9681..fcc3b5449 100644 --- a/src/CLI/Services/ContainerRunnerFactory.cs +++ b/src/CLI/Services/ContainerRunnerFactory.cs @@ -9,9 +9,8 @@ // See the License for the specific language governing permissions and // limitations under the License. - -using System; using Microsoft.Extensions.DependencyInjection; +using System; namespace Monai.Deploy.InformaticsGateway.CLI { @@ -29,12 +28,13 @@ public ContainerRunnerFactory(IServiceScopeFactory serviceScopeFactory, IConfigu public IContainerRunner GetContainerRunner() { var scope = _serviceScopeFactory.CreateScope(); - switch (_configurationService.Runner) + switch (_configurationService.Configurations.Runner) { case Runner.Docker: return scope.ServiceProvider.GetRequiredService(); + default: - throw new NotSupportedException($"The configured runner isn't yet supported '{_configurationService.Runner}'"); + throw new NotImplementedException($"The configured runner isn't yet supported '{_configurationService.Configurations.Runner}'"); } } } diff --git a/src/CLI/Services/ControlService.cs b/src/CLI/Services/ControlService.cs index 0e8296ac9..14bd2e9d2 100644 --- a/src/CLI/Services/ControlService.cs +++ b/src/CLI/Services/ControlService.cs @@ -9,14 +9,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -using Ardalis.GuardClauses; -using Docker.DotNet; -using Docker.DotNet.Models; using Microsoft.Extensions.Logging; +using Monai.Deploy.InformaticsGateway.Common; using System; -using System.Collections.Generic; -using System.IO.Abstractions; -using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -24,18 +19,18 @@ namespace Monai.Deploy.InformaticsGateway.CLI { public interface IControlService { + Task Restart(CancellationToken cancellationToken = default); + Task Start(CancellationToken cancellationToken = default); Task Stop(CancellationToken cancellationToken = default); - - Task Restart(CancellationToken cancellationToken = default); } public class ControlService : IControlService { + private readonly IConfigurationService _configurationService; private readonly IContainerRunnerFactory _containerRunnerFactory; private readonly ILogger _logger; - private readonly IConfigurationService _configurationService; public ControlService(IContainerRunnerFactory containerRunnerFactory, ILogger logger, IConfigurationService configService) { @@ -54,33 +49,50 @@ public async Task Start(CancellationToken cancellationToken = default) { var runner = _containerRunnerFactory.GetContainerRunner(); - var applicationVersion = await runner.GetApplicationVersion(cancellationToken); + var applicationVersion = await runner.GetLatestApplicationVersion(cancellationToken); + if (applicationVersion is null) + { + throw new ControlException(ExitCodes.Start_Error_ApplicationNotFound, $"No {Strings.ApplicationName} Docker images with prefix `{_configurationService.Configurations.DockerImagePrefix}` found."); + } var runnerState = await runner.IsApplicationRunning(applicationVersion, cancellationToken); if (runnerState.IsRunning) { - throw new Exception($"{Strings.ApplicationName} is already running in container ID {runnerState.IdShort}."); + throw new ControlException(ExitCodes.Start_Error_ApplicationAlreadyRunning, $"{Strings.ApplicationName} is already running in container ID {runnerState.IdShort}."); } await runner.StartApplication(applicationVersion, cancellationToken); } + /// + /// Stops any running applications, including, previous releases/versions. + /// + /// public async Task Stop(CancellationToken cancellationToken = default) { var runner = _containerRunnerFactory.GetContainerRunner(); var applicationVersions = await runner.GetApplicationVersions(cancellationToken); - foreach (var applicationVersion in applicationVersions) + if (!applicationVersions.IsNullOrEmpty()) { - var runnerState = await runner.IsApplicationRunning(applicationVersion, cancellationToken); - - if (runnerState.IsRunning) + foreach (var applicationVersion in applicationVersions) { - await runner.StopApplication(runnerState, cancellationToken); - return; + var runnerState = await runner.IsApplicationRunning(applicationVersion, cancellationToken); + + _logger.Log(LogLevel.Debug, $"{Strings.ApplicationName} with container ID {runnerState.Id} running={runnerState.IsRunning}."); + if (runnerState.IsRunning) + { + if (await runner.StopApplication(runnerState, cancellationToken)) + { + _logger.Log(LogLevel.Information, $"{Strings.ApplicationName} with container ID {runnerState.Id} stopped."); + } + else + { + _logger.Log(LogLevel.Warning, $"Error may have occurred stopping {Strings.ApplicationName} with container ID {runnerState.Id}. Please verify with the applicatio state with {_configurationService.Configurations.Runner}."); + } + } } } - _logger.Log(LogLevel.Warning, $"{Strings.ApplicationName} has not started. To start, execute `{System.AppDomain.CurrentDomain.FriendlyName} start`."); } } } diff --git a/src/CLI/Services/DockerRunner.cs b/src/CLI/Services/DockerRunner.cs index d5dcc90f4..5a5398b10 100644 --- a/src/CLI/Services/DockerRunner.cs +++ b/src/CLI/Services/DockerRunner.cs @@ -9,18 +9,17 @@ // See the License for the specific language governing permissions and // limitations under the License. - +using Ardalis.GuardClauses; +using Docker.DotNet; +using Docker.DotNet.Models; +using Microsoft.Extensions.Logging; +using Monai.Deploy.InformaticsGateway.Common; using System; using System.Collections.Generic; using System.IO.Abstractions; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Ardalis.GuardClauses; -using Docker.DotNet; -using Docker.DotNet.Models; -using Microsoft.Extensions.Logging; -using Monai.Deploy.InformaticsGateway.Common; namespace Monai.Deploy.InformaticsGateway.CLI { @@ -28,15 +27,15 @@ public class DockerRunner : IContainerRunner { private readonly ILogger _logger; private readonly IConfigurationService _configurationService; - public readonly DockerClient _dockerClient; private readonly IFileSystem _fileSystem; + public readonly IDockerClient _dockerClient; - public DockerRunner(ILogger logger, IConfigurationService configurationService, IFileSystem fileSystem) + public DockerRunner(ILogger logger, IConfigurationService configurationService, IFileSystem fileSystem, IDockerClient dockerClient) { _logger = logger ?? throw new System.ArgumentNullException(nameof(logger)); _configurationService = configurationService ?? throw new ArgumentNullException(nameof(configurationService)); - _dockerClient = new DockerClientConfiguration().CreateClient(); _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + _dockerClient = dockerClient ?? throw new ArgumentNullException(nameof(dockerClient)); } public async Task IsApplicationRunning(ImageVersion imageVersion, CancellationToken cancellationToken = default) @@ -52,7 +51,7 @@ public async Task IsApplicationRunning(ImageVersion imageVersion, C [imageVersion.Id] = true } }; - var matches = await _dockerClient.Containers.ListContainersAsync(parameters); + var matches = await _dockerClient.Containers.ListContainersAsync(parameters, cancellationToken); if (matches is null || matches.Count() == 0) { return new RunnerState { IsRunning = false }; @@ -61,21 +60,24 @@ public async Task IsApplicationRunning(ImageVersion imageVersion, C return new RunnerState { IsRunning = true, Id = matches.First().ID }; } - public async Task> GetApplicationVersions(CancellationToken cancellationToken = default) - { - _logger.Log(LogLevel.Debug, "Connecting to Docker..."); - var parameters = new ImagesListParameters(); - _logger.Log(LogLevel.Debug, "Retrieving images from Docker..."); - var images = await _dockerClient.Images.ListImagesAsync(parameters, cancellationToken); + public async Task GetLatestApplicationVersion(CancellationToken cancellationToken = default) + => await GetLatestApplicationVersion(_configurationService.Configurations.DockerImagePrefix, cancellationToken); - return images.Select(p => new ImageVersion { Version = p.RepoTags.First(), Id = p.ID }).ToList(); + public async Task GetLatestApplicationVersion(string version, CancellationToken cancellationToken = default) + { + Guard.Against.NullOrWhiteSpace(version, nameof(version)); + var results = await GetApplicationVersions(version, cancellationToken); + return results?.OrderByDescending(p => p.Created).FirstOrDefault(); } - public async Task GetApplicationVersion(CancellationToken cancellationToken = default) - => await GetApplicationVersion(_configurationService.DockerImagePrefix, cancellationToken); - public async Task GetApplicationVersion(string label, CancellationToken cancellationToken = default) + public async Task> GetApplicationVersions(CancellationToken cancellationToken = default) + => await GetApplicationVersions(_configurationService.Configurations.DockerImagePrefix, cancellationToken); + + public async Task> GetApplicationVersions(string label, CancellationToken cancellationToken = default) { + Guard.Against.NullOrWhiteSpace(label, nameof(label)); + _logger.Log(LogLevel.Debug, "Connecting to Docker..."); var parameters = new ImagesListParameters(); parameters.Filters = new Dictionary> @@ -87,49 +89,42 @@ public async Task GetApplicationVersion(string label, Cancellation }; _logger.Log(LogLevel.Debug, "Retrieving images from Docker..."); var images = await _dockerClient.Images.ListImagesAsync(parameters, cancellationToken); - var latestImage = images.OrderByDescending(p => p.Created).FirstOrDefault(); - if (latestImage is null) - { - throw new Exception($"No {Strings.ApplicationName} Docker images with prefix `{label}` found."); - } - return new ImageVersion - { - Version = latestImage.RepoTags.FirstOrDefault(), - Id = latestImage.ID - }; + return images?.Select(p => new ImageVersion { Version = p.RepoTags.First(), Id = p.ID, Created = p.Created }).ToList(); } - public async Task StartApplication(ImageVersion imageVersion, CancellationToken cancellationToken = default) + public async Task StartApplication(ImageVersion imageVersion, CancellationToken cancellationToken = default) { + Guard.Against.Null(imageVersion, nameof(imageVersion)); + _logger.Log(LogLevel.Information, $"Creating container {Strings.ApplicationName} - {imageVersion.Version} ({imageVersion.IdShort})..."); var createContainerParams = new CreateContainerParameters() { Image = imageVersion.Id, HostConfig = new HostConfig() }; createContainerParams.ExposedPorts = new Dictionary(); createContainerParams.HostConfig.PortBindings = new Dictionary>(); - _logger.Log(LogLevel.Information, $"\tPort binding: {_configurationService.DicomListeningPort}/tcp"); - createContainerParams.ExposedPorts.Add($"{_configurationService.DicomListeningPort}/tcp", new EmptyStruct()); - createContainerParams.HostConfig.PortBindings.Add($"{_configurationService.DicomListeningPort}/tcp", new List { new PortBinding { HostPort = $"{_configurationService.DicomListeningPort}" } }); + _logger.Log(LogLevel.Information, $"\tPort binding: {_configurationService.Configurations.DicomListeningPort}/tcp"); + createContainerParams.ExposedPorts.Add($"{_configurationService.Configurations.DicomListeningPort}/tcp", new EmptyStruct()); + createContainerParams.HostConfig.PortBindings.Add($"{_configurationService.Configurations.DicomListeningPort}/tcp", new List { new PortBinding { HostPort = $"{_configurationService.Configurations.DicomListeningPort}" } }); - _logger.Log(LogLevel.Information, $"\tPort binding: {_configurationService.InformaticsGatewayServerPort}/tcp"); - createContainerParams.ExposedPorts.Add($"{_configurationService.InformaticsGatewayServerPort}/tcp", new EmptyStruct()); - createContainerParams.HostConfig.PortBindings.Add($"{_configurationService.InformaticsGatewayServerPort}/tcp", new List { new PortBinding { HostPort = $"{_configurationService.InformaticsGatewayServerPort}" } }); + _logger.Log(LogLevel.Information, $"\tPort binding: {_configurationService.Configurations.InformaticsGatewayServerPort}/tcp"); + createContainerParams.ExposedPorts.Add($"{_configurationService.Configurations.InformaticsGatewayServerPort}/tcp", new EmptyStruct()); + createContainerParams.HostConfig.PortBindings.Add($"{_configurationService.Configurations.InformaticsGatewayServerPort}/tcp", new List { new PortBinding { HostPort = $"{_configurationService.Configurations.InformaticsGatewayServerPort}" } }); createContainerParams.HostConfig.Mounts = new List(); _logger.Log(LogLevel.Information, $"\tMount (configuration file): {Common.ConfigFilePath} => {Common.MountedConfigFilePath}"); createContainerParams.HostConfig.Mounts.Add(new Mount { Type = "bind", ReadOnly = true, Source = Common.ConfigFilePath, Target = Common.MountedConfigFilePath }); - _logger.Log(LogLevel.Information, $"\tMount (database file): {_configurationService.HostDatabaseStorageMount} => {Common.MountedDatabasePath}"); - _fileSystem.Directory.CreateDirectoryIfNotExists(Common.DatabaseDirectory); - createContainerParams.HostConfig.Mounts.Add(new Mount { Type = "bind", ReadOnly = false, Source = Common.DatabaseDirectory, Target = Common.MountedDatabasePath }); + _logger.Log(LogLevel.Information, $"\tMount (database file): {_configurationService.Configurations.HostDatabaseStorageMount} => {Common.MountedDatabasePath}"); + _fileSystem.Directory.CreateDirectoryIfNotExists(_configurationService.Configurations.HostDatabaseStorageMount); + createContainerParams.HostConfig.Mounts.Add(new Mount { Type = "bind", ReadOnly = false, Source = _configurationService.Configurations.HostDatabaseStorageMount, Target = Common.MountedDatabasePath }); - _logger.Log(LogLevel.Information, $"\tMount (temporary storage): {_configurationService.HostDataStorageMount} => {_configurationService.TempStoragePath}"); - _fileSystem.Directory.CreateDirectoryIfNotExists(_configurationService.HostDataStorageMount); - createContainerParams.HostConfig.Mounts.Add(new Mount { Type = "bind", ReadOnly = false, Source = _configurationService.HostDataStorageMount, Target = _configurationService.TempStoragePath }); + _logger.Log(LogLevel.Information, $"\tMount (temporary storage): {_configurationService.Configurations.HostDataStorageMount} => {_configurationService.Configurations.TempStoragePath}"); + _fileSystem.Directory.CreateDirectoryIfNotExists(_configurationService.Configurations.HostDataStorageMount); + createContainerParams.HostConfig.Mounts.Add(new Mount { Type = "bind", ReadOnly = false, Source = _configurationService.Configurations.HostDataStorageMount, Target = _configurationService.Configurations.TempStoragePath }); - _logger.Log(LogLevel.Information, $"\tMount (application logs): {_configurationService.HostLogsStorageMount} => {_configurationService.LogStoragePath}"); - _fileSystem.Directory.CreateDirectoryIfNotExists(_configurationService.HostLogsStorageMount); - createContainerParams.HostConfig.Mounts.Add(new Mount { Type = "bind", ReadOnly = false, Source = _configurationService.HostLogsStorageMount, Target = _configurationService.LogStoragePath }); + _logger.Log(LogLevel.Information, $"\tMount (application logs): {_configurationService.Configurations.HostLogsStorageMount} => {_configurationService.Configurations.LogStoragePath}"); + _fileSystem.Directory.CreateDirectoryIfNotExists(_configurationService.Configurations.HostLogsStorageMount); + createContainerParams.HostConfig.Mounts.Add(new Mount { Type = "bind", ReadOnly = false, Source = _configurationService.Configurations.HostLogsStorageMount, Target = _configurationService.Configurations.LogStoragePath }); var response = await _dockerClient.Containers.CreateContainerAsync(createContainerParams, cancellationToken); _logger.Log(LogLevel.Debug, $"{Strings.ApplicationName} created with container ID {response.ID.Substring(0, 12)}"); @@ -143,18 +138,23 @@ public async Task StartApplication(ImageVersion imageVersion, CancellationToken if (!await _dockerClient.Containers.StartContainerAsync(response.ID, containerStartParams, cancellationToken)) { _logger.Log(LogLevel.Error, $"Error starting container {response.ID.Substring(0, 12)}"); + return false; } else { _logger.Log(LogLevel.Information, $"{Strings.ApplicationName} started with container ID {response.ID.Substring(0, 12)}"); + return true; } } - public async Task StopApplication(RunnerState runnerState, CancellationToken cancellationToken = default) + public async Task StopApplication(RunnerState runnerState, CancellationToken cancellationToken = default) { + Guard.Against.Null(runnerState, nameof(runnerState)); + _logger.Log(LogLevel.Debug, $"Stopping {Strings.ApplicationName} with container ID {runnerState.IdShort}."); - await _dockerClient.Containers.StopContainerAsync(runnerState.Id, new ContainerStopParameters() { WaitBeforeKillSeconds = 60 }, cancellationToken); + var result = await _dockerClient.Containers.StopContainerAsync(runnerState.Id, new ContainerStopParameters() { WaitBeforeKillSeconds = 60 }, cancellationToken); _logger.Log(LogLevel.Information, $"{Strings.ApplicationName} with container ID {runnerState.IdShort} stopped."); + return result; } } } diff --git a/src/CLI/Services/EmbeddedResource.cs b/src/CLI/Services/EmbeddedResource.cs new file mode 100644 index 000000000..8ec9f9e29 --- /dev/null +++ b/src/CLI/Services/EmbeddedResource.cs @@ -0,0 +1,30 @@ +// Copyright 2021 MONAI Consortium +// 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 Ardalis.GuardClauses; +using System.IO; + +namespace Monai.Deploy.InformaticsGateway.CLI +{ + public interface IEmbeddedResource + { + Stream GetManifestResourceStream(string name); + } + + public class EmbeddedResource : IEmbeddedResource + { + public Stream GetManifestResourceStream(string name) + { + Guard.Against.NullOrWhiteSpace(name, nameof(name)); + return this.GetType().Assembly.GetManifestResourceStream(Common.AppSettingsResourceName); + } + } +} diff --git a/src/CLI/Services/IConfigurationService.cs b/src/CLI/Services/IConfigurationService.cs new file mode 100644 index 000000000..459a1a980 --- /dev/null +++ b/src/CLI/Services/IConfigurationService.cs @@ -0,0 +1,46 @@ +// Copyright 2021 MONAI Consortium +// 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.Threading; +using System.Threading.Tasks; + +namespace Monai.Deploy.InformaticsGateway.CLI +{ + public interface IConfigurationService + { + /// + /// Gets the configurations inside appsettings.json + /// + IConfigurationOptionAccessor Configurations { get; } + + /// + /// Gets whether the configuration file exists or not. + /// + bool IsConfigExists { get; } + + /// + /// Gets whether the configuration file has been intialized or not. + /// + bool IsInitialized { get; } + + /// + /// Initializes the Informatics Gateway environment. + /// Extracts the appsettings.json file to ~/.mig for the application to consume. + /// + /// + Task Initialize(CancellationToken cancellationToken); + + /// + /// Creates ~/.mig directory if not already exists. + /// + void CreateConfigDirectoryIfNotExist(); + } +} diff --git a/src/CLI/Services/IContainerRunner.cs b/src/CLI/Services/IContainerRunner.cs index b4450fec4..583926fb4 100644 --- a/src/CLI/Services/IContainerRunner.cs +++ b/src/CLI/Services/IContainerRunner.cs @@ -9,7 +9,6 @@ // See the License for the specific language governing permissions and // limitations under the License. - using System; using System.Collections.Generic; using System.Threading; @@ -71,14 +70,27 @@ public string IdShort return id.Substring(0, Math.Min(12, id.Length)); } } + + /// + /// Date tiem the image was created. + /// + public DateTime Created { get; set; } } + public interface IContainerRunner { Task IsApplicationRunning(ImageVersion imageVersion, CancellationToken cancellationToken = default); + + Task GetLatestApplicationVersion(CancellationToken cancellationToken = default); + + Task GetLatestApplicationVersion(string version, CancellationToken cancellationToken = default); + Task> GetApplicationVersions(CancellationToken cancellationToken = default); - Task GetApplicationVersion(CancellationToken cancellationToken = default); - Task GetApplicationVersion(string version, CancellationToken cancellationToken = default); - Task StartApplication(ImageVersion imageVersion, CancellationToken cancellationToken = default); - Task StopApplication(RunnerState runnerState, CancellationToken cancellationToken = default); + + Task> GetApplicationVersions(string version, CancellationToken cancellationToken = default); + + Task StartApplication(ImageVersion imageVersion, CancellationToken cancellationToken = default); + + Task StopApplication(RunnerState runnerState, CancellationToken cancellationToken = default); } } diff --git a/src/CLI/Services/IContainerRunnerFactory.cs b/src/CLI/Services/IContainerRunnerFactory.cs index 30c51ff58..b3fa4e8eb 100644 --- a/src/CLI/Services/IContainerRunnerFactory.cs +++ b/src/CLI/Services/IContainerRunnerFactory.cs @@ -9,7 +9,6 @@ // See the License for the specific language governing permissions and // limitations under the License. - namespace Monai.Deploy.InformaticsGateway.CLI { public interface IContainerRunnerFactory diff --git a/src/CLI/Test/ConfigurationOptionsTest.cs b/src/CLI/Services/Runner.cs similarity index 59% rename from src/CLI/Test/ConfigurationOptionsTest.cs rename to src/CLI/Services/Runner.cs index 20a382372..1d997d422 100644 --- a/src/CLI/Test/ConfigurationOptionsTest.cs +++ b/src/CLI/Services/Runner.cs @@ -9,19 +9,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; -using Xunit; - -namespace Monai.Deploy.InformaticsGateway.CLI.Test +namespace Monai.Deploy.InformaticsGateway.CLI { - public class ConfigurationOptionsTest + public enum Runner { - [Fact(DisplayName = "Validate with malformed Uri")] - public void Validate_MalformedUri() - { - var options = new ConfigurationOptions { Endpoint = "http:///bad-uri" }; - - Assert.Throws(() => options.Validate()); - } + Docker, + Kubernetes, + Helm, } } diff --git a/src/CLI/Strings.cs b/src/CLI/Strings.cs index d407da40b..399615a97 100644 --- a/src/CLI/Strings.cs +++ b/src/CLI/Strings.cs @@ -14,5 +14,6 @@ namespace Monai.Deploy.InformaticsGateway.CLI internal static class Strings { public const string ApplicationName = "MONAI Deploy Informatics Gateway"; + public const string WorkloadManagerName = "MONAI Deploy Workload Manager"; } } diff --git a/src/CLI/Test/AetCommandTest.cs b/src/CLI/Test/AetCommandTest.cs index 8f77bc4d6..aa61f32de 100644 --- a/src/CLI/Test/AetCommandTest.cs +++ b/src/CLI/Test/AetCommandTest.cs @@ -66,8 +66,10 @@ public AetCommandTest() _paser = _commandLineBuilder.Build(); _loggerFactory.Setup(p => p.CreateLogger(It.IsAny())).Returns(_logger.Object); - _configurationService.Setup(p => p.ConfigurationExists()).Returns(true); - _configurationService.Setup(p => p.Load(It.IsAny())).Returns(new ConfigurationOptions { Endpoint = "http://test" }); + _configurationService.SetupGet(p => p.IsInitialized).Returns(true); + _configurationService.SetupGet(p => p.IsConfigExists).Returns(true); + _configurationService.Setup(p => p.Configurations.InformaticsGatewayServerUri).Returns(new Uri("http://test")); + _configurationService.Setup(p => p.Configurations.InformaticsGatewayServerEndpoint).Returns("http://test"); } [Fact(DisplayName = "aet comand")] @@ -108,8 +110,6 @@ public async Task AetAdd_Command() Assert.Equal(ExitCodes.Success, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify( p => p.MonaiScpAeTitle.Create( @@ -128,14 +128,28 @@ public async Task AetAdd_Command_Exception() Assert.Equal(ExitCodes.MonaiScp_ErrorCreate, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.MonaiScpAeTitle.Create(It.IsAny(), It.IsAny()), Times.Once()); _logger.VerifyLoggingMessageBeginsWith("Error creating MONAI SCP AE Title", LogLevel.Critical, Times.Once()); } + [Fact(DisplayName = "aet add comand configuration exception")] + public async Task AetAdd_Command_ConfigurationException() + { + var command = "aet add -n MyName -a MyAET --apps App MyCoolApp TheApp"; + _configurationService.SetupGet(p => p.IsInitialized).Returns(false); + + int exitCode = await _paser.InvokeAsync(command); + + Assert.Equal(ExitCodes.Config_NotConfigured, exitCode); + + _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Never()); + _informaticsGatewayClient.Verify(p => p.MonaiScpAeTitle.List(It.IsAny()), Times.Never()); + + _logger.VerifyLoggingMessageBeginsWith("Please execute `testhost config init` to intialize Informatics Gateway.", LogLevel.Critical, Times.Once()); + } + [Fact(DisplayName = "aet remove comand")] public async Task AetRemove_Command() { @@ -152,8 +166,6 @@ public async Task AetRemove_Command() Assert.Equal(ExitCodes.Success, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.MonaiScpAeTitle.Delete(It.Is(o => o.Equals(name)), It.IsAny()), Times.Once()); } @@ -169,14 +181,28 @@ public async Task AetRemove_Command_Exception() Assert.Equal(ExitCodes.MonaiScp_ErrorDelete, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.MonaiScpAeTitle.Delete(It.IsAny(), It.IsAny()), Times.Once()); _logger.VerifyLoggingMessageBeginsWith("Error deleting MONAI SCP AE Title", LogLevel.Critical, Times.Once()); } + [Fact(DisplayName = "aet list comand configuration exception")] + public async Task AetRemove_Command_ConfigurationException() + { + var command = "aet rm -n MyName"; + _configurationService.SetupGet(p => p.IsInitialized).Returns(false); + + int exitCode = await _paser.InvokeAsync(command); + + Assert.Equal(ExitCodes.Config_NotConfigured, exitCode); + + _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Never()); + _informaticsGatewayClient.Verify(p => p.MonaiScpAeTitle.List(It.IsAny()), Times.Never()); + + _logger.VerifyLoggingMessageBeginsWith("Please execute `testhost config init` to intialize Informatics Gateway.", LogLevel.Critical, Times.Once()); + } + [Fact(DisplayName = "aet list comand")] public async Task AetList_Command() { @@ -198,8 +224,6 @@ public async Task AetList_Command() Assert.Equal(ExitCodes.Success, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.MonaiScpAeTitle.List(It.IsAny()), Times.Once()); } @@ -215,14 +239,28 @@ public async Task AetList_Command_Exception() Assert.Equal(ExitCodes.MonaiScp_ErrorList, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.MonaiScpAeTitle.List(It.IsAny()), Times.Once()); _logger.VerifyLoggingMessageBeginsWith("Error retrieving MONAI SCP AE Titles", LogLevel.Critical, Times.Once()); } + [Fact(DisplayName = "aet list comand configuration exception")] + public async Task AetList_Command_ConfigurationException() + { + var command = "aet list"; + _configurationService.SetupGet(p => p.IsInitialized).Returns(false); + + int exitCode = await _paser.InvokeAsync(command); + + Assert.Equal(ExitCodes.Config_NotConfigured, exitCode); + + _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Never()); + _informaticsGatewayClient.Verify(p => p.MonaiScpAeTitle.List(It.IsAny()), Times.Never()); + + _logger.VerifyLoggingMessageBeginsWith("Please execute `testhost config init` to intialize Informatics Gateway.", LogLevel.Critical, Times.Once()); + } + [Fact(DisplayName = "aet list comand empty")] public async Task AetList_Command_Empty() { @@ -234,8 +272,6 @@ public async Task AetList_Command_Empty() Assert.Equal(ExitCodes.Success, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.MonaiScpAeTitle.List(It.IsAny()), Times.Once()); diff --git a/src/CLI/Test/ConfigCommandTest.cs b/src/CLI/Test/ConfigCommandTest.cs index e2cf22059..0d16866d0 100644 --- a/src/CLI/Test/ConfigCommandTest.cs +++ b/src/CLI/Test/ConfigCommandTest.cs @@ -12,12 +12,15 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Monai.Deploy.InformaticsGateway.CLI.Services; using Monai.Deploy.InformaticsGateway.Shared.Test; using Moq; +using System; using System.CommandLine.Builder; using System.CommandLine.Hosting; using System.CommandLine.Parsing; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Xunit; @@ -31,9 +34,11 @@ public class ConfigCommandTest private readonly Parser _paser; private readonly Mock _loggerFactory; private readonly Mock _logger; + private readonly Mock _confirmationPrompt; public ConfigCommandTest() { + _confirmationPrompt = new Mock(); _loggerFactory = new Mock(); _logger = new Mock(); _configurationService = new Mock(); @@ -44,6 +49,7 @@ public ConfigCommandTest() { host.ConfigureServices(services => { + services.AddSingleton(p => _confirmationPrompt.Object); services.AddSingleton(p => _loggerFactory.Object); services.AddSingleton(p => _configurationService.Object); }); @@ -58,10 +64,10 @@ public async Task Config_Command() { var command = "config"; var result = _paser.Parse(command); - Assert.Equal("Option '-e' is required.", result.Errors.First().Message); + Assert.Equal("Required command was not provided.", result.Errors.First().Message); int exitCode = await _paser.InvokeAsync(command); - Assert.Equal(ExitCodes.Config_NotConfigured, exitCode); + Assert.Equal(ExitCodes.Success, exitCode); } [Fact(DisplayName = "config show comand when not yet configured")] @@ -71,15 +77,11 @@ public async Task ConfigShow_Command_NotConfigured() var result = _paser.Parse(command); Assert.Equal(0, result.Errors.Count); - _configurationService.Setup(p => p.ConfigurationExists()).Returns(false); - _configurationService.Setup(p => p.Load(It.IsAny())).Returns(new ConfigurationOptions { Endpoint = "http://test" }); + _configurationService.SetupGet(p => p.IsInitialized).Returns(false); int exitCode = await _paser.InvokeAsync(command); Assert.Equal(ExitCodes.Config_NotConfigured, exitCode); - - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Never()); } [Fact(DisplayName = "config show comand ")] @@ -89,37 +91,215 @@ public async Task ConfigShow_Command() var result = _paser.Parse(command); Assert.Equal(0, result.Errors.Count); - _configurationService.Setup(p => p.ConfigurationExists()).Returns(true); - _configurationService.Setup(p => p.Load(It.IsAny())).Returns(new ConfigurationOptions { Endpoint = "http://test" }); + _configurationService.SetupGet(p => p.IsInitialized).Returns(true); + _configurationService.SetupGet(p => p.Configurations.InformaticsGatewayServerEndpoint).Returns("http://test"); + _configurationService.SetupGet(p => p.Configurations.DicomListeningPort).Returns(100); + _configurationService.SetupGet(p => p.Configurations.Runner).Returns(Runner.Docker); + _configurationService.SetupGet(p => p.Configurations.HostDatabaseStorageMount).Returns("DB"); + _configurationService.SetupGet(p => p.Configurations.HostDataStorageMount).Returns("Data"); + _configurationService.SetupGet(p => p.Configurations.HostLogsStorageMount).Returns("Logs"); + _configurationService.SetupGet(p => p.Configurations.WorkloadManagerRestEndpoint).Returns("REST"); + _configurationService.SetupGet(p => p.Configurations.WorkloadManagerGrpcEndpoint).Returns("GRPC"); + + int exitCode = await _paser.InvokeAsync(command); + + Assert.Equal(ExitCodes.Success, exitCode); + + _logger.VerifyLogging("Informatics Gateway API: http://test", LogLevel.Information, Times.Once()); + _logger.VerifyLogging("DICOM SCP Listening Port: 100", LogLevel.Information, Times.Once()); + _logger.VerifyLogging("Container Runner: Docker", LogLevel.Information, Times.Once()); + _logger.VerifyLogging("Host:", LogLevel.Information, Times.Once()); + _logger.VerifyLogging(" Database storage mount: DB", LogLevel.Information, Times.Once()); + _logger.VerifyLogging(" Data storage mount: Data", LogLevel.Information, Times.Once()); + _logger.VerifyLogging(" Logs storage mount: Logs", LogLevel.Information, Times.Once()); + _logger.VerifyLogging("Workload Manager:", LogLevel.Information, Times.Once()); + _logger.VerifyLogging(" REST API: REST", LogLevel.Information, Times.Once()); + _logger.VerifyLogging(" gRPC API: GRPC", LogLevel.Information, Times.Once()); + } + + [Fact(DisplayName = "config show comand exception")] + public async Task ConfigShow_Command_Exception() + { + var command = "config show"; + var result = _paser.Parse(command); + Assert.Equal(0, result.Errors.Count); + + _configurationService.SetupGet(p => p.IsInitialized).Returns(true); + _configurationService.SetupGet(p => p.Configurations.InformaticsGatewayServerEndpoint).Throws(new System.Exception("error")); + + int exitCode = await _paser.InvokeAsync(command); + + Assert.Equal(ExitCodes.Config_ErrorShowing, exitCode); + } + + [Fact(DisplayName = "config wgrpc command")] + public async Task ConfigWmGrpc_Command() + { + var command = "config wmgrpc http://test:123"; + + var result = _paser.Parse(command); + Assert.Equal(0, result.Errors.Count); + + var callbackResult = string.Empty; + _configurationService.SetupGet(p => p.IsInitialized).Returns(true); + _configurationService.SetupSet(p => p.Configurations.WorkloadManagerGrpcEndpoint = It.IsAny()) + .Callback(value => callbackResult = value); + + int exitCode = await _paser.InvokeAsync(command); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Equal("http://test:123", callbackResult); + } + + [Fact(DisplayName = "config wgrest command")] + public async Task ConfigWmRest_Command() + { + var command = "config wmrest http://test:123"; + + var result = _paser.Parse(command); + Assert.Equal(0, result.Errors.Count); + + var callbackResult = string.Empty; + _configurationService.SetupGet(p => p.IsInitialized).Returns(true); + _configurationService.SetupSet(p => p.Configurations.WorkloadManagerRestEndpoint = It.IsAny()) + .Callback(value => callbackResult = value); int exitCode = await _paser.InvokeAsync(command); Assert.Equal(ExitCodes.Success, exitCode); + Assert.Equal("http://test:123", callbackResult); + } + + [Fact(DisplayName = "config runner command")] + public async Task ConfigRunner_Command() + { + var command = "config runner helm"; - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); + var result = _paser.Parse(command); + Assert.Equal(0, result.Errors.Count); - _logger.VerifyLogging("Endpoint: http://test", LogLevel.Information, Times.Once()); + var callbackResult = Runner.Docker; + _configurationService.SetupGet(p => p.IsInitialized).Returns(true); + _configurationService.SetupSet(p => p.Configurations.Runner = It.IsAny()) + .Callback(value => callbackResult = value); + + int exitCode = await _paser.InvokeAsync(command); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Equal(Runner.Helm, callbackResult); } - [Fact(DisplayName = "config with options")] - public async Task Config_Command_WithOptions() + [Fact(DisplayName = "config endpoint command")] + public async Task ConfigEndpoint_Command() { - var command = "config -e http://new"; + var command = "config endpoint http://test:123"; + var result = _paser.Parse(command); Assert.Equal(0, result.Errors.Count); - _configurationService.Setup(p => p.Load(It.IsAny())).Returns(new ConfigurationOptions { Endpoint = "http://old" }); - _configurationService.Setup(p => p.CreateConfigDirectoryIfNotExist()); + var callbackResult = string.Empty; + _configurationService.SetupGet(p => p.IsInitialized).Returns(true); + _configurationService.SetupSet(p => p.Configurations.InformaticsGatewayServerEndpoint = It.IsAny()) + .Callback(value => callbackResult = value); int exitCode = await _paser.InvokeAsync(command); Assert.Equal(ExitCodes.Success, exitCode); + Assert.Equal("http://test:123", callbackResult); + } + + [Fact(DisplayName = "config endpoint command exception")] + public async Task ConfigEndpoint_Command_Exception() + { + var command = "config endpoint http://test:123"; + + var result = _paser.Parse(command); + Assert.Equal(0, result.Errors.Count); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); - _configurationService.Verify(p => p.CreateConfigDirectoryIfNotExist(), Times.Once()); - _configurationService.Verify(p => p.Save(It.Is( - c => c.Endpoint.Equals("http://new"))), Times.Once()); + var callbackResult = string.Empty; + _configurationService.SetupGet(p => p.IsInitialized).Returns(true); + _configurationService.SetupSet(p => p.Configurations.InformaticsGatewayServerEndpoint = It.IsAny()) + .Throws(new Exception("error")); + + int exitCode = await _paser.InvokeAsync(command); + + Assert.Equal(ExitCodes.Config_ErrorSaving, exitCode); + } + + [Fact(DisplayName = "config endpoint command config exception")] + public async Task ConfigEndpoint_Command_CopnfigException() + { + var command = "config endpoint http://test:123"; + + var result = _paser.Parse(command); + Assert.Equal(0, result.Errors.Count); + + var callbackResult = string.Empty; + _configurationService.SetupGet(p => p.IsInitialized).Returns(false); + + int exitCode = await _paser.InvokeAsync(command); + + Assert.Equal(ExitCodes.Config_NotConfigured, exitCode); + } + + [Fact(DisplayName = "config init command")] + public async Task ConfigInit_Command() + { + var command = "config init"; + + var result = _paser.Parse(command); + Assert.Equal(0, result.Errors.Count); + + _configurationService.SetupGet(p => p.IsConfigExists).Returns(false); + _configurationService.Setup(p => p.Initialize(It.IsAny())); + + int exitCode = await _paser.InvokeAsync(command); + Assert.Equal(ExitCodes.Success, exitCode); + _configurationService.Verify(p => p.Initialize(It.IsAny()), Times.Once()); + } + + [Fact(DisplayName = "config init command bypass prompt")] + public async Task ConfigInit_Command_BypassPrompt() + { + var command = "config init -y"; + + var result = _paser.Parse(command); + Assert.Equal(0, result.Errors.Count); + + _configurationService.Setup(p => p.Initialize(It.IsAny())); + + int exitCode = await _paser.InvokeAsync(command); + Assert.Equal(ExitCodes.Success, exitCode); + _configurationService.Verify(p => p.Initialize(It.IsAny()), Times.Once()); + } + + [Fact(DisplayName = "config init command exception")] + public async Task ConfigInit_Command_Exception() + { + var command = "config init -y"; + + var result = _paser.Parse(command); + Assert.Equal(0, result.Errors.Count); + + _configurationService.Setup(p => p.Initialize(It.IsAny())).Throws(new Exception("error")); + + int exitCode = await _paser.InvokeAsync(command); + Assert.Equal(ExitCodes.Config_ErrorInitializing, exitCode); + _configurationService.Verify(p => p.Initialize(It.IsAny()), Times.Once()); + } + + [Fact(DisplayName = "config init command cancelled")] + public async Task ConfigInit_Command_Cancelled() + { + var command = "config init"; + _confirmationPrompt.Setup(p => p.ShowConfirmationPrompt(It.IsAny())).Returns(false); + _configurationService.SetupGet(p => p.IsConfigExists).Returns(true); + + var result = _paser.Parse(command); + Assert.Equal(0, result.Errors.Count); + + int exitCode = await _paser.InvokeAsync(command); + Assert.Equal(ExitCodes.Stop_Cancelled, exitCode); } } } diff --git a/src/CLI/Test/ConfigurationServiceTest.cs b/src/CLI/Test/ConfigurationServiceTest.cs index a4769794b..18d30f18b 100644 --- a/src/CLI/Test/ConfigurationServiceTest.cs +++ b/src/CLI/Test/ConfigurationServiceTest.cs @@ -10,13 +10,13 @@ // limitations under the License. using Microsoft.Extensions.Logging; -using Monai.Deploy.InformaticsGateway.Shared.Test; using Moq; -using Newtonsoft.Json; using System; using System.IO; using System.IO.Abstractions; using System.Text; +using System.Threading; +using System.Threading.Tasks; using Xunit; namespace Monai.Deploy.InformaticsGateway.CLI.Test @@ -25,17 +25,30 @@ public class ConfigurationServiceTest { private readonly Mock> _logger; private readonly Mock _fileSystem; + private readonly Mock _embeddedResource; public ConfigurationServiceTest() { _logger = new Mock>(); _fileSystem = new Mock(); + _embeddedResource = new Mock(); + } + + [Fact(DisplayName = "ConfigurationServiceTest constructor")] + public void ConfigurationServiceTest_Constructor() + { + Assert.Throws(() => new ConfigurationService(null, null, null)); + Assert.Throws(() => new ConfigurationService(_logger.Object, null, null)); + Assert.Throws(() => new ConfigurationService(_logger.Object, _fileSystem.Object, null)); + + var svc = new ConfigurationService(_logger.Object, _fileSystem.Object, _embeddedResource.Object); + Assert.NotNull(svc.Configurations); } [Fact(DisplayName = "CreateConfigDirectoryIfNotExist creates directory")] public void CreateConfigDirectoryIfNotExist_CreateDirectory() { - var svc = new ConfigurationService(_logger.Object, _fileSystem.Object); + var svc = new ConfigurationService(_logger.Object, _fileSystem.Object, _embeddedResource.Object); _fileSystem.Setup(p => p.Directory.Exists(It.IsAny())).Returns(false); svc.CreateConfigDirectoryIfNotExist(); @@ -46,7 +59,7 @@ public void CreateConfigDirectoryIfNotExist_CreateDirectory() [Fact(DisplayName = "CreateConfigDirectoryIfNotExist skips creating directory")] public void CreateConfigDirectoryIfNotExist_SkipsCreation() { - var svc = new ConfigurationService(_logger.Object, _fileSystem.Object); + var svc = new ConfigurationService(_logger.Object, _fileSystem.Object, _embeddedResource.Object); _fileSystem.Setup(p => p.Directory.Exists(It.IsAny())).Returns(true); svc.CreateConfigDirectoryIfNotExist(); @@ -54,91 +67,69 @@ public void CreateConfigDirectoryIfNotExist_SkipsCreation() _fileSystem.Verify(p => p.Directory.CreateDirectory(It.IsAny()), Times.Never()); } - [Fact(DisplayName = "ConfigurationExists")] - public void ConfigurationExists() + [Fact(DisplayName = "IsInitialized")] + public void IsInitialized() { - var svc = new ConfigurationService(_logger.Object, _fileSystem.Object); + var svc = new ConfigurationService(_logger.Object, _fileSystem.Object, _embeddedResource.Object); + _fileSystem.Setup(p => p.Directory.Exists(It.IsAny())).Returns(true); _fileSystem.Setup(p => p.File.Exists(It.IsAny())).Returns(true); - Assert.True(svc.ConfigurationExists()); - _fileSystem.Setup(p => p.File.Exists(It.IsAny())).Returns(false); - Assert.False(svc.ConfigurationExists()); + Assert.True(svc.IsInitialized); + _fileSystem.Setup(p => p.Directory.Exists(It.IsAny())).Returns(false); + Assert.False(svc.IsInitialized); } - [Fact(DisplayName = "Load verbose logging")] - public void Load_Verbose() + [Fact(DisplayName = "IsConfigExists")] + public void ConfigurationExists() { - var svc = new ConfigurationService(_logger.Object, _fileSystem.Object); - - var stream = new System.IO.StringWriter(); - var config = new ConfigurationOptions { Endpoint = "http://test" }; - var json = JsonConvert.SerializeObject(config); - _fileSystem.Setup(p => p.File.OpenText(It.IsAny())).Returns(new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(json)))); - - var result = svc.Load(true); + var svc = new ConfigurationService(_logger.Object, _fileSystem.Object, _embeddedResource.Object); - Assert.Equal(config.Endpoint, result.Endpoint); - _logger.VerifyLogging(LogLevel.Debug, Times.AtLeastOnce()); + _fileSystem.Setup(p => p.File.Exists(It.IsAny())).Returns(true); + Assert.True(svc.IsConfigExists); + _fileSystem.Setup(p => p.File.Exists(It.IsAny())).Returns(false); + Assert.False(svc.IsConfigExists); } - [Fact(DisplayName = "Load no verbose logging")] - public void Load_NoVerbose() + [Fact(DisplayName = "Initialize with missing config resource")] + public void Initialize_ShallThrowWhenConfigReousrceIsMissing() { - var svc = new ConfigurationService(_logger.Object, _fileSystem.Object); - - var stream = new System.IO.StringWriter(); - var config = new ConfigurationOptions { Endpoint = "http://test" }; - var json = JsonConvert.SerializeObject(config); - _fileSystem.Setup(p => p.File.OpenText(It.IsAny())).Returns(new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(json)))); + _embeddedResource.Setup(p => p.GetManifestResourceStream(It.IsAny())).Returns(default(Stream)); - var result = svc.Load(); - - Assert.Equal(config.Endpoint, result.Endpoint); - _logger.VerifyLogging(LogLevel.Debug, Times.Never()); + var svc = new ConfigurationService(_logger.Object, _fileSystem.Object, _embeddedResource.Object); + Assert.ThrowsAsync(async () => await svc.Initialize(CancellationToken.None)); } - [Fact(DisplayName = "Load throws")] - public void Load_Throws() + [Fact(DisplayName = "Initialize creates the config file")] + public async Task Initialize_CreatesTheConfigFile() { - var svc = new ConfigurationService(_logger.Object, _fileSystem.Object); - - using var stream = new System.IO.StringWriter(); - _fileSystem.Setup(p => p.File.OpenText(It.IsAny())).Throws(new Exception("error")); + var testString = "hello world"; + var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(testString)); + var mockStream = new Mock(); + byte[] bytesWritten = null; + + mockStream.Setup(p => p.FlushAsync(It.IsAny())); + mockStream.Setup(p => p.Close()); + mockStream.Setup(p => p.CanWrite).Returns(true); + mockStream.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((byte[] bytes, int offset, int count, CancellationToken cancellationToken) => + { + bytesWritten = new byte[bytes.Length]; + bytes.CopyTo(bytesWritten, 0); + }); - var result = svc.Load(); + _fileSystem.Setup(p => p.Directory.Exists(It.IsAny())).Returns(true); + _fileSystem.Setup(p => p.FileStream.Create(It.IsAny(), It.IsAny())).Returns(mockStream.Object); + _embeddedResource.Setup(p => p.GetManifestResourceStream(It.IsAny())).Returns(memoryStream); - Assert.NotNull(result); - Assert.Null(result.Endpoint); - _logger.VerifyLogging(LogLevel.Warning, Times.Once()); - _logger.VerifyLogging(LogLevel.Debug, Times.Never()); - } + var svc = new ConfigurationService(_logger.Object, _fileSystem.Object, _embeddedResource.Object); + await svc.Initialize(CancellationToken.None); - [Fact(DisplayName = "Save")] - public void Save() - { - var svc = new ConfigurationService(_logger.Object, _fileSystem.Object); - var config = new ConfigurationOptions { Endpoint = "http://test" }; + _embeddedResource.Verify(p => p.GetManifestResourceStream(Common.AppSettingsResourceName), Times.Once()); + _fileSystem.Verify(p => p.FileStream.Create(Common.ConfigFilePath, FileMode.Create), Times.Once()); + mockStream.Verify(p => p.FlushAsync(It.IsAny()), Times.Once()); + mockStream.Verify(p => p.Close(), Times.Once()); - byte[] bytes = null; - using (var ms = new MemoryStream()) - { - using (var sw = new StreamWriter(ms)) - { - _fileSystem.Setup(p => p.File.CreateText(It.IsAny())).Returns(sw); - svc.Save(config); - bytes = ms.ToArray(); - } - } - - ConfigurationOptions result; - using (var ms = new MemoryStream(bytes)) - { - var serializer = new JsonSerializer(); - using var streamReader = new StreamReader(ms); - result = serializer.Deserialize(streamReader, typeof(ConfigurationOptions)) as ConfigurationOptions; - } - Assert.NotNull(result); - Assert.Equal(config.Endpoint, result.Endpoint); + Assert.Equal(testString, Encoding.UTF8.GetString(bytesWritten)); } } } diff --git a/src/CLI/Test/ContainerRunnerFactoryTest.cs b/src/CLI/Test/ContainerRunnerFactoryTest.cs new file mode 100644 index 000000000..a7a2fde22 --- /dev/null +++ b/src/CLI/Test/ContainerRunnerFactoryTest.cs @@ -0,0 +1,72 @@ +// Copyright 2021 MONAI Consortium +// 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 Docker.DotNet; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using System; +using System.IO.Abstractions; +using Xunit; + +namespace Monai.Deploy.InformaticsGateway.CLI.Test +{ + public class ContainerRunnerFactoryTest + { + private readonly Mock _serviceScopeFactory; + private readonly Mock _configurationService; + private readonly Mock _serviceScope; + private readonly Mock> _logger; + private readonly Mock _fileSystem; + private readonly Mock _dockerClient; + + public ContainerRunnerFactoryTest() + { + _serviceScopeFactory = new Mock(); + _configurationService = new Mock(); + _serviceScope = new Mock(); + _logger = new Mock>(); + _fileSystem = new Mock(); + _dockerClient = new Mock(); + } + + [Fact(DisplayName = "ContainerRunnerFactory Constructor")] + public void ContainerRunnerFactory_Constructor() + { + Assert.Throws(() => new ContainerRunnerFactory(null, null)); + Assert.Throws(() => new ContainerRunnerFactory(_serviceScopeFactory.Object, null)); + } + + [Fact(DisplayName = "GetContainerRunner")] + public void GetContainerRunner() + { + var runner = new DockerRunner(_logger.Object, _configurationService.Object, _fileSystem.Object, _dockerClient.Object); + _configurationService.SetupGet(p => p.Configurations.Runner).Returns(Runner.Docker); + _serviceScopeFactory.Setup(p => p.CreateScope()).Returns(_serviceScope.Object); + _serviceScope.Setup(p => p.ServiceProvider.GetService(It.IsAny())).Returns(runner); + var factory = new ContainerRunnerFactory(_serviceScopeFactory.Object, _configurationService.Object); + + var result = factory.GetContainerRunner(); + Assert.Equal(result, runner); + } + + [Fact(DisplayName = "GetContainerRunner NotImplementedException")] + public void GetContainerRunner_NotImplementedException() + { + var runner = new DockerRunner(_logger.Object, _configurationService.Object, _fileSystem.Object, _dockerClient.Object); + _configurationService.SetupGet(p => p.Configurations.Runner).Returns(Runner.Helm); + _serviceScopeFactory.Setup(p => p.CreateScope()).Returns(_serviceScope.Object); + var factory = new ContainerRunnerFactory(_serviceScopeFactory.Object, _configurationService.Object); + + Assert.Throws(() => factory.GetContainerRunner()); + } + } +} diff --git a/src/CLI/Test/ControlServiceTest.cs b/src/CLI/Test/ControlServiceTest.cs new file mode 100644 index 000000000..37b7e7274 --- /dev/null +++ b/src/CLI/Test/ControlServiceTest.cs @@ -0,0 +1,179 @@ +// Copyright 2021 MONAI Consortium +// 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 Microsoft.Extensions.Logging; +using Monai.Deploy.InformaticsGateway.Shared.Test; +using Moq; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Monai.Deploy.InformaticsGateway.CLI.Test +{ + public class ControlServiceTest + { + private readonly Mock _configurationService; + private readonly Mock _containerRunnerFactory; + private readonly Mock _containerRunner; + private readonly Mock> _logger; + + public ControlServiceTest() + { + _configurationService = new Mock(); + _containerRunnerFactory = new Mock(); + _logger = new Mock>(); + _containerRunner = new Mock(); + + _containerRunnerFactory.Setup(p => p.GetContainerRunner()).Returns(_containerRunner.Object); + } + + [Fact(DisplayName = "ControlServiceTest constructor")] + public void ControlServiceTest_Constructor() + { + Assert.Throws(() => new ControlService(null, null, null)); + Assert.Throws(() => new ControlService(_containerRunnerFactory.Object, null, null)); + Assert.Throws(() => new ControlService(_containerRunnerFactory.Object, _logger.Object, null)); + } + + [Fact(DisplayName = "Start - throw exception wihtout any application images found")] + public async Task Start_WithoutAnyApplicationInstalled() + { + _containerRunner.Setup(p => p.GetLatestApplicationVersion(CancellationToken.None)).ReturnsAsync(default(ImageVersion)); + _configurationService.SetupGet(p => p.Configurations.DockerImagePrefix).Returns("PREFIX"); + + var service = new ControlService(_containerRunnerFactory.Object, _logger.Object, _configurationService.Object); + + var exception = await Assert.ThrowsAsync(async () => await service.Start()); + + Assert.Equal(ExitCodes.Start_Error_ApplicationNotFound, exception.ErrorCode); + } + + [Fact(DisplayName = "Start - throw exception when application image is running")] + public async Task Start_ApplicationImageIsAlreadyRunning() + { + _containerRunner.Setup(p => p.GetLatestApplicationVersion(It.IsAny())) + .ReturnsAsync(new ImageVersion { Id = Guid.NewGuid().ToString("N") }); + + _containerRunner.Setup(p => p.IsApplicationRunning(It.IsAny(), It.IsAny())) + .ReturnsAsync(new RunnerState { IsRunning = true, Id = Guid.NewGuid().ToString("N") }); + + var service = new ControlService(_containerRunnerFactory.Object, _logger.Object, _configurationService.Object); + + var exception = await Assert.ThrowsAsync(async () => await service.Start()); + + Assert.Equal(ExitCodes.Start_Error_ApplicationAlreadyRunning, exception.ErrorCode); + } + + [Fact(DisplayName = "Start - starts the application")] + public async Task Start_StartsTheLatestApplicationImage() + { + _containerRunner.Setup(p => p.GetLatestApplicationVersion(It.IsAny())) + .ReturnsAsync(new ImageVersion { Id = Guid.NewGuid().ToString("N") }); + + _containerRunner.Setup(p => p.IsApplicationRunning(It.IsAny(), It.IsAny())) + .ReturnsAsync(new RunnerState { IsRunning = false, Id = Guid.NewGuid().ToString("N") }); + + _containerRunner.Setup(p => p.StartApplication(It.IsAny(), It.IsAny())); + + var service = new ControlService(_containerRunnerFactory.Object, _logger.Object, _configurationService.Object); + + await service.Start(); + } + + [Fact(DisplayName = "Stop - no running application")] + public async Task Stop_NoRunningApplication() + { + _containerRunner.Setup(p => p.GetApplicationVersions(It.IsAny())) + .ReturnsAsync(default(List)); + + var service = new ControlService(_containerRunnerFactory.Object, _logger.Object, _configurationService.Object); + + await service.Stop(); + } + + [Fact(DisplayName = "Stop - error stopping application")] + public async Task Stop_ErrorStopingApplication() + { + var data = new List + { + new ImageVersion{ Id = Guid.NewGuid().ToString("N"), Version = "1", Created =DateTime.UtcNow} + }; + + _containerRunner.Setup(p => p.GetApplicationVersions(It.IsAny())) + .ReturnsAsync(data); + _containerRunner.Setup(p => p.IsApplicationRunning(It.IsAny(), It.IsAny())) + .ReturnsAsync(new RunnerState { IsRunning = true, Id = data[0].Id }); + _containerRunner.Setup(p => p.StopApplication(It.IsAny(), It.IsAny())).ReturnsAsync(false); + _configurationService.Setup(p => p.Configurations.Runner).Returns(Runner.Docker); + + var service = new ControlService(_containerRunnerFactory.Object, _logger.Object, _configurationService.Object); + + await service.Stop(); + + _logger.VerifyLogging($"Error may have occurred stopping {Strings.ApplicationName} with container ID {data[0].Id}. Please verify with the applicatio state with {Runner.Docker}.", LogLevel.Warning, Times.Once()); + } + + [Fact(DisplayName = "Stop - stops running applications")] + public async Task Stop_StopRunningApplications() + { + var data = new List + { + new ImageVersion{ Id = Guid.NewGuid().ToString("N"), Version = "1", Created =DateTime.UtcNow}, + new ImageVersion{ Id = Guid.NewGuid().ToString("N"), Version = "2", Created =DateTime.UtcNow}, + new ImageVersion{ Id = Guid.NewGuid().ToString("N"), Version = "3", Created =DateTime.UtcNow}, + }; + + _containerRunner.Setup(p => p.GetApplicationVersions(It.IsAny())) + .ReturnsAsync(data); + _containerRunner.SetupSequence(p => p.IsApplicationRunning(It.IsAny(), It.IsAny())) + .ReturnsAsync(new RunnerState { IsRunning = true, Id = data[0].Id }) + .ReturnsAsync(new RunnerState { IsRunning = true, Id = data[1].Id }) + .ReturnsAsync(new RunnerState { IsRunning = false, Id = data[2].Id }); + _containerRunner.Setup(p => p.StopApplication(It.IsAny(), It.IsAny())).ReturnsAsync(true); + _configurationService.Setup(p => p.Configurations.Runner).Returns(Runner.Docker); + + var service = new ControlService(_containerRunnerFactory.Object, _logger.Object, _configurationService.Object); + + await service.Stop(); + + _logger.VerifyLogging($"{Strings.ApplicationName} with container ID {data[0].Id} stopped.", LogLevel.Information, Times.Once()); + _logger.VerifyLogging($"{Strings.ApplicationName} with container ID {data[1].Id} stopped.", LogLevel.Information, Times.Once()); + _logger.VerifyLogging($"{Strings.ApplicationName} with container ID {data[2].Id} stopped.", LogLevel.Information, Times.Never()); + } + + [Fact(DisplayName = "Restart")] + public async Task Restart() + { + var data = new List + { + new ImageVersion{ Id = Guid.NewGuid().ToString("N"), Version = "1", Created =DateTime.UtcNow}, + new ImageVersion{ Id = Guid.NewGuid().ToString("N"), Version = "2", Created =DateTime.UtcNow}, + }; + _containerRunner.Setup(p => p.GetApplicationVersions(It.IsAny())) + .ReturnsAsync(data); + _containerRunner.Setup(p => p.IsApplicationRunning(It.Is(p => p == data[0]), It.IsAny())) + .ReturnsAsync(new RunnerState { IsRunning = true, Id = Guid.NewGuid().ToString("N") }); + _containerRunner.Setup(p => p.GetLatestApplicationVersion(It.IsAny())) + .ReturnsAsync(data[1]); + _containerRunner.Setup(p => p.IsApplicationRunning(It.Is(p => p == data[1]), It.IsAny())) + .ReturnsAsync(new RunnerState { IsRunning = false, Id = Guid.NewGuid().ToString("N") }); + _containerRunner.Setup(p => p.StartApplication(It.IsAny(), It.IsAny())); + _containerRunner.Setup(p => p.StopApplication(It.IsAny(), It.IsAny())).ReturnsAsync(true); + _configurationService.Setup(p => p.Configurations.Runner).Returns(Runner.Docker); + + var service = new ControlService(_containerRunnerFactory.Object, _logger.Object, _configurationService.Object); + + await service.Restart(); + } + } +} diff --git a/src/CLI/Test/DestinationCommandTest.cs b/src/CLI/Test/DestinationCommandTest.cs index d08da6dcc..3f5ddfb7e 100644 --- a/src/CLI/Test/DestinationCommandTest.cs +++ b/src/CLI/Test/DestinationCommandTest.cs @@ -66,8 +66,10 @@ public DestinationCommandTest() _paser = _commandLineBuilder.Build(); _loggerFactory.Setup(p => p.CreateLogger(It.IsAny())).Returns(_logger.Object); - _configurationService.Setup(p => p.ConfigurationExists()).Returns(true); - _configurationService.Setup(p => p.Load(It.IsAny())).Returns(new ConfigurationOptions { Endpoint = "http://test" }); + _configurationService.SetupGet(p => p.IsInitialized).Returns(true); + _configurationService.SetupGet(p => p.IsConfigExists).Returns(true); + _configurationService.SetupGet(p => p.Configurations.InformaticsGatewayServerUri).Returns(new Uri("http://test")); + _configurationService.SetupGet(p => p.Configurations.InformaticsGatewayServerEndpoint).Returns("http://test"); } [Fact(DisplayName = "dst comand")] @@ -108,8 +110,6 @@ public async Task DstAdd_Command() Assert.Equal(ExitCodes.Success, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify( p => p.DicomDestinations.Create( @@ -128,14 +128,28 @@ public async Task DstAdd_Command_Exception() Assert.Equal(ExitCodes.DestinationAe_ErrorCreate, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.DicomDestinations.Create(It.IsAny(), It.IsAny()), Times.Once()); _logger.VerifyLoggingMessageBeginsWith("Error creating DICOM destination", LogLevel.Critical, Times.Once()); } + [Fact(DisplayName = "dst add comand configuration exception")] + public async Task DstAdd_Command_ConfigurationException() + { + var command = "dst add -n MyName -a MyAET --apps App MyCoolApp TheApp"; + _configurationService.SetupGet(p => p.IsInitialized).Returns(false); + + int exitCode = await _paser.InvokeAsync(command); + + Assert.Equal(ExitCodes.Config_NotConfigured, exitCode); + + _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Never()); + _informaticsGatewayClient.Verify(p => p.DicomDestinations.List(It.IsAny()), Times.Never()); + + _logger.VerifyLoggingMessageBeginsWith("Please execute `testhost config init` to intialize Informatics Gateway.", LogLevel.Critical, Times.Once()); + } + [Fact(DisplayName = "dst remove comand")] public async Task DstRemove_Command() { @@ -152,8 +166,6 @@ public async Task DstRemove_Command() Assert.Equal(ExitCodes.Success, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.DicomDestinations.Delete(It.Is(o => o.Equals(name)), It.IsAny()), Times.Once()); } @@ -169,14 +181,28 @@ public async Task DstRemove_Command_Exception() Assert.Equal(ExitCodes.DestinationAe_ErrorDelete, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.DicomDestinations.Delete(It.IsAny(), It.IsAny()), Times.Once()); _logger.VerifyLoggingMessageBeginsWith("Error deleting DICOM destination", LogLevel.Critical, Times.Once()); } + [Fact(DisplayName = "dst remove comand configuration exception")] + public async Task DstRemove_Command_ConfigurationException() + { + var command = "dst rm -n MyName"; + _configurationService.SetupGet(p => p.IsInitialized).Returns(false); + + int exitCode = await _paser.InvokeAsync(command); + + Assert.Equal(ExitCodes.Config_NotConfigured, exitCode); + + _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Never()); + _informaticsGatewayClient.Verify(p => p.DicomDestinations.List(It.IsAny()), Times.Never()); + + _logger.VerifyLoggingMessageBeginsWith("Please execute `testhost config init` to intialize Informatics Gateway.", LogLevel.Critical, Times.Once()); + } + [Fact(DisplayName = "dst list comand")] public async Task DstList_Command() { @@ -199,8 +225,6 @@ public async Task DstList_Command() Assert.Equal(ExitCodes.Success, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.DicomDestinations.List(It.IsAny()), Times.Once()); } @@ -216,14 +240,28 @@ public async Task DstList_Command_Exception() Assert.Equal(ExitCodes.DestinationAe_ErrorList, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.DicomDestinations.List(It.IsAny()), Times.Once()); _logger.VerifyLoggingMessageBeginsWith("Error retrieving DICOM destinations", LogLevel.Critical, Times.Once()); } + [Fact(DisplayName = "dst list comand configuration exception")] + public async Task DstList_Command_ConfigurationException() + { + var command = "dst list"; + _configurationService.SetupGet(p => p.IsInitialized).Returns(false); + + int exitCode = await _paser.InvokeAsync(command); + + Assert.Equal(ExitCodes.Config_NotConfigured, exitCode); + + _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Never()); + _informaticsGatewayClient.Verify(p => p.DicomDestinations.List(It.IsAny()), Times.Never()); + + _logger.VerifyLoggingMessageBeginsWith("Please execute `testhost config init` to intialize Informatics Gateway.", LogLevel.Critical, Times.Once()); + } + [Fact(DisplayName = "dst list comand empty")] public async Task DstList_Command_Empty() { @@ -235,8 +273,6 @@ public async Task DstList_Command_Empty() Assert.Equal(ExitCodes.Success, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.DicomDestinations.List(It.IsAny()), Times.Once()); diff --git a/src/CLI/Test/DockerRunnerTest.cs b/src/CLI/Test/DockerRunnerTest.cs new file mode 100644 index 000000000..264f45642 --- /dev/null +++ b/src/CLI/Test/DockerRunnerTest.cs @@ -0,0 +1,194 @@ +// Copyright 2021 MONAI Consortium +// 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 Docker.DotNet; +using Docker.DotNet.Models; +using Microsoft.Extensions.Logging; +using Monai.Deploy.InformaticsGateway.Shared.Test; +using Moq; +using System; +using System.Collections.Generic; +using System.IO.Abstractions; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Monai.Deploy.InformaticsGateway.CLI.Test +{ + public class DockerRunnerTest + { + private readonly Mock _configurationService; + private readonly Mock _dockerClient; + private readonly Mock _fileSystem; + private readonly Mock> _logger; + + public DockerRunnerTest() + { + _logger = new Mock>(); + _configurationService = new Mock(); + _dockerClient = new Mock(); + _fileSystem = new Mock(); + } + + [Fact(DisplayName = "DockerRunner Constructor")] + public void DockerRunner_Constructor() + { + Assert.Throws(() => new DockerRunner(null, null, null, null)); + Assert.Throws(() => new DockerRunner(_logger.Object, null, null, null)); + Assert.Throws(() => new DockerRunner(_logger.Object, _configurationService.Object, null, null)); + Assert.Throws(() => new DockerRunner(_logger.Object, _configurationService.Object, _fileSystem.Object, null)); + } + + [Fact(DisplayName = "GetApplicationVersions")] + public async Task GetApplicationVersions() + { + var runner = new DockerRunner(_logger.Object, _configurationService.Object, _fileSystem.Object, _dockerClient.Object); + var data = new List + { + new ImagesListResponse{ RepoTags = new List{ "123"}, ID = $"sha256:{Guid.NewGuid().ToString("N")}", Created = DateTime.Now }, + new ImagesListResponse{ RepoTags = new List{ "456"}, ID = $"sha256:{Guid.NewGuid().ToString("N")}", Created = DateTime.Now } + }; + + _configurationService.SetupGet(p => p.Configurations.DockerImagePrefix).Returns("PREFIX"); + _dockerClient.SetupSequence(p => p.Images.ListImagesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(data) + .ReturnsAsync(default(List)); + + var results = await runner.GetApplicationVersions(CancellationToken.None); + + Assert.Equal(2, results.Count); + + results = await runner.GetApplicationVersions(CancellationToken.None); + Assert.Null(results); + + _dockerClient.Verify( + p => p.Images.ListImagesAsync(It.Is( + p => p.Filters.ContainsKey("reference") && p.Filters["reference"].ContainsKey("PREFIX")), It.IsAny()), Times.Exactly(2)); + } + + [Fact(DisplayName = "GetLatestApplicationVersion")] + public async Task GetLatestApplicationVersion() + { + var runner = new DockerRunner(_logger.Object, _configurationService.Object, _fileSystem.Object, _dockerClient.Object); + var id = Guid.NewGuid().ToString("N"); + var shaId = $"sha256:{id}"; + + _configurationService.SetupGet(p => p.Configurations.DockerImagePrefix).Returns("PREFIX"); + _dockerClient.SetupSequence(p => p.Images.ListImagesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new ImagesListResponse{ RepoTags = new List{ "123"}, ID = shaId, Created = DateTime.Now } + }) + .ReturnsAsync(default(List)); + + var result = await runner.GetLatestApplicationVersion(CancellationToken.None); + + Assert.Equal("123", result.Version); + Assert.Equal(shaId, result.Id); + Assert.Equal(id.Substring(0, 12), result.IdShort); + + result = await runner.GetLatestApplicationVersion(CancellationToken.None); + Assert.Null(result); + + _dockerClient.Verify( + p => p.Images.ListImagesAsync(It.Is( + p => p.Filters.ContainsKey("reference") && p.Filters["reference"].ContainsKey("PREFIX")), It.IsAny()), Times.Exactly(2)); + } + + [Fact(DisplayName = "IsApplicationRunning")] + public async Task IsApplicationRunning() + { + var image = new ImageVersion { Id = Guid.NewGuid().ToString("N") }; + var runner = new DockerRunner(_logger.Object, _configurationService.Object, _fileSystem.Object, _dockerClient.Object); + var id = Guid.NewGuid().ToString("N"); + _dockerClient.SetupSequence(p => p.Containers.ListContainersAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new ContainerListResponse{ ID = id} + }) + .ReturnsAsync(default(List)); + + var result = await runner.IsApplicationRunning(image, CancellationToken.None); + + Assert.True(result.IsRunning); + Assert.Equal(id, result.Id); + Assert.Equal(id.Substring(0, 12), result.IdShort); + + result = await runner.IsApplicationRunning(image, CancellationToken.None); + Assert.False(result.IsRunning); + + _dockerClient.Verify( + p => p.Containers.ListContainersAsync(It.Is( + p => p.Filters.ContainsKey("ancestor") && p.Filters["ancestor"].ContainsKey(image.Id)), It.IsAny()), Times.Exactly(2)); + } + + [Fact(DisplayName = "StartApplication")] + public async Task StartApplication() + { + var image = new ImageVersion { Id = Guid.NewGuid().ToString("N") }; + var runner = new DockerRunner(_logger.Object, _configurationService.Object, _fileSystem.Object, _dockerClient.Object); + var createContainerResponse = new CreateContainerResponse { ID = Guid.NewGuid().ToString("N"), Warnings = new List() }; + var createContainerResponseWithWarning = new CreateContainerResponse { ID = Guid.NewGuid().ToString("N"), Warnings = new List() { "warning1" } }; + + _configurationService.SetupGet(p => p.Configurations.DockerImagePrefix).Returns("PREFIX"); + _dockerClient.SetupSequence(p => p.Containers.CreateContainerAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(createContainerResponse) + .ReturnsAsync(createContainerResponseWithWarning); + + _dockerClient.SetupSequence(p => p.Containers.StartContainerAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true) + .ReturnsAsync(false); + + _fileSystem.Setup(p => p.Directory.Exists(It.IsAny())).Returns(true); + _configurationService.SetupGet(p => p.Configurations.DicomListeningPort).Returns(100); + _configurationService.SetupGet(p => p.Configurations.InformaticsGatewayServerPort).Returns(200); + _configurationService.SetupGet(p => p.Configurations.HostDatabaseStorageMount).Returns("/database"); + _configurationService.SetupGet(p => p.Configurations.HostDataStorageMount).Returns("/storage"); + _configurationService.SetupGet(p => p.Configurations.HostLogsStorageMount).Returns("/logs"); + _configurationService.SetupGet(p => p.Configurations.TempStoragePath).Returns("/tempdata"); + _configurationService.SetupGet(p => p.Configurations.LogStoragePath).Returns("/templogs"); + + Assert.True(await runner.StartApplication(image, CancellationToken.None)); + Assert.False(await runner.StartApplication(image, CancellationToken.None)); + + _dockerClient.Verify( + p => p.Containers.CreateContainerAsync(It.Is( + c => c.HostConfig.PortBindings.ContainsKey("100/tcp") && + c.ExposedPorts.ContainsKey("100/tcp") && + c.HostConfig.PortBindings.ContainsKey("200/tcp") && + c.ExposedPorts.ContainsKey("200/tcp") && + c.HostConfig.Mounts.Count(m => m.ReadOnly && m.Source == Common.ConfigFilePath && m.Target == Common.MountedConfigFilePath) == 1 && + c.HostConfig.Mounts.Count(m => !m.ReadOnly && m.Source == "/database" && m.Target == Common.MountedDatabasePath) == 1 && + c.HostConfig.Mounts.Count(m => !m.ReadOnly && m.Source == "/storage" && m.Target == "/tempdata") == 1 && + c.HostConfig.Mounts.Count(m => !m.ReadOnly && m.Source == "/logs" && m.Target == "/templogs") == 1), It.IsAny()), Times.Exactly(2)); + + _logger.VerifyLogging("Warnings: warning1", LogLevel.Warning, Times.Once()); + } + + [Fact(DisplayName = "StopApplication")] + public async Task StopApplication() + { + var runner = new DockerRunner(_logger.Object, _configurationService.Object, _fileSystem.Object, _dockerClient.Object); + var runnerState = new RunnerState { Id = Guid.NewGuid().ToString("N"), IsRunning = true }; + + _dockerClient.SetupSequence(p => p.Containers.StopContainerAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true) + .ReturnsAsync(false); + + Assert.True(await runner.StopApplication(runnerState, CancellationToken.None)); + Assert.False(await runner.StopApplication(runnerState, CancellationToken.None)); + + _dockerClient.Verify( + p => p.Containers.StopContainerAsync(runnerState.Id, It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + } +} diff --git a/src/CLI/Test/Monai.Deploy.InformaticsGateway.CLI.Test.csproj b/src/CLI/Test/Monai.Deploy.InformaticsGateway.CLI.Test.csproj index 33e32f2f3..1ef727048 100644 --- a/src/CLI/Test/Monai.Deploy.InformaticsGateway.CLI.Test.csproj +++ b/src/CLI/Test/Monai.Deploy.InformaticsGateway.CLI.Test.csproj @@ -15,6 +15,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/CLI/Test/RestartCommandTest.cs b/src/CLI/Test/RestartCommandTest.cs index 8c6692190..5f0af9cac 100644 --- a/src/CLI/Test/RestartCommandTest.cs +++ b/src/CLI/Test/RestartCommandTest.cs @@ -18,6 +18,7 @@ using System.CommandLine.Builder; using System.CommandLine.Hosting; using System.CommandLine.Parsing; +using System.Threading; using System.Threading.Tasks; using Xunit; @@ -67,7 +68,7 @@ public async Task RestartCommand_Cancelled() int exitCode = await _paser.InvokeAsync(command); Assert.Equal(ExitCodes.Restart_Cancelled, exitCode); - _controlService.Verify(p => p.Restart(), Times.Never()); + _controlService.Verify(p => p.Restart(It.IsAny()), Times.Never()); } [Fact(DisplayName = "restart comand - confirmed")] @@ -81,7 +82,7 @@ public async Task RestartCommand_Confirmed() int exitCode = await _paser.InvokeAsync(command); Assert.Equal(ExitCodes.Success, exitCode); - _controlService.Verify(p => p.Restart(), Times.Once()); + _controlService.Verify(p => p.Restart(It.IsAny()), Times.Once()); } [Fact(DisplayName = "restart comand -y")] @@ -94,7 +95,7 @@ public async Task RestartCommand_Auto() int exitCode = await _paser.InvokeAsync(command); Assert.Equal(ExitCodes.Success, exitCode); - _controlService.Verify(p => p.Restart(), Times.Once()); + _controlService.Verify(p => p.Restart(It.IsAny()), Times.Once()); } [Fact(DisplayName = "restart comand -y excception")] @@ -104,12 +105,12 @@ public async Task RestartCommand_Auto_Exception() var result = _paser.Parse(command); Assert.Equal(0, result.Errors.Count); - _controlService.Setup(p => p.Restart()).Throws(new Exception("error")); + _controlService.Setup(p => p.Restart(It.IsAny())).Throws(new Exception("error")); int exitCode = await _paser.InvokeAsync(command); Assert.Equal(ExitCodes.Restart_Error, exitCode); - _controlService.Verify(p => p.Restart(), Times.Once()); + _controlService.Verify(p => p.Restart(It.IsAny()), Times.Once()); } } } diff --git a/src/CLI/Test/SourceCommandTest.cs b/src/CLI/Test/SourceCommandTest.cs index 5c9542ef5..b3075a278 100644 --- a/src/CLI/Test/SourceCommandTest.cs +++ b/src/CLI/Test/SourceCommandTest.cs @@ -66,8 +66,10 @@ public SourceCommandTest() _paser = _commandLineBuilder.Build(); _loggerFactory.Setup(p => p.CreateLogger(It.IsAny())).Returns(_logger.Object); - _configurationService.Setup(p => p.ConfigurationExists()).Returns(true); - _configurationService.Setup(p => p.Load(It.IsAny())).Returns(new ConfigurationOptions { Endpoint = "http://test" }); + _configurationService.SetupGet(p => p.IsInitialized).Returns(true); + _configurationService.SetupGet(p => p.IsConfigExists).Returns(true); + _configurationService.SetupGet(p => p.Configurations.InformaticsGatewayServerUri).Returns(new Uri("http://test")); + _configurationService.SetupGet(p => p.Configurations.InformaticsGatewayServerEndpoint).Returns("http://test"); } [Fact(DisplayName = "src comand")] @@ -106,8 +108,6 @@ public async Task SrcAdd_Command() Assert.Equal(ExitCodes.Success, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify( p => p.DicomSources.Create( @@ -126,14 +126,28 @@ public async Task SrcAdd_Command_Exception() Assert.Equal(ExitCodes.SourceAe_ErrorCreate, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.DicomSources.Create(It.IsAny(), It.IsAny()), Times.Once()); _logger.VerifyLoggingMessageBeginsWith("Error creating DICOM source", LogLevel.Critical, Times.Once()); } + [Fact(DisplayName = "src add comand configuration exception")] + public async Task SrcAdd_Command_ConfigurationException() + { + var command = "src add -n MyName -a MyAET --apps App MyCoolApp TheApp"; + _configurationService.SetupGet(p => p.IsInitialized).Returns(false); + + int exitCode = await _paser.InvokeAsync(command); + + Assert.Equal(ExitCodes.Config_NotConfigured, exitCode); + + _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Never()); + _informaticsGatewayClient.Verify(p => p.MonaiScpAeTitle.List(It.IsAny()), Times.Never()); + + _logger.VerifyLoggingMessageBeginsWith("Please execute `testhost config init` to intialize Informatics Gateway.", LogLevel.Critical, Times.Once()); + } + [Fact(DisplayName = "src remove comand")] public async Task SrcRemove_Command() { @@ -150,8 +164,6 @@ public async Task SrcRemove_Command() Assert.Equal(ExitCodes.Success, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.DicomSources.Delete(It.Is(o => o.Equals(name)), It.IsAny()), Times.Once()); } @@ -167,14 +179,28 @@ public async Task SrcRemove_Command_Exception() Assert.Equal(ExitCodes.SourceAe_ErrorDelete, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.DicomSources.Delete(It.IsAny(), It.IsAny()), Times.Once()); _logger.VerifyLoggingMessageBeginsWith("Error deleting DICOM source", LogLevel.Critical, Times.Once()); } + [Fact(DisplayName = "src remove comand configuration exception")] + public async Task SrcRemove_Command_ConfigurationException() + { + var command = "src rm -n MyName"; + _configurationService.SetupGet(p => p.IsInitialized).Returns(false); + + int exitCode = await _paser.InvokeAsync(command); + + Assert.Equal(ExitCodes.Config_NotConfigured, exitCode); + + _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Never()); + _informaticsGatewayClient.Verify(p => p.MonaiScpAeTitle.List(It.IsAny()), Times.Never()); + + _logger.VerifyLoggingMessageBeginsWith("Please execute `testhost config init` to intialize Informatics Gateway.", LogLevel.Critical, Times.Once()); + } + [Fact(DisplayName = "src list comand")] public async Task SrcList_Command() { @@ -196,8 +222,6 @@ public async Task SrcList_Command() Assert.Equal(ExitCodes.Success, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.DicomSources.List(It.IsAny()), Times.Once()); } @@ -213,14 +237,28 @@ public async Task SrcList_Command_Exception() Assert.Equal(ExitCodes.SourceAe_ErrorList, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.DicomSources.List(It.IsAny()), Times.Once()); _logger.VerifyLoggingMessageBeginsWith("Error retrieving DICOM sources", LogLevel.Critical, Times.Once()); } + [Fact(DisplayName = "src list comand configuration exception")] + public async Task SrcList_Command_ConfigurationException() + { + var command = "src list"; + _configurationService.SetupGet(p => p.IsInitialized).Returns(false); + + int exitCode = await _paser.InvokeAsync(command); + + Assert.Equal(ExitCodes.Config_NotConfigured, exitCode); + + _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Never()); + _informaticsGatewayClient.Verify(p => p.MonaiScpAeTitle.List(It.IsAny()), Times.Never()); + + _logger.VerifyLoggingMessageBeginsWith("Please execute `testhost config init` to intialize Informatics Gateway.", LogLevel.Critical, Times.Once()); + } + [Fact(DisplayName = "src list comand empty")] public async Task SrcList_Command_Empty() { @@ -232,8 +270,6 @@ public async Task SrcList_Command_Empty() Assert.Equal(ExitCodes.Success, exitCode); - _configurationService.Verify(p => p.ConfigurationExists(), Times.Once()); - _configurationService.Verify(p => p.Load(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Once()); _informaticsGatewayClient.Verify(p => p.DicomSources.List(It.IsAny()), Times.Once()); diff --git a/src/CLI/Test/StartCommandTest.cs b/src/CLI/Test/StartCommandTest.cs index 76587c9cd..57508ddbb 100644 --- a/src/CLI/Test/StartCommandTest.cs +++ b/src/CLI/Test/StartCommandTest.cs @@ -18,6 +18,7 @@ using System.CommandLine.Builder; using System.CommandLine.Hosting; using System.CommandLine.Parsing; +using System.Threading; using System.Threading.Tasks; using Xunit; @@ -56,45 +57,46 @@ public StartCommandTest() _loggerFactory.Setup(p => p.CreateLogger(It.IsAny())).Returns(_logger.Object); } - [Fact(DisplayName = "start comand - cancelled")] - public async Task Start_Command_Cancelled() + [Fact(DisplayName = "start comand - confirmed")] + public async Task Start_Command_Confirmed() { var command = "start"; var result = _paser.Parse(command); Assert.Equal(0, result.Errors.Count); - _confirmationPrompt.Setup(p => p.ShowConfirmationPrompt(It.IsAny())).Returns(false); + _confirmationPrompt.Setup(p => p.ShowConfirmationPrompt(It.IsAny())).Returns(true); int exitCode = await _paser.InvokeAsync(command); - Assert.Equal(ExitCodes.Start_Cancelled, exitCode); + Assert.Equal(ExitCodes.Success, exitCode); - _controlService.Verify(p => p.Start(), Times.Never()); + _controlService.Verify(p => p.Start(It.IsAny()), Times.Once()); } - [Fact(DisplayName = "start comand - confirmed")] - public async Task Start_Command_Confirmed() + [Fact(DisplayName = "start comand -y")] + public async Task Start_Command_Auto() { - var command = "start"; + var command = "start -y"; var result = _paser.Parse(command); Assert.Equal(0, result.Errors.Count); - _confirmationPrompt.Setup(p => p.ShowConfirmationPrompt(It.IsAny())).Returns(true); int exitCode = await _paser.InvokeAsync(command); Assert.Equal(ExitCodes.Success, exitCode); - _controlService.Verify(p => p.Start(), Times.Once()); + _controlService.Verify(p => p.Start(It.IsAny()), Times.Once()); } - [Fact(DisplayName = "start comand -y")] - public async Task Start_Command_Auto() + [Fact(DisplayName = "start comand -y control excception")] + public async Task Start_Command_Auto_ControlException() { var command = "start -y"; var result = _paser.Parse(command); Assert.Equal(0, result.Errors.Count); + _controlService.Setup(p => p.Start(It.IsAny())).Throws(new ControlException(ExitCodes.Start_Error_ApplicationAlreadyRunning, "error")); + int exitCode = await _paser.InvokeAsync(command); - Assert.Equal(ExitCodes.Success, exitCode); + Assert.Equal(ExitCodes.Start_Error_ApplicationAlreadyRunning, exitCode); - _controlService.Verify(p => p.Start(), Times.Once()); + _controlService.Verify(p => p.Start(It.IsAny()), Times.Once()); } [Fact(DisplayName = "start comand -y excception")] @@ -104,12 +106,12 @@ public async Task Start_Command_Auto_Exception() var result = _paser.Parse(command); Assert.Equal(0, result.Errors.Count); - _controlService.Setup(p => p.Start()).Throws(new Exception("error")); + _controlService.Setup(p => p.Start(It.IsAny())).Throws(new Exception("error")); int exitCode = await _paser.InvokeAsync(command); Assert.Equal(ExitCodes.Start_Error, exitCode); - _controlService.Verify(p => p.Start(), Times.Once()); + _controlService.Verify(p => p.Start(It.IsAny()), Times.Once()); } } } diff --git a/src/CLI/Test/StatusCommandTest.cs b/src/CLI/Test/StatusCommandTest.cs index 1666953ac..3e5080c50 100644 --- a/src/CLI/Test/StatusCommandTest.cs +++ b/src/CLI/Test/StatusCommandTest.cs @@ -58,8 +58,10 @@ public StatusCommandTest() .AddCommand(new StatusCommand()); _paser = _commandLineBuilder.Build(); _loggerFactory.Setup(p => p.CreateLogger(It.IsAny())).Returns(_logger.Object); - _configurationService.Setup(p => p.ConfigurationExists()).Returns(true); - _configurationService.Setup(p => p.Load(It.IsAny())).Returns(new ConfigurationOptions { Endpoint = "http://test" }); + _configurationService.SetupGet(p => p.IsInitialized).Returns(true); + _configurationService.SetupGet(p => p.IsConfigExists).Returns(true); + _configurationService.SetupGet(p => p.Configurations.InformaticsGatewayServerUri).Returns(new Uri("http://test")); + _configurationService.SetupGet(p => p.Configurations.InformaticsGatewayServerEndpoint).Returns("http://test"); } [Fact(DisplayName = "status comand")] @@ -101,5 +103,21 @@ public async Task Status_Command_Exception() _logger.VerifyLoggingMessageBeginsWith("Error retrieving service status:", LogLevel.Critical, Times.Once()); } + + [Fact(DisplayName = "status comand configuration exception")] + public async Task SrcList_Command_ConfigurationException() + { + var command = "status"; + _configurationService.SetupGet(p => p.IsInitialized).Returns(false); + + int exitCode = await _paser.InvokeAsync(command); + + Assert.Equal(ExitCodes.Config_NotConfigured, exitCode); + + _informaticsGatewayClient.Verify(p => p.ConfigureServiceUris(It.IsAny()), Times.Never()); + _informaticsGatewayClient.Verify(p => p.MonaiScpAeTitle.List(It.IsAny()), Times.Never()); + + _logger.VerifyLoggingMessageBeginsWith("Please execute `testhost config init` to intialize Informatics Gateway.", LogLevel.Critical, Times.Once()); + } } } diff --git a/src/CLI/Test/StopCommandTest.cs b/src/CLI/Test/StopCommandTest.cs index cbb546877..64611e937 100644 --- a/src/CLI/Test/StopCommandTest.cs +++ b/src/CLI/Test/StopCommandTest.cs @@ -18,6 +18,7 @@ using System.CommandLine.Builder; using System.CommandLine.Hosting; using System.CommandLine.Parsing; +using System.Threading; using System.Threading.Tasks; using Xunit; @@ -67,7 +68,7 @@ public async Task StopCommand_Cancelled() int exitCode = await _paser.InvokeAsync(command); Assert.Equal(ExitCodes.Stop_Cancelled, exitCode); - _controlService.Verify(p => p.Stop(), Times.Never()); + _controlService.Verify(p => p.Stop(It.IsAny()), Times.Never()); } [Fact(DisplayName = "stop comand - confirmed")] @@ -81,7 +82,7 @@ public async Task StopCommand_Confirmed() int exitCode = await _paser.InvokeAsync(command); Assert.Equal(ExitCodes.Success, exitCode); - _controlService.Verify(p => p.Stop(), Times.Once()); + _controlService.Verify(p => p.Stop(It.IsAny()), Times.Once()); } [Fact(DisplayName = "stop comand -y")] @@ -94,7 +95,7 @@ public async Task StopCommand_Auto() int exitCode = await _paser.InvokeAsync(command); Assert.Equal(ExitCodes.Success, exitCode); - _controlService.Verify(p => p.Stop(), Times.Once()); + _controlService.Verify(p => p.Stop(It.IsAny()), Times.Once()); } [Fact(DisplayName = "stop comand -y excception")] @@ -104,12 +105,12 @@ public async Task StopCommand_Auto_Exception() var result = _paser.Parse(command); Assert.Equal(0, result.Errors.Count); - _controlService.Setup(p => p.Stop()).Throws(new Exception("error")); + _controlService.Setup(p => p.Stop(It.IsAny())).Throws(new Exception("error")); int exitCode = await _paser.InvokeAsync(command); Assert.Equal(ExitCodes.Stop_Error, exitCode); - _controlService.Verify(p => p.Stop(), Times.Once()); + _controlService.Verify(p => p.Stop(It.IsAny()), Times.Once()); } } } diff --git a/src/Client.Common/GuardExtensions.cs b/src/Client.Common/GuardExtensions.cs index 588670c95..337f96f8e 100644 --- a/src/Client.Common/GuardExtensions.cs +++ b/src/Client.Common/GuardExtensions.cs @@ -41,7 +41,6 @@ public static void MalformUri(this IGuardClause guardClause, string input, strin { Guard.Against.NullOrWhiteSpace(input, parameterName); Guard.Against.MalformUri(new Uri(input), parameterName); - } public static void OutOfRangePort(this IGuardClause guardClause, int port, string parameterName) diff --git a/src/Client.Common/ProblemDetails.cs b/src/Client.Common/ProblemDetails.cs index 319d7c2ac..9537344cd 100644 --- a/src/Client.Common/ProblemDetails.cs +++ b/src/Client.Common/ProblemDetails.cs @@ -23,4 +23,4 @@ public class ProblemDetails public int Status { get; set; } public string Detail { get; set; } } -} \ No newline at end of file +} diff --git a/src/Client.Common/ProblemException.cs b/src/Client.Common/ProblemException.cs index 09db7c5d5..62e10d462 100644 --- a/src/Client.Common/ProblemException.cs +++ b/src/Client.Common/ProblemException.cs @@ -47,4 +47,4 @@ public override string ToString() return $"HTTP Status: {_problemDetails.Status}. {_problemDetails.Detail}"; } } -} \ No newline at end of file +} diff --git a/src/Client.Common/Test/GuardExtensionsTest.cs b/src/Client.Common/Test/GuardExtensionsTest.cs index 9f7c2cb7b..a4316b990 100644 --- a/src/Client.Common/Test/GuardExtensionsTest.cs +++ b/src/Client.Common/Test/GuardExtensionsTest.cs @@ -50,11 +50,25 @@ public void MalformUri_NoneHttpHttps() [Fact(DisplayName = "MalformUri shall pass")] public void MalformUri_Valid() { - Uri input = new Uri("http://www.contoso.com/api/123"); + string input = "http://www.contoso.com/api/123"; Guard.Against.MalformUri(input, nameof(input)); Uri input2 = new Uri("https://www.contoso.com/api/123"); Guard.Against.MalformUri(input, nameof(input2)); } + + [Fact(DisplayName = "OutOfRangePort")] + public void OutOfRangePort() + { + int input = 100; + Guard.Against.OutOfRangePort(input, nameof(input)); + input = 65535; + Guard.Against.OutOfRangePort(input, nameof(input)); + + input = 0; + Assert.Throws(() => Guard.Against.OutOfRangePort(input, nameof(input))); + input = 65536; + Assert.Throws(() => Guard.Against.OutOfRangePort(input, nameof(input))); + } } } diff --git a/src/Client/Services/HealthService.cs b/src/Client/Services/HealthService.cs index 08787c546..b50458e90 100644 --- a/src/Client/Services/HealthService.cs +++ b/src/Client/Services/HealthService.cs @@ -12,7 +12,6 @@ using Ardalis.GuardClauses; using Microsoft.Extensions.Logging; using Monai.Deploy.InformaticsGateway.Api.Rest; -using System; using System.Net.Http; using System.Threading; using System.Threading.Tasks; diff --git a/src/Client/Services/InferenceService.cs b/src/Client/Services/InferenceService.cs index a34c09173..9adb13225 100644 --- a/src/Client/Services/InferenceService.cs +++ b/src/Client/Services/InferenceService.cs @@ -11,7 +11,6 @@ using Microsoft.Extensions.Logging; using Monai.Deploy.InformaticsGateway.Api.Rest; -using System; using System.Net.Http; using System.Net.Http.Formatting; using System.Threading; @@ -44,7 +43,7 @@ public async Task New(InferenceRequest request, Cancel await response.EnsureSuccessStatusCodeWithProblemDetails(_logger); return await response.Content.ReadAsAsync(cancellationToken); } - catch + catch { throw; } diff --git a/src/Client/Test/AeTitleServiceTest.cs b/src/Client/Test/AeTitleServiceTest.cs index 580096fa8..92aa58bde 100644 --- a/src/Client/Test/AeTitleServiceTest.cs +++ b/src/Client/Test/AeTitleServiceTest.cs @@ -13,7 +13,6 @@ using Monai.Deploy.InformaticsGateway.Api; using Monai.Deploy.InformaticsGateway.Client.Common; using Monai.Deploy.InformaticsGateway.Client.Services; -using Monai.Deploy.InformaticsGateway.Shared.Test; using Moq; using Newtonsoft.Json; using System.Collections.Generic; @@ -97,8 +96,6 @@ public async Task Create_ReturnsAProblem() var result = await Assert.ThrowsAsync(async () => await service.Create(aet, CancellationToken.None)); - _logger.VerifyLogging("Error sending request", LogLevel.Error, Times.Once()); - Assert.Equal($"HTTP Status: {problem.Status}. {problem.Detail}", result.Message); } @@ -164,8 +161,6 @@ public async Task Get_ReturnsAProblem() var result = await Assert.ThrowsAsync(async () => await service.Get(aet.Name, CancellationToken.None)); - _logger.VerifyLogging("Error sending request", LogLevel.Error, Times.Once()); - Assert.Equal($"HTTP Status: {problem.Status}. {problem.Detail}", result.Message); } @@ -231,8 +226,6 @@ public async Task Delete_ReturnsAProblem() var result = await Assert.ThrowsAsync(async () => await service.Delete(aet.Name, CancellationToken.None)); - _logger.VerifyLogging("Error sending request", LogLevel.Error, Times.Once()); - Assert.Equal($"HTTP Status: {problem.Status}. {problem.Detail}", result.Message); } @@ -307,8 +300,6 @@ public async Task List_ReturnsAProblem() var result = await Assert.ThrowsAsync(async () => await service.List(CancellationToken.None)); - _logger.VerifyLogging("Error sending request", LogLevel.Error, Times.Once()); - Assert.Equal($"HTTP Status: {problem.Status}. {problem.Detail}", result.Message); } } diff --git a/src/Client/Test/HealthServiceTest.cs b/src/Client/Test/HealthServiceTest.cs index 7834a75de..5c164115f 100644 --- a/src/Client/Test/HealthServiceTest.cs +++ b/src/Client/Test/HealthServiceTest.cs @@ -13,7 +13,6 @@ using Monai.Deploy.InformaticsGateway.Api.Rest; using Monai.Deploy.InformaticsGateway.Client.Common; using Monai.Deploy.InformaticsGateway.Client.Services; -using Monai.Deploy.InformaticsGateway.Shared.Test; using Moq; using Newtonsoft.Json; using System.Collections.Generic; @@ -88,8 +87,6 @@ public async Task Status_ReturnsAProblem() var result = await Assert.ThrowsAsync(async () => await service.Status(CancellationToken.None)); - _logger.VerifyLogging("Error sending request", LogLevel.Error, Times.Once()); - Assert.Equal($"HTTP Status: {problem.Status}. {problem.Detail}", result.Message); } @@ -137,8 +134,6 @@ public async Task Live_ReturnsAProblem() var result = await Assert.ThrowsAsync(async () => await service.Live(CancellationToken.None)); - _logger.VerifyLogging("Error sending request", LogLevel.Error, Times.Once()); - Assert.Equal($"HTTP Status: {problem.Status}. {problem.Detail}", result.Message); } @@ -186,8 +181,6 @@ public async Task Ready_ReturnsAProblem() var result = await Assert.ThrowsAsync(async () => await service.Ready(CancellationToken.None)); - _logger.VerifyLogging("Error sending request", LogLevel.Error, Times.Once()); - Assert.Equal($"HTTP Status: {problem.Status}. {problem.Detail}", result.Message); } } diff --git a/src/Client/Test/InferenceServiceTest.cs b/src/Client/Test/InferenceServiceTest.cs index e964c9b2b..835b2306e 100644 --- a/src/Client/Test/InferenceServiceTest.cs +++ b/src/Client/Test/InferenceServiceTest.cs @@ -13,7 +13,6 @@ using Monai.Deploy.InformaticsGateway.Api.Rest; using Monai.Deploy.InformaticsGateway.Client.Common; using Monai.Deploy.InformaticsGateway.Client.Services; -using Monai.Deploy.InformaticsGateway.Shared.Test; using Moq; using Newtonsoft.Json; using System; @@ -91,8 +90,6 @@ public async Task New_ReturnsAProblem() var result = await Assert.ThrowsAsync(async () => await service.New(inferenceRequest, CancellationToken.None)); - _logger.VerifyLogging("Error sending request", LogLevel.Error, Times.Once()); - Assert.Equal($"HTTP Status: {problem.Status}. {problem.Detail}", result.Message); } @@ -155,8 +152,6 @@ public async Task Status_ReturnsAProblem() var result = await Assert.ThrowsAsync(async () => await service.Status(inferenceRequest.TransactionId, CancellationToken.None)); - _logger.VerifyLogging("Error sending request", LogLevel.Error, Times.Once()); - Assert.Equal($"HTTP Status: {problem.Status}. {problem.Detail}", result.Message); } } diff --git a/src/Configuration/ConfigurationValidator.cs b/src/Configuration/ConfigurationValidator.cs index 3e87a8f1c..66313a1af 100644 --- a/src/Configuration/ConfigurationValidator.cs +++ b/src/Configuration/ConfigurationValidator.cs @@ -120,7 +120,7 @@ private bool IsWorkloadManagerValid(MonaiWorkloadManagerConfiguration configurat _validationErrors.Add("MONAI Workload Manager API REST endpoint is not configured: InformaticsGateway>workloadManager>restEndpoint."); valid = false; } - + if (string.IsNullOrWhiteSpace(configuration.GrpcEndpoint)) { _validationErrors.Add("MONAI Workload Manager API gRPC endpoint is not configured: InformaticsGateway>workloadManager>grpcEndpoint."); diff --git a/src/Configuration/Test/ConfigurationValidatorTest.cs b/src/Configuration/Test/ConfigurationValidatorTest.cs index 89ddf3375..611e9f485 100644 --- a/src/Configuration/Test/ConfigurationValidatorTest.cs +++ b/src/Configuration/Test/ConfigurationValidatorTest.cs @@ -69,7 +69,7 @@ public void ServicesWithMissingPlatformRestEndpoint() var valid = new ConfigurationValidator(logger.Object).Validate("", config); - var validationMessage = $"MONAI Workload Manager API REST endpoint is not configured: InformaticsGateway>workloadManager>endpoint."; + var validationMessage = $"MONAI Workload Manager API REST endpoint is not configured: InformaticsGateway>workloadManager>restEndpoint."; Assert.Equal(validationMessage, valid.FailureMessage); logger.VerifyLogging(validationMessage, LogLevel.Error, Times.Once()); } @@ -82,7 +82,7 @@ public void ServicesWithMissingPlatformGrpcEndpoint() var valid = new ConfigurationValidator(logger.Object).Validate("", config); - var validationMessage = $"MONAI Workload Manager API gRPC endpoint is not configured: InformaticsGateway>workloadManager>endpoint."; + var validationMessage = $"MONAI Workload Manager API gRPC endpoint is not configured: InformaticsGateway>workloadManager>grpcEndpoint."; Assert.Equal(validationMessage, valid.FailureMessage); logger.VerifyLogging(validationMessage, LogLevel.Error, Times.Once()); } diff --git a/src/Configuration/Test/ValidationExtensionsTest.cs b/src/Configuration/Test/ValidationExtensionsTest.cs index aa2530543..d0ce8c73f 100644 --- a/src/Configuration/Test/ValidationExtensionsTest.cs +++ b/src/Configuration/Test/ValidationExtensionsTest.cs @@ -11,7 +11,6 @@ using Monai.Deploy.InformaticsGateway.Api; using System; -using System.Collections.Generic; using Xunit; namespace Monai.Deploy.InformaticsGateway.Configuration.Test diff --git a/src/Database/FileStorageInfoConfiguration.cs b/src/Database/FileStorageInfoConfiguration.cs index bc5ae0252..255f1f7d6 100644 --- a/src/Database/FileStorageInfoConfiguration.cs +++ b/src/Database/FileStorageInfoConfiguration.cs @@ -28,7 +28,7 @@ public void Configure(EntityTypeBuilder builder) c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), c => c.ToArray()); var jsonSerializerSettings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - + builder.HasKey(j => j.Id); builder.Property(j => j.FilePath).IsRequired(); builder.Property(j => j.CorrelationId).IsRequired(); diff --git a/src/Database/Migrations/20210923225957_R1_Initialize.cs b/src/Database/Migrations/20210923225957_R1_Initialize.cs index 4c0ccbb36..aaf8e8aee 100644 --- a/src/Database/Migrations/20210923225957_R1_Initialize.cs +++ b/src/Database/Migrations/20210923225957_R1_Initialize.cs @@ -1,5 +1,5 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; +using System; namespace Monai.Deploy.InformaticsGateway.Database.Migrations { diff --git a/src/DicomWebClient/API/UnsupportedReturnTypeException.cs b/src/DicomWebClient/API/UnsupportedReturnTypeException.cs index 7aa9e1ac5..fbdc66e8f 100644 --- a/src/DicomWebClient/API/UnsupportedReturnTypeException.cs +++ b/src/DicomWebClient/API/UnsupportedReturnTypeException.cs @@ -16,7 +16,6 @@ */ using System; -using System.Runtime.Serialization; namespace Monai.Deploy.InformaticsGateway.DicomWeb.Client.API { diff --git a/src/InformaticsGateway/Logging/FileLoggingTextFormatter.cs b/src/InformaticsGateway/Logging/FileLoggingTextFormatter.cs index 71b394e29..ea810553f 100644 --- a/src/InformaticsGateway/Logging/FileLoggingTextFormatter.cs +++ b/src/InformaticsGateway/Logging/FileLoggingTextFormatter.cs @@ -51,13 +51,13 @@ protected override void AppendMessage(StringBuilder sb, string message) } public override void BuildEntryText( - StringBuilder sb, - string categoryName, - LogLevel logLevel, - EventId eventId, - string message, + StringBuilder sb, + string categoryName, + LogLevel logLevel, + EventId eventId, + string message, Exception exception, - IExternalScopeProvider scopeProvider, + IExternalScopeProvider scopeProvider, DateTimeOffset timestamp) { AppendTimestamp(sb, timestamp); @@ -78,4 +78,4 @@ public override void BuildEntryText( AppendException(sb, exception); } } -} \ No newline at end of file +} diff --git a/src/InformaticsGateway/Repositories/IMonaiServiceLocator.cs b/src/InformaticsGateway/Repositories/IMonaiServiceLocator.cs index 125bc4dc1..a93e78f1d 100644 --- a/src/InformaticsGateway/Repositories/IMonaiServiceLocator.cs +++ b/src/InformaticsGateway/Repositories/IMonaiServiceLocator.cs @@ -21,4 +21,4 @@ public interface IMonaiServiceLocator Dictionary GetServiceStatus(); } -} \ No newline at end of file +} diff --git a/src/InformaticsGateway/Services/Http/HealthController.cs b/src/InformaticsGateway/Services/Http/HealthController.cs index c09e61c42..4079a8b51 100644 --- a/src/InformaticsGateway/Services/Http/HealthController.cs +++ b/src/InformaticsGateway/Services/Http/HealthController.cs @@ -29,13 +29,10 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Monai.Deploy.InformaticsGateway.Api.Rest; -using Monai.Deploy.InformaticsGateway.Configuration; using Monai.Deploy.InformaticsGateway.Repositories; using Monai.Deploy.InformaticsGateway.Services.Scp; using System; -using System.Collections.Generic; using System.Linq; using System.Net; diff --git a/src/InformaticsGateway/Services/Scp/FileStoredNotificationQueue.cs b/src/InformaticsGateway/Services/Scp/FileStoredNotificationQueue.cs index 333dfdbd2..91f7c83b7 100644 --- a/src/InformaticsGateway/Services/Scp/FileStoredNotificationQueue.cs +++ b/src/InformaticsGateway/Services/Scp/FileStoredNotificationQueue.cs @@ -17,7 +17,6 @@ using Monai.Deploy.InformaticsGateway.Repositories; using System; using System.Collections.Concurrent; -using System.Linq; using System.Threading; using System.Threading.Tasks; diff --git a/src/InformaticsGateway/Test/Logging/FileLoggingTextFormatterTest.cs b/src/InformaticsGateway/Test/Logging/FileLoggingTextFormatterTest.cs index f2297375e..093480f3f 100644 --- a/src/InformaticsGateway/Test/Logging/FileLoggingTextFormatterTest.cs +++ b/src/InformaticsGateway/Test/Logging/FileLoggingTextFormatterTest.cs @@ -1,12 +1,8 @@ using Microsoft.Extensions.Logging; using Monai.Deploy.InformaticsGateway.Logging; -using Moq; using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; using System.Text; -using System.Threading.Tasks; using Xunit; namespace Monai.Deploy.InformaticsGateway.Test.Logging @@ -28,10 +24,9 @@ public void BuildEntryText() var formatter = FileLoggingTextFormatter.Default; formatter.BuildEntryText( - sb, cateogry, LogLevel.Information, eventId, message, + sb, cateogry, LogLevel.Information, eventId, message, exception, scopeProvider, timestamp); - var result = sb.ToString(); Assert.Contains(timestamp.ToLocalTime().ToString("o", CultureInfo.InvariantCulture), result); Assert.Contains($"info: {cateogry}[{eventId.Id}] [StateA] [StateB] => {message}", result); diff --git a/src/InformaticsGateway/Test/Repositories/MonaiServiceLocatorTest.cs b/src/InformaticsGateway/Test/Repositories/MonaiServiceLocatorTest.cs index 3d65fb421..398b3726e 100644 --- a/src/InformaticsGateway/Test/Repositories/MonaiServiceLocatorTest.cs +++ b/src/InformaticsGateway/Test/Repositories/MonaiServiceLocatorTest.cs @@ -1,13 +1,9 @@ using Monai.Deploy.InformaticsGateway.Api.Rest; using Monai.Deploy.InformaticsGateway.Repositories; using Monai.Deploy.InformaticsGateway.Services.Common; -using Monai.Deploy.InformaticsGateway.Services.Connectors; using Moq; using System; -using System.Collections.Generic; using System.Linq; -using System.Text; -using System.Threading.Tasks; using Xunit; namespace Monai.Deploy.InformaticsGateway.Test.Repositories diff --git a/src/InformaticsGateway/Test/Services/Connectors/WorkloadManagerNotificationServiceTest.cs b/src/InformaticsGateway/Test/Services/Connectors/WorkloadManagerNotificationServiceTest.cs index 1a36fa16c..ec1caf4f9 100644 --- a/src/InformaticsGateway/Test/Services/Connectors/WorkloadManagerNotificationServiceTest.cs +++ b/src/InformaticsGateway/Test/Services/Connectors/WorkloadManagerNotificationServiceTest.cs @@ -80,7 +80,7 @@ public async Task ShallUploadFileAndQueueForDelete() _workloadManagerApi.Setup(p => p.Upload(It.IsAny(), It.IsAny())); _cleanupQueue.Setup(p => p.Queue(It.IsAny())); _taskQueue.Setup(p => p.Dequeue(It.IsAny())) - .Returns(() => + .ReturnsAsync(() => { if (stack.Count > 0) { @@ -132,7 +132,7 @@ public async Task ShallRequeueFileUponUploadFailure() _cleanupQueue.Setup(p => p.Queue(It.IsAny())); _taskQueue.Setup(p => p.Queue(It.IsAny())); _taskQueue.Setup(p => p.Dequeue(It.IsAny())) - .Returns(() => + .ReturnsAsync(() => { if (stack.Count > 0) { @@ -184,7 +184,7 @@ public async Task ShallQueueForDeleteIfFileIsMissing() _workloadManagerApi.Setup(p => p.Upload(It.IsAny(), It.IsAny())); _cleanupQueue.Setup(p => p.Queue(It.IsAny())); _taskQueue.Setup(p => p.Dequeue(It.IsAny())) - .Returns(() => + .ReturnsAsync(() => { if (stack.Count > 0) { diff --git a/src/InformaticsGateway/Test/Services/Http/ExceptionHandlingMiddlewareTest.cs b/src/InformaticsGateway/Test/Services/Http/ExceptionHandlingMiddlewareTest.cs index 83b3cfed7..1feac18df 100644 --- a/src/InformaticsGateway/Test/Services/Http/ExceptionHandlingMiddlewareTest.cs +++ b/src/InformaticsGateway/Test/Services/Http/ExceptionHandlingMiddlewareTest.cs @@ -5,10 +5,7 @@ using Moq; using Newtonsoft.Json; using System; -using System.Collections.Generic; using System.IO; -using System.Linq; -using System.Text; using System.Threading.Tasks; using Xunit; @@ -42,7 +39,7 @@ public async Task InvokeAsync_ProceedsWithNextRequest() Assert.Empty(body); } - [Fact(DisplayName ="InvokeAsync - Handles Exception")] + [Fact(DisplayName = "InvokeAsync - Handles Exception")] public async Task InvokeAsync_HandlesExcption() { var context = new DefaultHttpContext(); diff --git a/src/InformaticsGateway/Test/Services/Http/HealthControllerTest.cs b/src/InformaticsGateway/Test/Services/Http/HealthControllerTest.cs index 785bbd79a..cd376ce8f 100644 --- a/src/InformaticsGateway/Test/Services/Http/HealthControllerTest.cs +++ b/src/InformaticsGateway/Test/Services/Http/HealthControllerTest.cs @@ -13,11 +13,8 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Monai.Deploy.InformaticsGateway.Api.Rest; -using Monai.Deploy.InformaticsGateway.Configuration; using Monai.Deploy.InformaticsGateway.Repositories; -using Monai.Deploy.InformaticsGateway.Services.Common; using Monai.Deploy.InformaticsGateway.Services.Http; using Moq; using System; diff --git a/src/InformaticsGateway/Test/Services/Scp/MonaiAeChangedNotificationServiceTest.cs b/src/InformaticsGateway/Test/Services/Scp/MonaiAeChangedNotificationServiceTest.cs index 9503ff874..a156b00bd 100644 --- a/src/InformaticsGateway/Test/Services/Scp/MonaiAeChangedNotificationServiceTest.cs +++ b/src/InformaticsGateway/Test/Services/Scp/MonaiAeChangedNotificationServiceTest.cs @@ -12,7 +12,6 @@ using Microsoft.Extensions.Logging; using Monai.Deploy.InformaticsGateway.Api; using Monai.Deploy.InformaticsGateway.Services.Scp; -using Monai.Deploy.InformaticsGateway.Shared.Test; using Moq; using System; using xRetry; diff --git a/src/InformaticsGateway/appsettings.json b/src/InformaticsGateway/appsettings.json index c00a8d577..7552855bb 100644 --- a/src/InformaticsGateway/appsettings.json +++ b/src/InformaticsGateway/appsettings.json @@ -76,4 +76,4 @@ "InformaticsGatewayServerEndpoint": "http://localhost:5000", "DockerImagePrefix": "monai/informatics-gateway" } -} \ No newline at end of file +}