diff --git a/src/Azure.Functions.Cli/Actions/AzureActions/PublishFunctionAppAction.cs b/src/Azure.Functions.Cli/Actions/AzureActions/PublishFunctionAppAction.cs index 191d05761..4cb06fdef 100644 --- a/src/Azure.Functions.Cli/Actions/AzureActions/PublishFunctionAppAction.cs +++ b/src/Azure.Functions.Cli/Actions/AzureActions/PublishFunctionAppAction.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; @@ -10,6 +11,7 @@ using Azure.Functions.Cli.Actions.LocalActions; using Azure.Functions.Cli.Arm.Models; using Azure.Functions.Cli.Common; +using Azure.Functions.Cli.Diagnostics; using Azure.Functions.Cli.Extensions; using Azure.Functions.Cli.Helpers; using Azure.Functions.Cli.Interfaces; @@ -18,6 +20,7 @@ using Microsoft.WindowsAzure.Storage; using Microsoft.WindowsAzure.Storage.Blob; using Newtonsoft.Json; +using NuGet.Common; using static Azure.Functions.Cli.Common.OutputTheme; namespace Azure.Functions.Cli.Actions.AzureActions @@ -105,6 +108,7 @@ public override ICommandLineParserResult ParseArgs(string[] args) .Setup("additional-packages") .WithDescription("List of packages to install when building native dependencies. For example: \"python3-dev libevent-dev\"") .Callback(p => AdditionalPackages = p); + Parser .Setup("force") .WithDescription("Depending on the publish scenario, this will ignore pre-publish checks") @@ -429,7 +433,7 @@ private async Task PublishFunctionApp(Site functionApp, GitIgnoreParser ignorePa var functionAppRoot = ScriptHostHelpers.GetFunctionAppRootDirectory(Environment.CurrentDirectory); // For dedicated linux apps, we do not support run from package right now - var isFunctionAppDedicatedLinux = functionApp.IsLinux && !functionApp.IsDynamic && !functionApp.IsElasticPremium; + var isFunctionAppDedicatedLinux = functionApp.IsLinux && !functionApp.IsDynamic && !functionApp.IsElasticPremium && !functionApp.IsFlex; if (GlobalCoreToolsSettings.CurrentWorkerRuntime == WorkerRuntime.python && !functionApp.IsLinux) { @@ -442,6 +446,7 @@ private async Task PublishFunctionApp(Site functionApp, GitIgnoreParser ignorePa ColoredConsole.WriteLine(WarningColor("Recommend using '--build remote' to resolve project dependencies remotely on Azure")); } + ColoredConsole.WriteLine(GetLogMessage("Starting the function app deployment...")); Func> zipStreamFactory = () => ZipHelper.GetAppZipFile(functionAppRoot, BuildNativeDeps, PublishBuildOption, NoBuild, ignoreParser, AdditionalPackages, ignoreDotNetCheck: true); @@ -457,6 +462,11 @@ private async Task PublishFunctionApp(Site functionApp, GitIgnoreParser ignorePa // Consumption Linux shouldSyncTriggers = await HandleLinuxConsumptionPublish(functionApp, zipStreamFactory); } + else if (functionApp.IsFlex) + { + // Flex + shouldSyncTriggers = await HandleFlexConsumptionPublish(functionApp, zipStreamFactory); + } else if (functionApp.IsLinux && functionApp.IsElasticPremium) { // Elastic Premium Linux @@ -496,8 +506,8 @@ private async Task PublishFunctionApp(Site functionApp, GitIgnoreParser ignorePa { await PublishZipDeploy(functionApp, zipStreamFactory); } - - if (shouldSyncTriggers) + + if (shouldSyncTriggers && !functionApp.IsFlex) { await Task.Delay(TimeSpan.FromSeconds(5)); await SyncTriggers(functionApp); @@ -518,7 +528,7 @@ private async Task SyncTriggers(Site functionApp) { await RetryHelper.Retry(async () => { - ColoredConsole.WriteLine("Syncing triggers..."); + ColoredConsole.WriteLine(GetLogMessage("Syncing triggers...")); HttpResponseMessage response = null; // This SyncTriggers function calls the endpoint for linux syncTriggers response = await AzureHelper.SyncTriggers(functionApp, AccessToken, ManagementURL); @@ -585,6 +595,7 @@ private async Task PerformAppServiceRemoteBuild(Func> zipStreamFact functionApp.AzureAppSettings.Remove("WEBSITE_RUN_FROM_PACKAGE"); appSettingsUpdated = true; } + appSettingsUpdated = functionApp.AzureAppSettings.SafeLeftMerge(Constants.KuduLiteDeploymentConstants.LinuxDedicatedBuildSettings) || appSettingsUpdated; if (appSettingsUpdated) { @@ -602,6 +613,85 @@ await WaitForAppSettingUpdateSCM(functionApp, shouldHaveSettings: functionApp.Az await PerformServerSideBuild(functionApp, zipStreamFactory, pollDedicatedBuild); } + /// + /// Handler for Linux Consumption publish event + /// + /// Function App in Azure + /// Factory for local project zipper + /// ShouldSyncTrigger value + private async Task HandleFlexConsumptionPublish(Site functionApp, Func> zipFileFactory) + { + Task pollDeploymentStatusTask(HttpClient client) => KuduLiteDeploymentHelpers.WaitForFlexDeployment(client, functionApp); + var deploymentParameters = new Dictionary(); + + if (PublishBuildOption == BuildOption.Remote) + { + deploymentParameters.Add("RemoteBuild", true.ToString()); + } + + var deploymentStatus = await PerformFlexDeployment(functionApp, zipFileFactory, pollDeploymentStatusTask, deploymentParameters); + + return deploymentStatus == DeployStatus.Success; + } + + public async Task PerformFlexDeployment(Site functionApp, Func> zipFileFactory, Func> deploymentStatusPollTask, IDictionary deploymentParameters) + { + using (var handler = new ProgressMessageHandler(new HttpClientHandler())) + using (var client = GetRemoteZipClient(functionApp, handler)) + using (var request = new HttpRequestMessage(HttpMethod.Post, new Uri( + $"api/Deploy/Zip?isAsync=true&author={Environment.MachineName}&Deployer=core_tools&{string.Join("&", deploymentParameters?.Select(kvp => $"{kvp.Key}={kvp.Value}")) ?? string.Empty}", UriKind.Relative))) + { + ColoredConsole.WriteLine(GetLogMessage("Creating archive for current directory...")); + + request.Headers.IfMatch.Add(EntityTagHeaderValue.Any); + + (var content, var length) = CreateStreamContentZip(await zipFileFactory()); + request.Content = content; + HttpResponseMessage response = await PublishHelper.InvokeLongRunningRequest(client, handler, request, length, "Uploading"); + await PublishHelper.CheckResponseStatusAsync(response, GetLogMessage("Uploading archive...")); + + // Streaming deployment status for Linux Server Side Build + DeployStatus status = await deploymentStatusPollTask(client); + + if (status == DeployStatus.Success) + { + // the deployment was successful. Waiting for 60 seconds so that Kudu finishes the sync trigger. + await Task.Delay(TimeSpan.FromSeconds(60)); + + // Checking the function app host status + try + { + await AzureHelper.CheckFunctionHostStatusForFlex(functionApp, AccessToken, ManagementURL); + } + catch (Exception) + { + throw new CliException("Deployment was successful but the app appears to be unhealthy, please check the app logs."); + } + + ColoredConsole.WriteLine(VerboseColor(GetLogMessage("The deployment was successful!"))); + } + else if (status == DeployStatus.Failed) + { + throw new CliException("The deployment failed, Please check the printed logs."); + } + else if (status == DeployStatus.Conflict) + { + throw new CliException("Deployment was cancelled, another deployment in progress."); + } + else if (status == DeployStatus.PartialSuccess) + { + ColoredConsole.WriteLine(WarningColor(GetLogMessage("\"Deployment was partially successful, Please check the printed logs."))); + } + else if (status == DeployStatus.Unknown) + { + ColoredConsole.WriteLine(WarningColor(GetLogMessage($"Failed to retrieve deployment status, please visit https://{functionApp.ScmUri}/api/deployments"))); + } + + return status; + } + } + + /// /// Handler for Linux Consumption publish event /// @@ -1069,6 +1159,7 @@ private static (StreamContent, long) CreateStreamContentZip(Stream zipFile) private HttpClient GetRemoteZipClient(Site functionApp, HttpMessageHandler handler = null) { handler = handler ?? new HttpClientHandler(); + var client = new HttpClient(handler) { BaseAddress = new Uri($"https://{functionApp.ScmUri}"), @@ -1109,6 +1200,16 @@ private static string NormalizeDotnetFrameworkVersion(string version) return $"{parsedVersion.Major}.{parsedVersion.Minor}"; } + private string GetLogMessage(string message) + { + return GetLogPrefix() + message; + } + + private string GetLogPrefix() + { + return $"[{DateTime.UtcNow.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffZ", CultureInfo.InvariantCulture)}] ".ToString(); + } + // For testing internal class AzureHelperService { diff --git a/src/Azure.Functions.Cli/Arm/Models/Site.cs b/src/Azure.Functions.Cli/Arm/Models/Site.cs index 201ccc1f8..73115aa65 100644 --- a/src/Azure.Functions.Cli/Arm/Models/Site.cs +++ b/src/Azure.Functions.Cli/Arm/Models/Site.cs @@ -40,6 +40,9 @@ public bool IsLinux public bool IsDynamic => Sku?.Equals("dynamic", StringComparison.OrdinalIgnoreCase) == true; + public bool IsFlex + => Sku?.Equals("flexconsumption", StringComparison.OrdinalIgnoreCase) == true; + public bool IsElasticPremium => Sku?.Equals("elasticpremium", StringComparison.OrdinalIgnoreCase) == true; diff --git a/src/Azure.Functions.Cli/Common/DeploymentStatus.cs b/src/Azure.Functions.Cli/Common/DeploymentStatus.cs index c70b7ab98..9d385dc43 100644 --- a/src/Azure.Functions.Cli/Common/DeploymentStatus.cs +++ b/src/Azure.Functions.Cli/Common/DeploymentStatus.cs @@ -11,6 +11,8 @@ public enum DeployStatus Building = 1, Deploying = 2, Failed = 3, - Success = 4 + Success = 4, + Conflict = 5, + PartialSuccess = 6 } } diff --git a/src/Azure.Functions.Cli/Helpers/AzureHelper.cs b/src/Azure.Functions.Cli/Helpers/AzureHelper.cs index 4096c49c0..a22f4fe63 100644 --- a/src/Azure.Functions.Cli/Helpers/AzureHelper.cs +++ b/src/Azure.Functions.Cli/Helpers/AzureHelper.cs @@ -66,6 +66,7 @@ private static async Task TryGetFunctionAppFromArg(string name, IEnumerabl try { string siteId = await GetResourceIDFromArg(subscriptions, query, accessToken, managementURL); + var app = new Site(siteId); // The implementation of the load function is different for certain function apps like function apps based on Container Apps. @@ -122,6 +123,7 @@ internal static async Task GetResourceIDFromArg(IEnumerable subI ?? throw new CliException("Error finding the Azure Resource information."); } + internal static async Task IsBasicAuthAllowedForSCM(Site functionApp, string accessToken, string managementURL) { var url = new Uri($"{managementURL}{functionApp.SiteId}/basicPublishingCredentialsPolicies/scm?api-version={ArmUriTemplates.BasicAuthCheckApiVersion}"); @@ -362,6 +364,53 @@ public static Task SyncTriggers(Site functionApp, string ac return ArmClient.HttpInvoke(HttpMethod.Post, url, accessToken); } + internal static async Task CheckFunctionHostStatusForFlex(Site functionApp, string accessToken, string managementURL, + HttpMessageHandler messageHandler = null) + { + ColoredConsole.Write("Checking the app health..."); + var masterKey = await GetMasterKeyAsync(functionApp.SiteId, accessToken, managementURL); + + if (masterKey is null) + { + throw new CliException($"The masterKey is null. hostname: {functionApp.HostName}."); + } + + HttpMessageHandler handler = messageHandler ?? new HttpClientHandler(); + if (StaticSettings.IsDebug) + { + handler = new LoggingHandler(handler); + } + + var functionAppReadyClient = new HttpClient(handler); + const string jsonContentType = "application/json"; + functionAppReadyClient.DefaultRequestHeaders.Add("User-Agent", Constants.CliUserAgent); + functionAppReadyClient.DefaultRequestHeaders.Add("Accept", jsonContentType); + + await RetryHelper.Retry(async () => + { + functionAppReadyClient.DefaultRequestHeaders.Add("x-ms-request-id", Guid.NewGuid().ToString()); + var uri = new Uri($"https://{functionApp.HostName}/admin/host/status?code={masterKey}"); + var request = new HttpRequestMessage() + { + RequestUri = uri, + Method = HttpMethod.Get + }; + + var response = await functionAppReadyClient.SendAsync(request); + ColoredConsole.Write("."); + + if (response.IsSuccessStatusCode) + { + ColoredConsole.WriteLine(" done"); + } + else + { + throw new CliException($"The host didn't return success status. Returned: {response.StatusCode}"); + } + + }, 15, TimeSpan.FromSeconds(3)); + } + public static async Task LoadSiteObjectAsync(Site site, string accessToken, string managementURL) { var url = new Uri($"{managementURL}{site.SiteId}?api-version={ArmUriTemplates.WebsitesApiVersion}"); @@ -558,10 +607,10 @@ public static async Task PrintFunctionsInfo(Site functionApp, string accessToken } ArmArrayWrapper functions = await GetFunctions(functionApp, accessToken, managementURL); - functions = await GetFunctions(functionApp, accessToken, managementURL); ColoredConsole.WriteLine(TitleColor($"Functions in {functionApp.SiteName}:")); + if (functionApp.IsKubeApp && !functions.value.Any()) { ColoredConsole.WriteLine(WarningColor( diff --git a/src/Azure.Functions.Cli/Helpers/KuduLiteDeploymentHelpers.cs b/src/Azure.Functions.Cli/Helpers/KuduLiteDeploymentHelpers.cs index 8e04c4d5f..571de6523 100644 --- a/src/Azure.Functions.Cli/Helpers/KuduLiteDeploymentHelpers.cs +++ b/src/Azure.Functions.Cli/Helpers/KuduLiteDeploymentHelpers.cs @@ -50,15 +50,79 @@ public static async Task WaitForRemoteBuild(HttpClient client, Sit return statusCode; } + + public static async Task WaitForFlexDeployment(HttpClient client, Site functionApp) + { + ColoredConsole.WriteLine("Deployment in progress, please wait..."); + DeployStatus statusCode = DeployStatus.Pending; + DateTime logLastUpdate = DateTime.MinValue; + string id = null; + + while (string.IsNullOrEmpty(id)) + { + await Task.Delay(TimeSpan.FromSeconds(Constants.KuduLiteDeploymentConstants.StatusRefreshSeconds)); + id = await GetLatestDeploymentId(client, functionApp); + } + + while(statusCode != DeployStatus.Success && statusCode != DeployStatus.Failed && statusCode != DeployStatus.Unknown && statusCode != DeployStatus.Conflict && statusCode != DeployStatus.PartialSuccess) + { + try + { + statusCode = await GetDeploymentStatusById(client, functionApp, id); + } + catch (HttpRequestException) + { + return DeployStatus.Unknown; + } + + await Task.Delay(TimeSpan.FromSeconds(Constants.KuduLiteDeploymentConstants.StatusRefreshSeconds)); + } + + + // Safely printing logs after the status is confirmed. + try + { + await DisplayDeploymentLog(client, functionApp, id, logLastUpdate); + } + catch (Exception) + { + // ignore the fetch log failure. + } + + return statusCode; + } + + private static async Task GetLatestDeploymentId(HttpClient client, Site functionApp) { - var deployments = await InvokeRequest>(client, HttpMethod.Get, "/deployments"); + var deploymentUrl = "/deployments"; + if (functionApp.IsFlex) + { + deploymentUrl = "api/deployments"; + } + + var deployments = await InvokeRequest>(client, HttpMethod.Get, deploymentUrl); + + if (functionApp.IsFlex) + { + if (!deployments.Any()) + { + await Task.Delay(TimeSpan.FromSeconds(20)); + deployments = await InvokeRequest>(client, HttpMethod.Get, deploymentUrl); + + if (!deployments.Any()) + { + throw new CliException("The deployment ID couldn't be found. Please try again."); + } + } + } // Automatically ordered by received time var latestDeployment = deployments.First(); DeployStatus? status = latestDeployment.Status; if (status == DeployStatus.Building || status == DeployStatus.Deploying - || status == DeployStatus.Success || status == DeployStatus.Failed) + || status == DeployStatus.Success || status == DeployStatus.Failed + || status == DeployStatus.Conflict || status == DeployStatus.PartialSuccess) { return latestDeployment.Id; } @@ -67,7 +131,13 @@ private static async Task GetLatestDeploymentId(HttpClient client, Site private static async Task GetDeploymentStatusById(HttpClient client, Site functionApp, string id) { - var deploymentInfo = await InvokeRequest(client, HttpMethod.Get, $"/deployments/{id}"); + var deploymentUrl = $"/deployments/{id}"; + if (functionApp.IsFlex) + { + deploymentUrl = $"/api/deployments/{id}"; + } + + var deploymentInfo = await InvokeRequest(client, HttpMethod.Get, deploymentUrl); DeployStatus? status = deploymentInfo.Status; if (status == null) { @@ -78,7 +148,7 @@ private static async Task GetDeploymentStatusById(HttpClient clien private static async Task DisplayDeploymentLog(HttpClient client, Site functionApp, string id, DateTime lastUpdate, Uri innerUrl = null, StringBuilder innerLogger = null) { - string logUrl = innerUrl != null ? innerUrl.ToString() : $"/deployments/{id}/log"; + string logUrl = innerUrl != null ? innerUrl.ToString() : (functionApp.IsFlex? $"/api/deployments/{id}/log" : $"/deployments/{id}/log"); StringBuilder sbLogger = innerLogger != null ? innerLogger : new StringBuilder(); var deploymentLogs = await InvokeRequest>(client, HttpMethod.Get, logUrl);