diff --git a/CHANGELOG.md b/CHANGELOG.md index 59e2d6a..134cebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to UGS CLI will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [1.6.0] - 2024-07-18 + +### Changed +- [Game Server Hosting] Mark options: `--speed`, `--cores` and `--memory` for CREATE and UPDATE of `gsh build configuration` as deprecated to allow for backwards compatibility. + New usage should be set on the fleet using [server density configuration](https://docs.unity.com/ugs/en-us/manual/game-server-hosting/manual/guides/configure-server-density) + +### Fixed +- [Remote Config] Fixed import and export on an empty environment. +- [Cloud Content Delivery] Now normalizing the path to always use forward slashes even on windows platform. + +### Added +- [Game Server Hosting] Added support for Google Cloud Storage (GCS) as a source for Builds and Build Configurations. ## [1.5.0] - 2024-06-12 @@ -97,7 +109,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Added Deployment Definitions to the Deploy and Fetch commands. - Added analytics related to command usage and options used. - Deploy/Fetch return an array in a table-like format with -json flag enabled. -- Leaderboards now supports the `ugs deploy` and `ugs fetch` commands at the root +- Leaderboards now supports the `ugs deploy` and `ugs fetch` commands at the root - Deploy sends file configurations into the service - Fetch updates local files based on service configuration - Leaderboards now supports `new-file`, to create an empty file for leaderboards diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessClientTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessClientTests.cs index 97e3289..9d81e4a 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessClientTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Deploy/ProjectAccessClientTests.cs @@ -40,7 +40,7 @@ public void InitializeChangeProperties() Assert.That(m_ProjectAccessClient.CancellationToken, Is.EqualTo(CancellationToken.None)); }); CancellationToken cancellationToken = new(true); - m_ProjectAccessClient!.Initialize( k_TestEnvironmentId, k_TestProjectId, cancellationToken); + m_ProjectAccessClient!.Initialize(k_TestEnvironmentId, k_TestProjectId, cancellationToken); Assert.Multiple(() => { Assert.That(m_ProjectAccessClient.ProjectId, Is.SameAs(k_TestProjectId)); @@ -68,7 +68,7 @@ public async Task GetAsyncForPolicyWithNoStatements() [Test] public async Task GetAsyncForPolicyWithStatements() { - var policy = TestMocks.GetPolicy(new List(){TestMocks.GetProjectStatement()}); + var policy = TestMocks.GetPolicy(new List() { TestMocks.GetProjectStatement() }); m_MockAccessService.Setup(r => r.GetPolicyAsync(k_TestProjectId, k_TestEnvironmentId, CancellationToken.None)).ReturnsAsync(policy); var authoringStatements = new List() @@ -85,8 +85,8 @@ public async Task GetAsyncForPolicyWithStatements() [Test] public async Task UpsertAsyncSuccessfully() { - var authoringStatements = new List(){TestMocks.GetAuthoringStatement("sid-1"), TestMocks.GetAuthoringStatement("sid-2")}; - var policy = TestMocks.GetPolicy(new List(){TestMocks.GetProjectStatement("sid-1"), TestMocks.GetProjectStatement("sid-2")}); + var authoringStatements = new List() { TestMocks.GetAuthoringStatement("sid-1"), TestMocks.GetAuthoringStatement("sid-2") }; + var policy = TestMocks.GetPolicy(new List() { TestMocks.GetProjectStatement("sid-1"), TestMocks.GetProjectStatement("sid-2") }); await m_ProjectAccessClient!.UpsertAsync(authoringStatements); m_MockAccessService.Verify(ac => ac.UpsertProjectAccessCaCAsync(k_TestProjectId, k_TestEnvironmentId, policy, CancellationToken.None), Times.Once); } @@ -94,8 +94,8 @@ public async Task UpsertAsyncSuccessfully() [Test] public async Task DeleteAsyncSuccessfully() { - var authoringStatements = new List(){TestMocks.GetAuthoringStatement("sid-1"), TestMocks.GetAuthoringStatement("sid-2")}; - var deleteOptions = TestMocks.GetDeleteOptions(new List(){"sid-1", "sid-2"}); + var authoringStatements = new List() { TestMocks.GetAuthoringStatement("sid-1"), TestMocks.GetAuthoringStatement("sid-2") }; + var deleteOptions = TestMocks.GetDeleteOptions(new List() { "sid-1", "sid-2" }); await m_ProjectAccessClient!.DeleteAsync(authoringStatements); m_MockAccessService.Verify(ac => ac.DeleteProjectAccessCaCAsync(k_TestProjectId, k_TestEnvironmentId, deleteOptions, CancellationToken.None), Times.Once); } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Service/AccessServiceTests.cs b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Service/AccessServiceTests.cs index f20d78a..2334f9c 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Service/AccessServiceTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Access.UnitTest/Service/AccessServiceTests.cs @@ -377,7 +377,7 @@ public void UpsertProjectAccessCaCAsync_Invalid_ApiThrowsError() [Test] public async Task DeleteProjectAccessCaCAsync_Valid() { - var statementIDs = new List(){"statement-1"}; + var statementIDs = new List() { "statement-1" }; m_ProjectPolicyApi.Setup(a => a.DeletePolicyStatementsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), CancellationToken.None)); await m_AccessService!.DeleteProjectAccessCaCAsync(TestValues.ValidProjectId, TestValues.ValidEnvironmentId, TestMocks.GetDeleteOptions(statementIDs), diff --git a/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/ProjectAccessClient.cs b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/ProjectAccessClient.cs index 0b704ed..78c49aa 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/ProjectAccessClient.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Access/Deploy/ProjectAccessClient.cs @@ -44,7 +44,7 @@ public async Task> GetAsync() { var policy = await m_Service.GetPolicyAsync(ProjectId, EnvironmentId, CancellationToken); - return (policy.Statements == null || policy.Statements?.Count == 0) ? new List() : GetAuthoringStatementsFromPolicy(policy); + return (policy.Statements == null || policy.Statements?.Count == 0) ? new List() : GetAuthoringStatementsFromPolicy(policy); } public async Task UpsertAsync(IReadOnlyList authoringStatements) diff --git a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Export/BaseExporter.cs b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Export/BaseExporter.cs index 55ee3ae..18cc695 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Authoring/Export/BaseExporter.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Authoring/Export/BaseExporter.cs @@ -52,6 +52,12 @@ public async Task ExportAsync(ExportInput input, CancellationToken cancellationT var configs = await ListConfigsAsync(projectId, environmentId, cancellationToken); var state = new ExportState(configs.Select(ToImportExportEntry).ToList()); + if (!configs.Any()) + { + m_Logger.LogInformation("No content to export."); + return; + } + if (!input.DryRun) { await ExportToZipAsync(input.OutputDirectory, fileName, state, cancellationToken); diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery.UnitTest/Utils/CcdUtilsTests.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery.UnitTest/Utils/CcdUtilsTests.cs index 24dc199..9f277ab 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery.UnitTest/Utils/CcdUtilsTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery.UnitTest/Utils/CcdUtilsTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Runtime.InteropServices; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NUnit.Framework; @@ -103,4 +104,46 @@ public void ValidateParseMetadata_InvalidJson_ThrowsException() Assert.Throws(() => CcdUtils.ParseMetadata("[invalid]")); } + [Test] + public void AdjustPathForPlatform_ValidInput_ReturnsAdjustedPath() + { + const string input = "images/image.jpg"; + const string expectedBackwardSlashes = "images\\image.jpg"; + const string expectedForwardSlashes = "images/image.jpg"; + + string result = CcdUtils.AdjustPathForPlatform(input); + + if (Path.DirectorySeparatorChar == '\\') + { + Assert.That(result, Is.EqualTo(expectedBackwardSlashes), "The adjusted path should use backward slashes."); + } + else + { + Assert.That(result, Is.EqualTo(expectedForwardSlashes), "The adjusted path should use forward slashes."); + } + + } + + [Test] + public void AdjustPathForWindowsUsers_EmptyOrNullInput_ReturnsSame() + { + Assert.That(CcdUtils.AdjustPathForPlatform(""), Is.EqualTo(""), "The result should be an empty string when the input is an empty string."); + } + + [Test] + public void ConvertPathToForwardSlashes_ValidInput_ReturnsConvertedPath() + { + const string input = "images\\image.jpg"; + const string expected = "images/image.jpg"; + + var result = CcdUtils.ConvertPathToForwardSlashes(input); + Assert.That(result, Is.EqualTo(expected), "The converted path should match the expected result."); + } + + [Test] + public void ConvertPathToForwardSlashes_EmptyOrNullInput_ReturnsSame() + { + Assert.That(CcdUtils.ConvertPathToForwardSlashes(""), Is.EqualTo(""), "The result should be an empty string when the input is an empty string."); + } + } diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Service/EntryClient.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Service/EntryClient.cs index 6990a67..e0290d6 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Service/EntryClient.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Service/EntryClient.cs @@ -177,6 +177,9 @@ await m_EntriesApi.DeleteEntryEnvAsync( m_ContentDeliveryValidator.ValidatePath(localPath); m_ContentDeliveryValidator.ValidatePath(remotePath); + localPath = CcdUtils.AdjustPathForPlatform(localPath); + remotePath = CcdUtils.ConvertPathToForwardSlashes(remotePath); + await using var filestream = m_FileSystem.File.OpenRead(localPath); var contentSize = m_UploadContentClient.GetContentSize(filestream); var contentType = m_UploadContentClient.GetContentType(localPath); @@ -192,6 +195,7 @@ await m_EntriesApi.DeleteEntryEnvAsync( labels, entryMetadata, true); + var entry = await m_EntriesApi.CreateOrUpdateEntryByPathEnvAsync( environmentId, bucketId, diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Service/SynchronizationService.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Service/SynchronizationService.cs index 47b089b..4d173fc 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Service/SynchronizationService.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Service/SynchronizationService.cs @@ -2,6 +2,7 @@ using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using Unity.Services.Cli.CloudContentDelivery.Model; +using Unity.Services.Cli.CloudContentDelivery.Utils; using Unity.Services.Cli.Common.Exceptions; using Unity.Services.Cli.ServiceAccountAuthentication; using Unity.Services.Cli.ServiceAccountAuthentication.Token; @@ -192,11 +193,12 @@ public SyncResult CalculateDifference( foreach (var remoteEntry in remoteEntries) { - var path = remoteEntry.Path; + var path = CcdUtils.ConvertPathToForwardSlashes(remoteEntry.Path); if (localFiles.Contains(path)) { var filePath = Path.Combine(localFolder, path); + filePath = CcdUtils.AdjustPathForPlatform(filePath); try { using (var filestream = m_FileSystem.File.OpenRead(filePath)) @@ -268,6 +270,7 @@ public SyncResult CalculateDifference( foreach (var path in localFiles) { var filePath = Path.Combine(localFolder, path); + filePath = CcdUtils.AdjustPathForPlatform(filePath); using var filestream = m_FileSystem.File.OpenRead(filePath); var contentSize = m_UploadContentClient.GetContentSize(filestream); var contentType = m_UploadContentClient.GetContentType(filePath); @@ -452,6 +455,7 @@ await ThrottledRetryPolicyAsync( try { var filePath = Path.Combine(localFolder, createdEntry.Path); + filePath = CcdUtils.AdjustPathForPlatform(filePath); await using var filestream = m_FileSystem.File.OpenRead(filePath); var response = await m_UploadContentClient.UploadContentToCcd( createdEntry.SignedUrl, @@ -631,7 +635,7 @@ public HashSet GetFilesFromDir( } return new HashSet( - files.Select(filePath => m_FileSystem.Path.GetRelativePath(directoryPath, filePath))); + files.Select(filePath => CcdUtils.ConvertPathToForwardSlashes(m_FileSystem.Path.GetRelativePath(directoryPath, filePath)))); } public async Task AuthorizeServiceAsync(CancellationToken cancellationToken = default) diff --git a/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Utils/CcdUtils.cs b/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Utils/CcdUtils.cs index 35c8bb5..cb4ee76 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Utils/CcdUtils.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.CloudContentDelivery/Utils/CcdUtils.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using Newtonsoft.Json; using Unity.Services.Cli.Common.Exceptions; using Unity.Services.Gateway.ContentDeliveryManagementApiV1.Generated.Client; @@ -47,4 +48,25 @@ 2. Include the -b option when running the command. } } + + public static string AdjustPathForPlatform(string path) + { + if (string.IsNullOrEmpty(path) || Path.DirectorySeparatorChar == '/') + { + return path; + } + return path.Replace('/', Path.DirectorySeparatorChar); + } + + public static string ConvertPathToForwardSlashes(string path) + { + if (string.IsNullOrEmpty(path)) + { + return path; + } + + path = path.Replace("\\", "/"); + + return path; + } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.Common/Unity.Services.Cli.Common.csproj b/Unity.Services.Cli/Unity.Services.Cli.Common/Unity.Services.Cli.Common.csproj index 0c9f269..34f43fb 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Common/Unity.Services.Cli.Common.csproj +++ b/Unity.Services.Cli/Unity.Services.Cli.Common/Unity.Services.Cli.Common.csproj @@ -25,7 +25,7 @@ - + diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/GameServerHostingUnitTestsConstants.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/GameServerHostingUnitTestsConstants.cs index 3d531ac..d6cfbd5 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/GameServerHostingUnitTestsConstants.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/GameServerHostingUnitTestsConstants.cs @@ -23,6 +23,7 @@ public static class GameServerHostingUnitTestsConstants public const long ValidBuildIdBucket = 201; public const long ValidBuildIdContainer = 202; public const long ValidBuildIdFileUpload = 203; + public const long ValidBuildIdGcs = 204; public const long SyncingBuildId = 333; // Filenames diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildConfigurationCreateHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildConfigurationCreateHandlerTests.cs index aecb1e3..29787df 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildConfigurationCreateHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildConfigurationCreateHandlerTests.cs @@ -115,25 +115,13 @@ await BuildConfigurationCreateHandler.BuildConfigurationCreateAsync( ValidBuildConfigurationId, ValidBuildConfigurationCommandLine, ValidBuildConfigurationConfiguration, - null, - 100, - ValidBuildConfigurationName, - ValidBuildConfigurationQueryType, 100, - true, - TestName = "Missing Cores")] - [TestCase( - ValidBuildConfigurationBinaryPath, - ValidBuildConfigurationId, - ValidBuildConfigurationCommandLine, - ValidBuildConfigurationConfiguration, 100, null, - ValidBuildConfigurationName, ValidBuildConfigurationQueryType, 100, true, - TestName = "Missing Memory")] + TestName = "Missing Name")] [TestCase( ValidBuildConfigurationBinaryPath, ValidBuildConfigurationId, @@ -141,37 +129,101 @@ await BuildConfigurationCreateHandler.BuildConfigurationCreateAsync( ValidBuildConfigurationConfiguration, 100, 100, + ValidBuildConfigurationName, null, - ValidBuildConfigurationQueryType, 100, true, - TestName = "Missing Name")] + TestName = "Missing QueryType")] + public Task BuildConfigurationCreateAsync_NullInputThrowsException( + string? binaryPath, + long? buildId, + string? commandLine, + string? configuration, + long? cores, + long? memory, + string? name, + string? queryType, + long? speed, + bool? readiness) + { + BuildConfigurationCreateInput input = new() + { + CloudProjectId = ValidProjectId, + TargetEnvironmentName = ValidEnvironmentName, + BinaryPath = binaryPath, + BuildId = buildId, + CommandLine = commandLine, + Configuration = configuration == null + ? null + : new List + { + configuration + }, + Cores = cores, + Memory = memory, + Name = name, + QueryType = queryType, + Speed = speed, + Readiness = readiness + }; + + Assert.ThrowsAsync( + () => + BuildConfigurationCreateHandler.BuildConfigurationCreateAsync( + input, + MockUnityEnvironment.Object, + GameServerHostingService!, + MockLogger!.Object, + CancellationToken.None + ) + ); + + TestsHelper.VerifyLoggerWasCalled( + MockLogger!, + LogLevel.Critical, + LoggerExtension.ResultEventId, + Times.Never); + return Task.CompletedTask; + } + + [TestCase( + ValidBuildConfigurationBinaryPath, + ValidBuildConfigurationId, + ValidBuildConfigurationCommandLine, + ValidBuildConfigurationConfiguration, + 100, + 100, + ValidBuildConfigurationName, + ValidBuildConfigurationQueryType, + null, + true, + TestName = "Missing Speed"), + ] [TestCase( ValidBuildConfigurationBinaryPath, ValidBuildConfigurationId, ValidBuildConfigurationCommandLine, ValidBuildConfigurationConfiguration, - 100, + null, 100, ValidBuildConfigurationName, - null, + ValidBuildConfigurationQueryType, 100, true, - TestName = "Missing QueryType")] + TestName = "Missing Cores")] [TestCase( ValidBuildConfigurationBinaryPath, ValidBuildConfigurationId, ValidBuildConfigurationCommandLine, ValidBuildConfigurationConfiguration, 100, - 100, + null, ValidBuildConfigurationName, ValidBuildConfigurationQueryType, - null, + 100, true, - TestName = "Missing Speed"), - ] - public Task BuildConfigurationCreateAsync_NullInputThrowsException( + TestName = "Missing Memory")] + public Task BuildConfigurationCreateAsync_InvalidLegacyUsageInputThrowsException( string? binaryPath, long? buildId, string? commandLine, @@ -204,7 +256,7 @@ public Task BuildConfigurationCreateAsync_NullInputThrowsException( Readiness = readiness }; - Assert.ThrowsAsync( + Assert.ThrowsAsync( () => BuildConfigurationCreateHandler.BuildConfigurationCreateAsync( input, diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildConfigurationUpdateHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildConfigurationUpdateHandlerTests.cs index f9296c0..5a2f1fa 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildConfigurationUpdateHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildConfigurationUpdateHandlerTests.cs @@ -63,7 +63,10 @@ public Task BuildConfigurationUpdateAsync_InvalidConfigurationInputThrowsExcepti CloudProjectId = ValidProjectId, TargetEnvironmentName = ValidEnvironmentName, BuildId = ValidBuildConfigurationBuildId, - Configuration = new List { configuration! }, + Configuration = new List + { + configuration! + }, }; Assert.ThrowsAsync( @@ -84,4 +87,86 @@ public Task BuildConfigurationUpdateAsync_InvalidConfigurationInputThrowsExcepti Times.Never); return Task.CompletedTask; } + + [TestCase( + 10, + null, + 10, + TestName = "Cores empty")] + [TestCase( + 10, + 10, + null, + TestName = "Memory empty")] + [TestCase( + null, + 10, + 10, + TestName = "Speed empty")] + public Task BuildConfigurationUpdateAsync_InvalidLegacyInputUsageException(long? speed, long? cores, long? memory) + { + BuildConfigurationUpdateInput input = new() + { + CloudProjectId = ValidProjectId, + TargetEnvironmentName = ValidEnvironmentName, + BuildId = ValidBuildConfigurationBuildId, + Configuration = new List + { + "foo:bar" + }, + Speed = speed, + Cores = cores, + Memory = memory, + }; + + Assert.ThrowsAsync( + () => + BuildConfigurationUpdateHandler.BuildConfigurationUpdateAsync( + input, + MockUnityEnvironment.Object, + GameServerHostingService!, + MockLogger!.Object, + CancellationToken.None + ) + ); + + TestsHelper.VerifyLoggerWasCalled( + MockLogger!, + LogLevel.Critical, + LoggerExtension.ResultEventId, + Times.Never); + return Task.CompletedTask; + } + + [Test] + public async Task BuildConfigurationUpdateAsync_DepricatetionWarning() + { + BuildConfigurationUpdateInput input = new() + { + CloudProjectId = ValidProjectId, + TargetEnvironmentName = ValidEnvironmentName, + BuildId = ValidBuildConfigurationBuildId, + Configuration = new List + { + "foo:bar" + }, + Speed = 100, + Memory = 100, + Cores = 100, + }; + + await BuildConfigurationUpdateHandler.BuildConfigurationUpdateAsync( + input, + MockUnityEnvironment.Object, + GameServerHostingService!, + MockLogger!.Object, + CancellationToken.None + ); + + TestsHelper.VerifyLoggerWasCalled( + MockLogger!, + LogLevel.Warning, + null, + Times.Once); + } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildCreateVersionBucketHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildCreateVersionBucketHandlerTests.cs index e6eab30..ea694cd 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildCreateVersionBucketHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildCreateVersionBucketHandlerTests.cs @@ -1,3 +1,4 @@ +using System.IO.Abstractions; using Microsoft.Extensions.Logging; using Moq; using Unity.Services.Cli.Common.Exceptions; @@ -159,6 +160,6 @@ Type expectedExceptionType FileDirectory = fileDirectory, }; - Assert.Throws(expectedExceptionType, () => BuildCreateVersionHandler.ValidateBucketInput(input)); + Assert.Throws(expectedExceptionType, () => BuildCreateVersionHandler.ValidateS3Input(input)); } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildCreateVersionGCSHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildCreateVersionGCSHandlerTests.cs new file mode 100644 index 0000000..8301c58 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Handlers/BuildCreateVersionGCSHandlerTests.cs @@ -0,0 +1,224 @@ +using System.IO.Abstractions; +using Microsoft.Extensions.Logging; +using Moq; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Cli.Common.Logging; +using Unity.Services.Cli.GameServerHosting.Exceptions; +using Unity.Services.Cli.GameServerHosting.Handlers; +using Unity.Services.Cli.GameServerHosting.Input; +using Unity.Services.Cli.TestUtils; +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model; + +namespace Unity.Services.Cli.GameServerHosting.UnitTest.Handlers; + +partial class BuildCreateVersionHandlerTests +{ + [TestCase(null, "./test/sa.json", "gs://bucket/url")] + [TestCase(ValidBuildIdGcs, null, "gs://bucket/url")] + [TestCase(ValidBuildIdGcs, "./test/sa.json", null)] + public void BuildCreateVersionAsync_GCS_MissingInputThrowsException( + long? buildId, + string? serviceAccountJsonFile, + string? bucketUrl + ) + { + BuildCreateVersionInput input = new() + { + CloudProjectId = ValidProjectId, + TargetEnvironmentName = ValidEnvironmentName, + BuildId = buildId.HasValue ? buildId.ToString() : null, + ServiceAccountJsonFile = serviceAccountJsonFile, + BucketUrl = bucketUrl, + BuildVersionName = ValidBuildVersionName, + }; + + Assert.ThrowsAsync( + () => BuildCreateVersionHandler.BuildCreateVersionAsync( + input, + MockUnityEnvironment.Object, + GameServerHostingService!, + MockLogger!.Object, + new Mock().Object, + CancellationToken.None + ) + ); + + BuildsApi!.DefaultBuildsClient.Verify( + api => api.CreateNewBuildVersionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + 0, + CancellationToken.None + ), + Times.Never); + + TestsHelper.VerifyLoggerWasCalled( + MockLogger!, + LogLevel.Critical, + LoggerExtension.ResultEventId, + Times.Never); + } + + [TestCase( + "secretKey", + null, + null, + null, + TestName = "Redundant AccessKey")] + [TestCase( + null, + "accessKey", + null, + null, + TestName = "Redundant SecretKey")] + [TestCase( + null, + null, + "containerTag", + null, + TestName = "Redundant fileDirectory")] + [TestCase( + null, + null, + null, + "fileDirectory", + TestName = "Redundant containerTag")] + public void BuildCreateVersionAsync_GCS_UnknownInputThrowsException( + string? secretKey = null, + string? accessKey = null, + string? containerTag = null, + string? fileDirectory = null + ) + { + BuildCreateVersionInput input = new() + { + CloudProjectId = ValidProjectId, + TargetEnvironmentName = ValidEnvironmentName, + BuildId = ValidBuildIdGcs.ToString(), + ServiceAccountJsonFile = "./test/sa.json", + BucketUrl = "gs://bucket/url", + BuildVersionName = ValidBuildVersionName, + SecretKey = secretKey, + AccessKey = accessKey, + ContainerTag = containerTag, + FileDirectory = fileDirectory + }; + + Assert.ThrowsAsync( + () => BuildCreateVersionHandler.BuildCreateVersionAsync( + input, + MockUnityEnvironment.Object, + GameServerHostingService!, + MockLogger!.Object, + new Mock().Object, + CancellationToken.None + ) + ); + + BuildsApi!.DefaultBuildsClient.Verify( + api => api.CreateNewBuildVersionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + 0, + CancellationToken.None + ), + Times.Never); + + TestsHelper.VerifyLoggerWasCalled( + MockLogger!, + LogLevel.Critical, + LoggerExtension.ResultEventId, + Times.Never); + } + + + [TestCase( + false, + true, + false, + TestName = "golden path")] + [TestCase( + true, + false, + false, + TestName = "io exception")] + [TestCase( + false, + true, + true, + TestName = "api exception")] + public void BuildCreateVersionAsync_GCS( + bool throwIoException, + bool expectApiCall, + bool throwApiException + ) + { + var serviceAcountPath = "./tmp/sa.json"; + var fullServiceAccountPath = Path.GetFullPath(serviceAcountPath); + + BuildCreateVersionInput input = new() + { + CloudProjectId = ValidProjectId, + TargetEnvironmentName = ValidEnvironmentName, + BuildId = ValidBuildIdGcs.ToString(), + BucketUrl = "gc://bucket/url", + ServiceAccountJsonFile = serviceAcountPath, + BuildVersionName = throwApiException ? InValidBuildVersionName : ValidBuildVersionName + }; + + var fileMock = new Mock(); + var expectation = fileMock.Setup(file => file.ReadAllTextAsync(fullServiceAccountPath, CancellationToken.None)); + + if (throwIoException) + { + expectation.ThrowsAsync(new IOException("test")); + } + else + { + expectation.ReturnsAsync("{}"); + } + BuildCreateVersionHandler.FileSystem = fileMock.Object; + + + if (throwIoException || throwApiException) + { + Assert.ThrowsAsync(FuncCall); + } + else + { + Assert.DoesNotThrowAsync(FuncCall); + } + + + BuildsApi!.DefaultBuildsClient.Verify( + api => api.CreateNewBuildVersionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + 0, + CancellationToken.None + ), + expectApiCall ? Times.Once : Times.Never); + + TestsHelper.VerifyLoggerWasCalled( + MockLogger!, + LogLevel.Critical, + LoggerExtension.ResultEventId, + Times.Never); + return; + + Task FuncCall() => + BuildCreateVersionHandler.BuildCreateVersionAsync( + input, + MockUnityEnvironment.Object, + GameServerHostingService!, + MockLogger!.Object, + new Mock().Object, + CancellationToken.None); + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Mocks/GameServerHostingBuildApiV1AsyncMock.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Mocks/GameServerHostingBuildApiV1AsyncMock.cs index 83421c9..9a1f799 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Mocks/GameServerHostingBuildApiV1AsyncMock.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting.UnitTest/Mocks/GameServerHostingBuildApiV1AsyncMock.cs @@ -142,6 +142,15 @@ class GameServerHostingBuildsApiV1Mock osFamily: CreateBuild200Response.OsFamilyEnum.LINUX, syncStatus: CreateBuild200Response.SyncStatusEnum.SYNCED, updated: new DateTime(2022, 10, 11)), + new CreateBuild200Response( + ValidBuildIdGcs, + "build1-gcs-build", + CreateBuild200Response.BuildTypeEnum.GCS, + buildVersionName: ValidBuildVersionName, + gcs: new GoogleCloudStorageDetails("gs://bucket-name"), + osFamily: CreateBuild200Response.OsFamilyEnum.LINUX, + syncStatus: CreateBuild200Response.SyncStatusEnum.SYNCED, + updated: new DateTime(2022, 10, 12)), new CreateBuild200Response( ValidBuildIdFileUpload, "build3-file-upload-build", diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Exceptions/InvalidLegacyInputUsageException.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Exceptions/InvalidLegacyInputUsageException.cs new file mode 100644 index 0000000..9dfd359 --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Exceptions/InvalidLegacyInputUsageException.cs @@ -0,0 +1,11 @@ +using System.Runtime.Serialization; +using Unity.Services.Cli.Common.Exceptions; + +namespace Unity.Services.Cli.GameServerHosting.Exceptions; + + +public class InvalidLegacyInputUsageException : CliException +{ + public InvalidLegacyInputUsageException(string input) + : base($"Build Configuration usage settings are invalid. Missing value for input: '{input}'", Common.Exceptions.ExitCode.HandledError) { } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/GameServerHostingModule.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/GameServerHostingModule.cs index 71af090..afdc7d6 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/GameServerHostingModule.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/GameServerHostingModule.cs @@ -64,7 +64,8 @@ public GameServerHostingModule() BuildCreateVersionInput.FileDirectoryOption, BuildCreateVersionInput.SecretKeyOption, BuildCreateVersionInput.RemoveOldFilesOption, - BuildCreateVersionInput.BuildVersionNameOption + BuildCreateVersionInput.BuildVersionNameOption, + BuildCreateVersionInput.ServiceAccountJsonFileOption }; BuildCreateVersionCommand.SetHandler< BuildCreateVersionInput, @@ -76,7 +77,6 @@ public GameServerHostingModule() CancellationToken >(BuildCreateVersionHandler.BuildCreateVersionAsync); - BuildDeleteCommand = new Command("delete", "Delete a Game Server Hosting build") { BuildIdInput.BuildIdArgument, diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildConfigurationCreateHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildConfigurationCreateHandler.cs index c7c8e9e..df8800d 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildConfigurationCreateHandler.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildConfigurationCreateHandler.cs @@ -40,32 +40,45 @@ CancellationToken cancellationToken var buildId = input.BuildId ?? throw new MissingInputException(BuildConfigurationCreateInput.BuildIdKey); var commandLine = input.CommandLine ?? throw new MissingInputException(BuildConfigurationCreateInput.CommandLineKey); var configuration = input.Configuration ?? throw new MissingInputException(BuildConfigurationCreateInput.ConfigurationKey); - var cores = input.Cores ?? throw new MissingInputException(BuildConfigurationCreateInput.CoresKey); - var memory = input.Memory ?? throw new MissingInputException(BuildConfigurationCreateInput.MemoryKey); + var cores = input.Cores ?? 0; + var memory = input.Memory ?? 0; var name = input.Name ?? throw new MissingInputException(BuildConfigurationCreateInput.NameKey); var queryType = input.QueryType ?? throw new MissingInputException(BuildConfigurationCreateInput.QueryTypeKey); - var speed = input.Speed ?? throw new MissingInputException(BuildConfigurationCreateInput.SpeedKey); + var speed = input.Speed ?? 0; var readiness = input.Readiness ?? false; await service.AuthorizeGameServerHostingService(cancellationToken); var parsedConfigs = configuration.Select(ParseConfig).ToList(); + var createReq = new BuildConfigurationCreateRequest( + binaryPath: binaryPath, + buildID: buildId, + commandLine: commandLine, + configuration: parsedConfigs, + name: name, + queryType: queryType, + readiness: readiness + ); + + // allow for usage backwards compatibility. + var oldUsage = cores + memory + speed; + if (oldUsage > 0) + { +#pragma warning disable CS0612 // Type or member is obsolete + createReq.Speed = speed > 0 ? speed : throw new InvalidLegacyInputUsageException(BuildConfigurationCreateInput.SpeedKey); + createReq.Memory = memory > 0 ? memory : throw new InvalidLegacyInputUsageException(BuildConfigurationCreateInput.MemoryKey); + createReq.Cores = cores > 0 ? cores : throw new InvalidLegacyInputUsageException(BuildConfigurationCreateInput.CoresKey); +#pragma warning disable CS0612 // Type or member is obsolete + + // log warning for those options + logger.LogWarning("The '--cores', '--memory' and '--speed' options are deprecated and will be removed in a future release. Please use '--usage-setting' option on the fleet create instead. For more info please refer to https://docs.unity.com/ugs/en-us/manual/game-server-hosting/manual/guides/configure-server-density."); + } + var buildConfiguration = await service.BuildConfigurationsApi.CreateBuildConfigurationAsync( Guid.Parse(input.CloudProjectId!), Guid.Parse(environmentId), - new BuildConfigurationCreateRequest( - binaryPath: binaryPath, - buildID: buildId, - commandLine: commandLine, - configuration: parsedConfigs, - cores: cores, - memory: memory, - name: name, - queryType: queryType, - speed: speed, - readiness: readiness - ), + createReq, cancellationToken: cancellationToken); logger.LogResultValue(new BuildConfigurationOutput(buildConfiguration)); } diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildConfigurationUpdateHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildConfigurationUpdateHandler.cs index c27aa5f..b08e95c 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildConfigurationUpdateHandler.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildConfigurationUpdateHandler.cs @@ -80,6 +80,25 @@ CancellationToken cancellationToken #pragma warning restore CS0612 // Type or member is obsolete ); + var cores = input.Cores ?? 0; + var memory = input.Memory ?? 0; + var speed = input.Speed ?? 0; + +#pragma warning disable CS0612 // Type or member is obsolete + // allow for usage backwards compatibility. + var oldUsage = cores + memory + speed; + if (oldUsage > 0) + { + req.Speed = speed > 0 ? speed : throw new InvalidLegacyInputUsageException(BuildConfigurationCreateInput.SpeedKey); + req.Memory = memory > 0 ? memory : throw new InvalidLegacyInputUsageException(BuildConfigurationCreateInput.MemoryKey); + req.Cores = cores > 0 ? cores : throw new InvalidLegacyInputUsageException(BuildConfigurationCreateInput.CoresKey); + + // log warning for those options + logger.LogWarning("The '--cores', '--memory' and '--speed' options are deprecated and will be removed in a future release. Please use '--usage-setting' option on the fleet create instead. For more info please refer to https://docs.unity.com/ugs/en-us/manual/game-server-hosting/manual/guides/configure-server-density."); + } +#pragma warning disable CS0612 // Type or member is obsolete + + var buildConfiguration = await service.BuildConfigurationsApi.UpdateBuildConfigurationAsync( Guid.Parse(input.CloudProjectId!), Guid.Parse(environmentId), diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionContainerHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionContainerHandler.cs index 5ed8e22..3c08933 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionContainerHandler.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionContainerHandler.cs @@ -50,5 +50,11 @@ internal static void ValidateContainerInput(BuildCreateVersionInput input) throw new CliException("Build does not support file upload flags.", ExitCode.HandledError); if (input.BucketUrl != null) throw new CliException("Build does not support s3 buckets.", ExitCode.HandledError); + if (input.SecretKey != null) + throw new CliException("Build does not support s3 flags.", ExitCode.HandledError); + if (input.AccessKey != null) + throw new CliException("Build does not support s3 flags.", ExitCode.HandledError); + if (input.ServiceAccountJsonFile != null) + throw new CliException("Build does not support gcs flags.", ExitCode.HandledError); } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionFileUploadHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionFileUploadHandler.cs index e8900d4..5146402 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionFileUploadHandler.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionFileUploadHandler.cs @@ -288,5 +288,11 @@ internal static void ValidateFileUploadInput(BuildCreateVersionInput input) throw new CliException("Build does not support container flags.", ExitCode.HandledError); if (input.BucketUrl != null) throw new CliException("Build does not support s3 buckets.", ExitCode.HandledError); + if (input.SecretKey != null) + throw new CliException("Build does not support s3 flags.", ExitCode.HandledError); + if (input.AccessKey != null) + throw new CliException("Build does not support s3 flags.", ExitCode.HandledError); + if (input.ServiceAccountJsonFile != null) + throw new CliException("Build does not support gcs flags.", ExitCode.HandledError); } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionGCSHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionGCSHandler.cs new file mode 100644 index 0000000..2d36bbc --- /dev/null +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionGCSHandler.cs @@ -0,0 +1,91 @@ +using System.IO.Abstractions; +using Microsoft.Extensions.Logging; +using Unity.Services.Cli.Common.Exceptions; +using Unity.Services.Cli.GameServerHosting.Exceptions; +using Unity.Services.Cli.GameServerHosting.Input; +using Unity.Services.Cli.GameServerHosting.Service; +using Unity.Services.Cli.GameServerHosting.Services; +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Client; +using Unity.Services.Gateway.GameServerHostingApiV1.Generated.Model; + +namespace Unity.Services.Cli.GameServerHosting.Handlers; + +static partial class BuildCreateVersionHandler +{ + public static IFile FileSystem { get; set; } = new FileSystem().File; + + static async Task CreateGcsUploadBuildVersion( + IGameServerHostingService service, + BuildCreateVersionInput input, + string environmentId, + ILogger logger, + CreateBuild200Response build, + CancellationToken cancellationToken) + { + ValidateGcsInput(input); + + var serviceAccountFileContent = await ReadServiceAccountFileAsync( + input.ServiceAccountJsonFile!, + FileSystem, + cancellationToken); + + try + { + await service.BuildsApi.CreateNewBuildVersionAsync( + Guid.Parse(input.CloudProjectId!), + Guid.Parse(environmentId), + build.BuildID, + new CreateNewBuildVersionRequest( + buildVersionName: input.BuildVersionName!, + gcs: new GoogleCloudStorageRequest( + input.BucketUrl!, + serviceAccountFileContent + ) + ), + cancellationToken: cancellationToken + ); + + logger.LogInformation("Build version created successfully"); + } + catch (ApiException e) + { + ApiExceptionConverter.Convert(e); + } + } + + // We need to apply our own conditional validation based on the build type + static void ValidateGcsInput(BuildCreateVersionInput input) + { + if (input.ServiceAccountJsonFile == null) + throw new MissingInputException(BuildCreateVersionInput.ServiceAccountJsonFileKey); + if (input.BucketUrl == null) + throw new MissingInputException(BuildCreateVersionInput.BucketUrlKey); + if (input.SecretKey != null) + throw new CliException("Build does not support s3 flags.", ExitCode.HandledError); + if (input.AccessKey != null) + throw new CliException("Build does not support s3 flags.", ExitCode.HandledError); + if (input.ContainerTag != null) + throw new CliException("Build does not support container flags.", ExitCode.HandledError); + if (input.FileDirectory != null) + throw new CliException("Build does not support file upload flags.", ExitCode.HandledError); + } + + static async Task ReadServiceAccountFileAsync( + string serviceAccountJsonFile, + IFile fileSystem, + CancellationToken cancellationToken) + { + var fullPath = Path.GetFullPath(serviceAccountJsonFile); + try + { + return await fileSystem.ReadAllTextAsync(fullPath, cancellationToken); + } + catch (IOException e) + { + throw new CliException( + $"Failed to read service account file at {fullPath}. IO error: {e.Message}", + e, + ExitCode.HandledError); + } + } +} diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionHandler.cs index 4603389..f39dd3d 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionHandler.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionHandler.cs @@ -79,7 +79,7 @@ await CreateFileUploadBuildVersion( ); break; case S3: - await CreateBucketUploadBuildVersion( + await CreateS3UploadBuildVersion( logger, service, input, @@ -87,6 +87,15 @@ await CreateBucketUploadBuildVersion( build, cancellationToken); break; + case GCS: + await CreateGcsUploadBuildVersion( + service, + input, + environmentId, + logger, + build, + cancellationToken); + break; default: throw new CliException("Unsupported build type", ExitCode.HandledError); } diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionBucketHandler.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionS3Handler.cs similarity index 88% rename from Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionBucketHandler.cs rename to Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionS3Handler.cs index 5d48e73..2bab838 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionBucketHandler.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Handlers/BuildCreateVersionS3Handler.cs @@ -11,7 +11,7 @@ namespace Unity.Services.Cli.GameServerHosting.Handlers; static partial class BuildCreateVersionHandler { - static async Task CreateBucketUploadBuildVersion( + static async Task CreateS3UploadBuildVersion( ILogger logger, IGameServerHostingService service, BuildCreateVersionInput input, @@ -20,7 +20,7 @@ static async Task CreateBucketUploadBuildVersion( CancellationToken cancellationToken ) { - ValidateBucketInput(input); + ValidateS3Input(input); try { await service.BuildsApi.CreateNewBuildVersionAsync( @@ -43,7 +43,7 @@ await service.BuildsApi.CreateNewBuildVersionAsync( } // We need to apply our own conditional validation based on the build type - internal static void ValidateBucketInput(BuildCreateVersionInput input) + internal static void ValidateS3Input(BuildCreateVersionInput input) { if (input.AccessKey == null) throw new MissingInputException(BuildCreateVersionInput.AccessKeyKey); @@ -51,6 +51,8 @@ internal static void ValidateBucketInput(BuildCreateVersionInput input) throw new MissingInputException(BuildCreateVersionInput.BucketUrlKey); if (input.SecretKey == null) throw new MissingInputException(BuildCreateVersionInput.SecretKeyKey); + if (input.ServiceAccountJsonFile != null) + throw new CliException("Build does not support gcs flags.", ExitCode.HandledError); if (input.ContainerTag != null) throw new CliException("Build does not support container flags.", ExitCode.HandledError); if (input.FileDirectory != null) diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Input/BuildConfigurationCreateInput.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Input/BuildConfigurationCreateInput.cs index 1505ea4..745064e 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Input/BuildConfigurationCreateInput.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Input/BuildConfigurationCreateInput.cs @@ -40,15 +40,9 @@ public class BuildConfigurationCreateInput : CommonInput AllowMultipleArgumentsPerToken = true }; - public static readonly Option CoresOption = new(CoresKey, "The number of CPU cores required per server") - { - IsRequired = true - }; + public static readonly Option CoresOption = new(CoresKey, "The number of CPU cores required per server"); - public static readonly Option MemoryOption = new(MemoryKey, "Maximum memory required per server (MB)") - { - IsRequired = true - }; + public static readonly Option MemoryOption = new(MemoryKey, "Maximum memory required per server (MB)"); public static readonly Option NameOption = new(NameKey, "Name to use for the new build configuration") { @@ -60,10 +54,7 @@ public class BuildConfigurationCreateInput : CommonInput IsRequired = true }; - public static readonly Option SpeedOption = new(SpeedKey, "CPU utilisation per core") - { - IsRequired = true - }; + public static readonly Option SpeedOption = new(SpeedKey, "CPU utilisation per core"); public static readonly Option ReadinessOption = new(ReadinessKey, "Readiness of the build configuration"); diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Input/BuildCreateVersionInput.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Input/BuildCreateVersionInput.cs index e6a8673..0bbfdc2 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Input/BuildCreateVersionInput.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Input/BuildCreateVersionInput.cs @@ -12,6 +12,7 @@ class BuildCreateVersionInput : CommonInput public const string FileDirectoryKey = "--directory"; public const string RemoveOldFilesKey = "--remove-old-files"; public const string SecretKeyKey = "--secret-key"; + public const string ServiceAccountJsonFileKey = "--service-account-json-file"; public const string BuildVersionNameKey = "--build-version-name"; public static readonly Option AccessKeyOption = new( @@ -24,7 +25,7 @@ class BuildCreateVersionInput : CommonInput public static readonly Option BucketUrlOption = new( BucketUrlKey, - "The bucket url to use for the build version, for s3 bucket builds"); + "The bucket url to use for the build version, for s3 or gcs bucket builds"); public static readonly Option ContainerTagOption = new( ContainerTagKey, @@ -46,6 +47,10 @@ class BuildCreateVersionInput : CommonInput BuildVersionNameKey, "The name of the build version to create"); + public static readonly Option ServiceAccountJsonFileOption = new( + ServiceAccountJsonFileKey, + "The path to the service account JSON file, for GCS builds"); + [InputBinding(nameof(AccessKeyOption))] public string? AccessKey { get; init; } @@ -69,4 +74,7 @@ class BuildCreateVersionInput : CommonInput [InputBinding(nameof(BuildVersionNameOption))] public string? BuildVersionName { get; init; } + + [InputBinding(nameof(ServiceAccountJsonFileOption))] + public string? ServiceAccountJsonFile { get; init; } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/BuildOutput.cs b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/BuildOutput.cs index 4d2ff84..488f46e 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/BuildOutput.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Model/BuildOutput.cs @@ -20,6 +20,7 @@ public BuildOutput(BuildListInner build) if (build.Ccd != null) Ccd = new CcdOutput(build.Ccd); Container = build.Container; S3 = build.S3; + Gcs = build.Gcs; } public BuildOutput(CreateBuild200Response build) @@ -35,6 +36,7 @@ public BuildOutput(CreateBuild200Response build) if (build.Ccd != null) Ccd = new CcdOutput(build.Ccd); Container = build.Container; S3 = build.S3; + Gcs = build.Gcs; } public string BuildVersionName { get; } @@ -63,6 +65,8 @@ public BuildOutput(CreateBuild200Response build) [YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)] public AmazonS3Details S3 { get; } + [YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)] + public GoogleCloudStorageDetails Gcs { get; } public override string ToString() { var serializer = new SerializerBuilder() diff --git a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Unity.Services.Cli.GameServerHosting.csproj b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Unity.Services.Cli.GameServerHosting.csproj index 381364d..cb676f6 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Unity.Services.Cli.GameServerHosting.csproj +++ b/Unity.Services.Cli/Unity.Services.Cli.GameServerHosting/Unity.Services.Cli.GameServerHosting.csproj @@ -30,7 +30,7 @@ - + diff --git a/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/CloudContentDeliveryApiMock.cs b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/CloudContentDeliveryApiMock.cs index 8e3651a..ade4cd6 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/CloudContentDeliveryApiMock.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/CloudContentDeliveryApiMock.cs @@ -87,7 +87,7 @@ public class CloudContentDeliveryApiMock : IServiceApiMock "my label" }, Metadata = "{}", - Path = "image.jpg", + Path = "images/image.jpg", SignedUrl = "http://localhost:8080/ccd/upload" }; diff --git a/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/GameServerHosting/GameServerHostingApiMock.cs b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/GameServerHosting/GameServerHostingApiMock.cs index 588b0cf..66c0bba 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/GameServerHosting/GameServerHostingApiMock.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.Integration.MockServer/ServiceMocks/GameServerHosting/GameServerHostingApiMock.cs @@ -35,6 +35,7 @@ public async Task> CreateMappingModels() public void CustomMock(WireMockServer mockServer) { MockFleetGet(mockServer); + MockFleetList(mockServer); MockServerGet(mockServer); MockServerList(mockServer); MockBuildInstalls(mockServer); @@ -52,6 +53,37 @@ public void CustomMock(WireMockServer mockServer) MockFilesDownload(mockServer); } + static void MockFleetList(WireMockServer mockServer) + { + var fleetList = new List + { + new ( + FleetListItem.AllocationTypeEnum.ALLOCATION, + new List(), + graceful: false, + regions: new List(), + id: new Guid(Keys.ValidFleetId), + name: "Test Fleet", + osName: Fleet.OsFamilyEnum.LINUX.ToString(), + servers: new Servers( + new FleetServerBreakdown(new ServerStatus()), + new FleetServerBreakdown(new ServerStatus()), + new FleetServerBreakdown(new ServerStatus())), + status: FleetListItem.StatusEnum.ONLINE + ), + }; + + var request = Request.Create() + .WithPath(Keys.FleetsPath) + .UsingGet(); + + var response = Response.Create() + .WithBodyAsJson(fleetList) + .WithStatusCode(HttpStatusCode.OK); + + mockServer.Given(request).RespondWith(response); + } + static void MockFleetGet(WireMockServer mockServer) { var fleet = new Fleet( diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudContentDeliveryTests/CloudContentDeliveryEntryTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudContentDeliveryTests/CloudContentDeliveryEntryTests.cs index 0b5e151..448422c 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudContentDeliveryTests/CloudContentDeliveryEntryTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/CloudContentDeliveryTests/CloudContentDeliveryEntryTests.cs @@ -11,6 +11,8 @@ namespace Unity.Services.Cli.IntegrationTest.CloudContentDeliveryTests; public class CloudContentDeliveryEntryTests : UgsCliFixture { static readonly string k_TestDirectory = Path.Combine(UgsCliBuilder.RootDirectory, ".tmp/FilesDir"); + static readonly string k_TempDirectory = Path.Combine(UgsCliBuilder.RootDirectory, ".tmp/FilesDir/temp"); + static readonly string k_ImageDirectory = Path.Combine(UgsCliBuilder.RootDirectory, ".tmp/FilesDir/images"); [SetUp] public async Task SetUp() @@ -20,8 +22,15 @@ public async Task SetUp() if (!Directory.Exists(k_TestDirectory)) Directory.CreateDirectory(k_TestDirectory); - await File.WriteAllTextAsync(Path.Join(k_TestDirectory, "foo"), "{}"); - await File.WriteAllTextAsync(Path.Join(k_TestDirectory, "image.jpg"), "{}"); + if (!Directory.Exists(k_TempDirectory)) + Directory.CreateDirectory(k_TempDirectory); + + if (!Directory.Exists(k_ImageDirectory)) + Directory.CreateDirectory(k_ImageDirectory); + + await File.WriteAllTextAsync(Path.Join(k_TempDirectory, "foo.txt"), "{}"); + await File.WriteAllTextAsync(Path.Join(k_ImageDirectory, "image.jpg"), "{}"); + await MockApi.MockServiceAsync(new IdentityV1Mock()); await MockApi.MockServiceAsync(new CloudContentDeliveryApiMock()); @@ -161,7 +170,7 @@ public async Task CloudContentDeliveryCopyEntries() { await GetLoggedInCli() .Command( - $"ccd entries copy {k_TestDirectory}/foo foo") + $"ccd entries copy {k_TempDirectory}/foo.txt foo.txt") .AssertStandardOutputContains( "entryid: 00000000-0000-0000-0000-000000000000") .AssertNoErrors() diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingBuildConfigurationCreateTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingBuildConfigurationCreateTests.cs index 8a5c0c0..b51ae78 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingBuildConfigurationCreateTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingBuildConfigurationCreateTests.cs @@ -24,7 +24,7 @@ await GetFullySetCli() StringAssert.Contains("Creating build config...", v); StringAssert.Contains("binaryPath: ", v); }) - .AssertNoErrors() + .AssertStandardErrorContains("The '--cores', '--memory' and '--speed' options are deprecated and will be removed in a future release.") .ExecuteAsync(); } @@ -124,7 +124,7 @@ public async Task BuildConfigurationCreate_FailsMissingCoresException() await GetFullySetCli() .Command(k_BuildConfigurationCreatePrefix + k_BuildConfigurationCreateOrUpdateCommandMissingCores) .AssertExitCode(ExitCode.HandledError) - .AssertStandardErrorContains("Option '--cores' is required.") + .AssertStandardErrorContains("Build Configuration usage settings are invalid. Missing value for input: '--cores'") .ExecuteAsync(); } @@ -137,7 +137,7 @@ public async Task BuildConfigurationCreate_FailsMissingMemoryException() await GetFullySetCli() .Command(k_BuildConfigurationCreatePrefix + k_BuildConfigurationCreateOrUpdateCommandMissingMemory) .AssertExitCode(ExitCode.HandledError) - .AssertStandardErrorContains("Option '--memory' is required.") + .AssertStandardErrorContains("Build Configuration usage settings are invalid. Missing value for input: '--memory'") .ExecuteAsync(); } @@ -176,7 +176,7 @@ public async Task BuildConfigurationCreate_FailsMissingSpeedException() await GetFullySetCli() .Command(k_BuildConfigurationCreatePrefix + k_BuildConfigurationCreateOrUpdateCommandMissingSpeed) .AssertExitCode(ExitCode.HandledError) - .AssertStandardErrorContains("Option '--speed' is required.") + .AssertStandardErrorContains("Build Configuration usage settings are invalid. Missing value for input: '--speed'") .ExecuteAsync(); } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingBuildConfigurationUpdateTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingBuildConfigurationUpdateTests.cs index 6d03225..e62a755 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingBuildConfigurationUpdateTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingBuildConfigurationUpdateTests.cs @@ -24,7 +24,7 @@ await GetFullySetCli() StringAssert.Contains("Updating build config...", v); StringAssert.Contains("binaryPath: ", v); }) - .AssertNoErrors() + .AssertStandardErrorContains("The '--cores', '--memory' and '--speed' options are deprecated and will be removed in a future release.") .ExecuteAsync(); } @@ -124,7 +124,7 @@ public async Task BuildConfigurationUpdate_FailsMissingCoresException() await GetFullySetCli() .Command(k_BuildConfigurationUpdatePrefix + k_BuildConfigurationCreateOrUpdateCommandMissingCores) .AssertExitCode(ExitCode.HandledError) - .AssertStandardErrorContains("Option '--cores' is required.") + .AssertStandardErrorContains("Build Configuration usage settings are invalid. Missing value for input: '--cores'") .ExecuteAsync(); } @@ -137,7 +137,7 @@ public async Task BuildConfigurationUpdate_FailsMissingMemoryException() await GetFullySetCli() .Command(k_BuildConfigurationUpdatePrefix + k_BuildConfigurationCreateOrUpdateCommandMissingMemory) .AssertExitCode(ExitCode.HandledError) - .AssertStandardErrorContains("Option '--memory' is required.") + .AssertStandardErrorContains("Build Configuration usage settings are invalid. Missing value for input: '--memory'") .ExecuteAsync(); } @@ -176,7 +176,7 @@ public async Task BuildConfigurationUpdate_FailsMissingSpeedException() await GetFullySetCli() .Command(k_BuildConfigurationUpdatePrefix + k_BuildConfigurationCreateOrUpdateCommandMissingSpeed) .AssertExitCode(ExitCode.HandledError) - .AssertStandardErrorContains("Option '--speed' is required.") + .AssertStandardErrorContains("Build Configuration usage settings are invalid. Missing value for input: '--speed'") .ExecuteAsync(); } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingBuildCreateTests.cs b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingBuildCreateTests.cs index 98a4170..b64a8a4 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingBuildCreateTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.IntegrationTest/GameServerHostingTests/GameServerHostingBuildCreateTests.cs @@ -64,7 +64,7 @@ await GetFullySetCli() .Command("gsh build create --name test-build --os-family linux --type VM") .AssertExitCode(ExitCode.HandledError) .AssertStandardErrorContains( - "Invalid option for --type. Did you mean one of the following? FILEUPLOAD, CONTAINER, S3") + "Invalid option for --type. Did you mean one of the following? FILEUPLOAD, CONTAINER, S3, GCS") .ExecuteAsync(); } diff --git a/Unity.Services.Cli/Unity.Services.Cli.RemoteConfig.UnitTest/ImportExport/ExportHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.RemoteConfig.UnitTest/ImportExport/ExportHandlerTests.cs index 790e9a6..e0af908 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.RemoteConfig.UnitTest/ImportExport/ExportHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.RemoteConfig.UnitTest/ImportExport/ExportHandlerTests.cs @@ -55,6 +55,12 @@ public void SetUp() It.IsAny())) .Returns(Task.FromResult((IEnumerable)m_LocalEntries)); + var dir = new Mock(); + var file = new Mock(); + file.Setup(f => f.Exists(It.IsAny())).Returns(false); + m_FileSystemMock.Setup(fs => fs.Directory).Returns(dir.Object); + m_FileSystemMock.Setup(fs => fs.File).Returns(file.Object); + m_RemoteConfigExporter = new RemoteConfigExporter( m_MockRemoteConfigClient.Object, m_MockArchiver.Object, @@ -166,4 +172,26 @@ public async Task ExportAsync_ApiCalls(bool dryRun, bool exists, int getCalls, i c => c.UpdateAsync(It.IsAny>()), updateCallsTimes); } + + [Test] + public void ExportAsync_SucceedsOnEmptyEnv() + { + var importInput = new ExportInput() + { + OutputDirectory = "mock_input_directory", + FileName = "file.rczip" + }; + + m_MockRemoteConfigClient.Setup(c => c.GetAsync()) + .ReturnsAsync(new GetConfigsResult(false, null)); + + Assert.DoesNotThrowAsync( + async () => + { + await m_RemoteConfigExporter.ExportAsync( + importInput, + CancellationToken.None + ); + }); + } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.RemoteConfig.UnitTest/ImportExport/ImportHandlerTests.cs b/Unity.Services.Cli/Unity.Services.Cli.RemoteConfig.UnitTest/ImportExport/ImportHandlerTests.cs index 883b1bf..bfceb7b 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.RemoteConfig.UnitTest/ImportExport/ImportHandlerTests.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.RemoteConfig.UnitTest/ImportExport/ImportHandlerTests.cs @@ -189,4 +189,24 @@ public async Task Reconcile_Deletes() CollectionAssert.DoesNotContain(sentEntries, remoteEntries[0]); } + [Test] + public void ImportAsync_SucceedsOnEmptyEnv() + { + var importInput = new ImportInput() + { + InputDirectory = "mock_input_directory" + }; + + m_MockRemoteConfigClient.Setup(c => c.GetAsync()) + .ReturnsAsync(new GetConfigsResult(false, null)); + + Assert.DoesNotThrowAsync( + async () => + { + await m_RemoteConfigImporter.ImportAsync( + importInput, + CancellationToken.None + ); + }); + } } diff --git a/Unity.Services.Cli/Unity.Services.Cli.RemoteConfig/Handlers/ExportImport/RemoteConfigExporter.cs b/Unity.Services.Cli/Unity.Services.Cli.RemoteConfig/Handlers/ExportImport/RemoteConfigExporter.cs index 613cdb5..2b1c2a4 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.RemoteConfig/Handlers/ExportImport/RemoteConfigExporter.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.RemoteConfig/Handlers/ExportImport/RemoteConfigExporter.cs @@ -35,6 +35,8 @@ protected override async Task> ListConfigsAsyn { m_RemoteConfigClient.Initialize(projectId, environmentId, cancellationToken); var result = await m_RemoteConfigClient.GetAsync(); + if (!result.ConfigsExists) + return Array.Empty(); return ToDto(result.Configs); } @@ -42,7 +44,7 @@ protected override ImportExportEntry ToImportExportEntry(R { return new ImportExportEntry(value.key.GetHashCode(), value.key, value); } - + static IReadOnlyList ToDto(IReadOnlyList entries) { return entries.Select(entry => new RemoteConfigEntryDTO() diff --git a/Unity.Services.Cli/Unity.Services.Cli.RemoteConfig/Handlers/ExportImport/RemoteConfigImporter.cs b/Unity.Services.Cli/Unity.Services.Cli.RemoteConfig/Handlers/ExportImport/RemoteConfigImporter.cs index 51740a9..7f60425 100644 --- a/Unity.Services.Cli/Unity.Services.Cli.RemoteConfig/Handlers/ExportImport/RemoteConfigImporter.cs +++ b/Unity.Services.Cli/Unity.Services.Cli.RemoteConfig/Handlers/ExportImport/RemoteConfigImporter.cs @@ -41,6 +41,9 @@ protected override async Task> ListConfigsAsyn m_RemoteConfigClient.Initialize(cloudProjectId, environmentId, cancellationToken); m_GetResult = await m_RemoteConfigClient.GetAsync(); + if (!m_GetResult.ConfigsExists) + return Array.Empty(); + var configsOnRemote = ToDto(m_GetResult.Configs); return configsOnRemote; } diff --git a/Unity.Services.Cli/Unity.Services.Cli/Unity.Services.Cli.csproj b/Unity.Services.Cli/Unity.Services.Cli/Unity.Services.Cli.csproj index 695b104..a8c2f43 100644 --- a/Unity.Services.Cli/Unity.Services.Cli/Unity.Services.Cli.csproj +++ b/Unity.Services.Cli/Unity.Services.Cli/Unity.Services.Cli.csproj @@ -4,7 +4,7 @@ net8.0 10 ugs - 1.5.0 + 1.6.0 true true