diff --git a/GoogleCloudExtension/GoogleCloudExtension.GCloud/Models/CloudSdkVersions.cs b/GoogleCloudExtension/GoogleCloudExtension.GCloud/Models/CloudSdkVersions.cs index 176c7d8e0..e590bd1b2 100644 --- a/GoogleCloudExtension/GoogleCloudExtension.GCloud/Models/CloudSdkVersions.cs +++ b/GoogleCloudExtension/GoogleCloudExtension.GCloud/Models/CloudSdkVersions.cs @@ -13,6 +13,8 @@ // limitations under the License. using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using System; namespace GoogleCloudExtension.GCloud.Models { @@ -25,6 +27,7 @@ public class CloudSdkVersions /// The version of the Cloud SDK itself. /// [JsonProperty("Google Cloud SDK")] - public string SdkVersion { get; set; } + [JsonConverter(typeof(VersionConverter))] + public Version SdkVersion { get; set; } } } diff --git a/GoogleCloudExtension/GoogleCloudExtension/GCloud/GCloudContext.cs b/GoogleCloudExtension/GoogleCloudExtension/GCloud/GCloudContext.cs index 7e23bc583..4a9b47ca5 100644 --- a/GoogleCloudExtension/GoogleCloudExtension/GCloud/GCloudContext.cs +++ b/GoogleCloudExtension/GoogleCloudExtension/GCloud/GCloudContext.cs @@ -13,6 +13,7 @@ // limitations under the License. using GoogleCloudExtension.Accounts; +using GoogleCloudExtension.GCloud.Models; using GoogleCloudExtension.Utils; using System; using System.Collections.Generic; @@ -26,27 +27,40 @@ namespace GoogleCloudExtension.GCloud /// public class GCloudContext : IGCloudContext { + /// + /// The first version of gcloud with the builds group. + /// + /// + public const string GCloudBuildsMinimumVersion = "207.0.0"; + private const string GCloudMetricsVariable = "CLOUDSDK_METRICS_ENVIRONMENT"; private const string GCloudMetricsVersionVariable = "CLOUDSDK_METRICS_ENVIRONMENT_VERSION"; + /// + /// The first version of gcloud with the builds group. + /// + /// + private static readonly Version s_gCloudBuildsMinimumVersion = new Version(GCloudBuildsMinimumVersion); + /// /// The path to the credentials .json file to use for the call. The .json file should be a - /// format accetable by gcloud's --credential-file-override parameter. Typically an authorize_user kind. + /// format acceptable by gcloud's --credential-file-override parameter. Typically an authorize_user kind. /// public string CredentialsPath { get; } /// - /// The project id of the project to use for the invokation of gcloud. + /// The project id of the project to use for the invocation of gcloud. /// public string ProjectId { get; } protected readonly Dictionary Environment = new Dictionary { [GCloudMetricsVariable] = GoogleCloudExtensionPackage.Instance.ApplicationName, - [GCloudMetricsVersionVariable] = - GoogleCloudExtensionPackage.Instance.ApplicationVersion + [GCloudMetricsVersionVariable] = GoogleCloudExtensionPackage.Instance.ApplicationVersion }; + private readonly Task _versionsTask; + /// /// Creates the default GCloud context from the current environment. /// @@ -54,6 +68,7 @@ public GCloudContext() { CredentialsPath = CredentialsStore.Default.CurrentAccountPath; ProjectId = CredentialsStore.Default.CurrentProjectId; + _versionsTask = GetGcloudOutputAsync("version"); } /// @@ -92,10 +107,13 @@ public Task DeployAppAsync(string appYaml, string version, bool promote, A /// The name of the image to build. /// The contents of the container, including the Dockerfile. /// The action to perform on each line of output. - public Task BuildContainerAsync(string imageTag, string contentsPath, Action outputAction) + public async Task BuildContainerAsync(string imageTag, string contentsPath, Action outputAction) { - string command = $"container builds submit --tag=\"{imageTag}\" \"{contentsPath}\""; - return RunGcloudCommandAsync(command, outputAction); + CloudSdkVersions sdkVersions = await _versionsTask; + string group = sdkVersions.SdkVersion >= s_gCloudBuildsMinimumVersion ? "builds" : "container builds"; + + string command = $"{group} submit --tag=\"{imageTag}\" \"{contentsPath}\""; + return await RunGcloudCommandAsync(command, outputAction); } /// diff --git a/GoogleCloudExtension/GoogleCloudExtension/GCloud/GCloudWrapper.cs b/GoogleCloudExtension/GoogleCloudExtension/GCloud/GCloudWrapper.cs index 624d44d36..ad4058d7f 100644 --- a/GoogleCloudExtension/GoogleCloudExtension/GCloud/GCloudWrapper.cs +++ b/GoogleCloudExtension/GoogleCloudExtension/GCloud/GCloudWrapper.cs @@ -45,7 +45,7 @@ public class GCloudWrapper : IGCloudWrapper new Dictionary { [GCloudComponent.Beta] = "beta", - [GCloudComponent.Kubectl] = "kubectl", + [GCloudComponent.Kubectl] = "kubectl" }; private readonly Lazy _processService; @@ -89,7 +89,7 @@ public async Task ValidateGCloudAsync(GCloudComponent co /// in . If the does not refer to a supported CVS (currently git) then /// nothing will be done. /// - /// The directory for which to generate the source contenxt. + /// The directory for which to generate the source context. /// Where to store the source context files. /// The task to be completed when the operation finishes. public async Task GenerateSourceContextAsync(string sourcePath, string outputPath) @@ -109,7 +109,7 @@ private async Task> GetInstalledComponentsAsync() return components.Where(x => x.State.IsInstalled).Select(x => x.Id).ToList(); } - private bool IsGCloudCliInstalled() + private static bool IsGCloudCliInstalled() { Debug.WriteLine("Validating GCloud installation."); string gcloudPath = PathUtils.GetCommandPathFromPATH("gcloud.cmd"); @@ -136,7 +136,7 @@ private async Task GetInstalledCloudSdkVersionAsync() } CloudSdkVersions version = await GetJsonOutputAsync("version"); - return new Version(version.SdkVersion); + return version.SdkVersion; } private async Task GetJsonOutputAsync(string command) diff --git a/GoogleCloudExtension/GoogleCloudExtension/GoogleCloudExtension.csproj b/GoogleCloudExtension/GoogleCloudExtension/GoogleCloudExtension.csproj index 96a67e319..049937f3d 100644 --- a/GoogleCloudExtension/GoogleCloudExtension/GoogleCloudExtension.csproj +++ b/GoogleCloudExtension/GoogleCloudExtension/GoogleCloudExtension.csproj @@ -44,6 +44,7 @@ 4 true false + 7.1 pdbonly @@ -54,6 +55,7 @@ 4 true false + 7.1 diff --git a/GoogleCloudExtension/GoogleCloudExtensionUnitTests/GCloud/GCloudContextUnitTests.cs b/GoogleCloudExtension/GoogleCloudExtensionUnitTests/GCloud/GCloudContextUnitTests.cs index 3b1fef3e1..3776a76fd 100644 --- a/GoogleCloudExtension/GoogleCloudExtensionUnitTests/GCloud/GCloudContextUnitTests.cs +++ b/GoogleCloudExtension/GoogleCloudExtensionUnitTests/GCloud/GCloudContextUnitTests.cs @@ -13,11 +13,14 @@ // limitations under the License. using GoogleCloudExtension.GCloud; +using GoogleCloudExtension.GCloud.Models; using GoogleCloudExtension.Utils; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using System; using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; using System.Threading.Tasks; namespace GoogleCloudExtensionUnitTests.GCloud @@ -36,11 +39,34 @@ public class GCloudContextUnitTests : ExtensionTestBase private GCloudContext _objectUnderTest; private Mock _processServiceMock; private Action _mockedOutputAction; + private TaskCompletionSource _versionResultSource; + + /// + /// A version of Google Cloud SDK that includes the gcloud builds commands. + /// + private static readonly CloudSdkVersions s_buildsEnabledSdkVersion = + new CloudSdkVersions { SdkVersion = new Version(GCloudContext.GCloudBuildsMinimumVersion) }; + + /// + /// A version of Google Cloud SDK from before the gcloud builds commands were added. + /// + private static readonly CloudSdkVersions s_buildsMissingSdkVersion = + new CloudSdkVersions { SdkVersion = new Version(GCloudWrapper.GCloudSdkMinimumVersion) }; + + /// + /// Used as dynamic data to test that container builder arguments do not change between versions. + /// + private static IEnumerable SdkVersions => new[] + { + new object[] {s_buildsMissingSdkVersion}, + new object[] {s_buildsEnabledSdkVersion} + }; protected override void BeforeEach() { _processServiceMock = new Mock(); - SetupRunCommandResult(true); + _versionResultSource = new TaskCompletionSource(); + SetupGetJsonOutput("version", _versionResultSource.Task); PackageMock.Setup(p => p.ProcessService).Returns(_processServiceMock.Object); _objectUnderTest = new GCloudContext(); _mockedOutputAction = Mock.Of>(); @@ -244,16 +270,29 @@ public async Task TestDeployAppAsync_ReturnsResultFromCommand(bool expectedResul } [TestMethod] - public async Task TestBuildContainerAsync_RunsGcloudContainerBuildsSubmit() + public async Task TestBuildContainerAsync_ForOldVersion_RunsGcloudContainerBuildsSubmit() { + _versionResultSource.SetResult(s_buildsMissingSdkVersion); await _objectUnderTest.BuildContainerAsync(DefaultImageTag, DefaultContentsPath, _mockedOutputAction); - VerifyCommandArgsContain("container builds submit"); + VerifyCommandArgsContain("gcloud container builds submit"); } [TestMethod] - public async Task TestBuildContainerAsync_PassesGivenImageTag() + public async Task TestBuildContainerAsync_ForNewerVersion_RunsGcloudBuildsSubmit() { + _versionResultSource.SetResult(s_buildsEnabledSdkVersion); + await _objectUnderTest.BuildContainerAsync(DefaultImageTag, DefaultContentsPath, _mockedOutputAction); + + VerifyCommandArgsContain("gcloud builds submit"); + VerifyCommandArgs(s => !s.Contains("container")); + } + + [TestMethod] + [DynamicData(nameof(SdkVersions))] + public async Task TestBuildContainerAsync_PassesGivenImageTag(CloudSdkVersions version) + { + _versionResultSource.SetResult(version); const string expectedImageTag = "expected-image-tag"; await _objectUnderTest.BuildContainerAsync(expectedImageTag, DefaultContentsPath, _mockedOutputAction); @@ -261,8 +300,10 @@ public async Task TestBuildContainerAsync_PassesGivenImageTag() } [TestMethod] - public async Task TestBuildContainerAsync_PassesGivenIContentPath() + [DynamicData(nameof(SdkVersions))] + public async Task TestBuildContainerAsync_PassesGivenIContentPath(CloudSdkVersions version) { + _versionResultSource.SetResult(version); const string expectedContentsPath = "expected-contents-path"; await _objectUnderTest.BuildContainerAsync(DefaultImageTag, expectedContentsPath, _mockedOutputAction); @@ -270,8 +311,10 @@ public async Task TestBuildContainerAsync_PassesGivenIContentPath() } [TestMethod] - public async Task TestBuildContainerAsync_PassesHandler() + [DynamicData(nameof(SdkVersions))] + public async Task TestBuildContainerAsync_PassesHandler(CloudSdkVersions version) { + _versionResultSource.SetResult(version); const string expectedOutputLine = "expected-output-line"; SetupRunCommandInvokeHandler(expectedOutputLine); @@ -280,11 +323,16 @@ public async Task TestBuildContainerAsync_PassesHandler() Mock.Get(_mockedOutputAction).Verify(f => f(expectedOutputLine)); } + private static IEnumerable SdkVersionAndBooleans => + SdkVersions.SelectMany(v => new[] { true, false }, (v, b) => new[] { v[0], b }); + [TestMethod] - [DataRow(true)] - [DataRow(false)] - public async Task TestBuildContainerAsync_ReturnsResultFromCommand(bool expectedResult) + [DynamicData(nameof(SdkVersionAndBooleans))] + public async Task TestBuildContainerAsync_ReturnsResultFromCommand( + CloudSdkVersions version, + bool expectedResult) { + _versionResultSource.SetResult(version); SetupRunCommandResult(expectedResult); bool result = await _objectUnderTest.BuildContainerAsync( @@ -305,12 +353,14 @@ private void VerifyCommandOutputArgsContain(string expectedArg) It.IsAny>())); } - private void VerifyCommandArgsContain(string expectedArg) + private void VerifyCommandArgsContain(string expectedArg) => VerifyCommandArgs(s => s.Contains(expectedArg)); + + private void VerifyCommandArgs(Expression> predicateExpression) { _processServiceMock.Verify( p => p.RunCommandAsync( "cmd.exe", - It.Is(s => s.Contains(expectedArg)), + It.Is(predicateExpression), It.IsAny>(), It.IsAny(), It.IsAny>())); @@ -329,6 +379,18 @@ private void SetupRunCommandResult(bool result) .Returns(Task.FromResult(result)); } + private void SetupGetJsonOutput(string command, Task result) + { + _processServiceMock + .Setup( + p => p.GetJsonOutputAsync( + It.IsAny(), + It.Is(s => s.Contains(command)), + null, + It.IsAny>())) + .Returns(result); + } + private void SetupGetJsonOutput(T result) { _processServiceMock