cli/azd/pkg/azsdk/zip_deploy_client.go (339 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package azsdk import ( "context" "errors" "fmt" "io" "net/http" "strings" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" armruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/runtime" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2" "github.com/azure/azure-dev/cli/azd/pkg/httputil" "go.opentelemetry.io/otel/trace" ) const ( deployStatusInterval = 10 * time.Second ) // ZipDeployClient wraps usage of app service zip deploy used for application deployments // More info can be found at the following: // https://github.com/MicrosoftDocs/azure-docs/blob/main/includes/app-service-deploy-zip-push-rest.md // https://github.com/projectkudu/kudu/wiki/REST-API type ZipDeployClient struct { hostName string pipeline runtime.Pipeline cred azcore.TokenCredential armClientOptions *arm.ClientOptions } type DeployResponse struct { DeployStatus } type DeployStatusResponse struct { DeployStatus } type DeployStatus struct { Id string `json:"id"` Status int `json:"status"` StatusText string `json:"status_text"` Message string `json:"message"` Progress *string `json:"progress"` ReceivedTime *time.Time `json:"received_time"` StartTime *time.Time `json:"start_time"` EndTime *time.Time `json:"end_time"` Complete bool `json:"complete"` Active bool `json:"active"` LogUrl string `json:"log_url"` SiteName string `json:"site_name"` } // Creates a new ZipDeployClient instance func NewZipDeployClient( hostName string, credential azcore.TokenCredential, armClientOptions *arm.ClientOptions, ) (*ZipDeployClient, error) { zipDeployOptions := &arm.ClientOptions{} if armClientOptions != nil { optionsCopy := *armClientOptions zipDeployOptions = &optionsCopy } // We do not have a Resource provider to register zipDeployOptions.DisableRPRegistration = true // Increase default retry attempts from 3 to 4 as zipdeploy often fails with 3 retries. // With the default azcore.policy options of 800ms RetryDelay, this introduces up to 20 seconds of exponential back-off. zipDeployOptions.Retry = policy.RetryOptions{ MaxRetries: 4, } pipeline, err := armruntime.NewPipeline("zip-deploy", "1.0.0", credential, runtime.PipelineOptions{}, zipDeployOptions) if err != nil { return nil, fmt.Errorf("failed creating HTTP pipeline: %w", err) } return &ZipDeployClient{ hostName: hostName, pipeline: pipeline, cred: credential, armClientOptions: armClientOptions, }, nil } // Begins a zip deployment and returns a poller to check for status func (c *ZipDeployClient) BeginDeploy( ctx context.Context, zipFile io.ReadSeeker, ) (*runtime.Poller[*DeployResponse], error) { request, err := c.createDeployRequest(ctx, zipFile) if err != nil { return nil, err } response, err := c.pipeline.Do(request) if err != nil { return nil, err } defer response.Body.Close() if !runtime.HasStatusCode(response, http.StatusAccepted) { return nil, runtime.NewResponseError(response) } var finalResponse *DeployResponse pollerOptions := &runtime.NewPollerOptions[*DeployResponse]{ Response: &finalResponse, Handler: newDeployPollingHandler(c.pipeline, response), } return runtime.NewPoller(response, c.pipeline, pollerOptions) } // Deploys the specified application zip to the azure app service using deployment status api and waits for completion func (c *ZipDeployClient) BeginDeployTrackStatus( ctx context.Context, zipFile io.ReadSeeker, subscriptionId, resourceGroup, appName string, ) (*runtime.Poller[armappservice.WebAppsClientGetProductionSiteDeploymentStatusResponse], error) { request, err := c.createDeployRequest(ctx, zipFile) if err != nil { return nil, err } response, err := c.pipeline.Do(request) if err != nil { return nil, err } defer response.Body.Close() if !runtime.HasStatusCode(response, http.StatusAccepted) { return nil, runtime.NewResponseError(response) } client, err := armappservice.NewWebAppsClient(subscriptionId, c.cred, c.armClientOptions) if err != nil { return nil, fmt.Errorf("creating web app client: %w", err) } deploymentStatusId := response.Header.Get("Scm-Deployment-Id") if deploymentStatusId == "" { return nil, fmt.Errorf("empty deployment status id") } // Add 404 to default retry errors in azure-sdk-for-go. We get temporary 404s when the KUDO API received the request // and created a temp deployment id as a intermediate step before deployed with actual deployment id retryCtx := policy.WithRetryOptions(ctx, policy.RetryOptions{ MaxRetries: 4, RetryDelay: 5 * time.Second, StatusCodes: append([]int{ http.StatusRequestTimeout, // 408 http.StatusTooManyRequests, // 429 http.StatusInternalServerError, // 500 http.StatusBadGateway, // 502 http.StatusServiceUnavailable, // 503 http.StatusGatewayTimeout, // 504 }, http.StatusNotFound), // 404 }) // nolint:lll // Example definition: https://github.com/Azure/azure-rest-api-specs/tree/main/specification/web/resource-manager/Microsoft.Web/stable/2022-03-01/examples/GetSiteDeploymentStatus.json poller, err := client.BeginGetProductionSiteDeploymentStatus(retryCtx, resourceGroup, appName, deploymentStatusId, nil) if err != nil { return nil, fmt.Errorf("getting deployment status: %w", err) } return poller, nil } func logWebAppDeploymentStatus( res armappservice.WebAppsClientGetProductionSiteDeploymentStatusResponse, traceId string, progressLog func(string), ) error { if (res == armappservice.WebAppsClientGetProductionSiteDeploymentStatusResponse{} || res.CsmDeploymentStatus == armappservice.CsmDeploymentStatus{} || res.CsmDeploymentStatus.Properties == nil) { return fmt.Errorf("response or its properties are empty") } properties := res.CsmDeploymentStatus.Properties inProgressNumber := int(*properties.NumberOfInstancesInProgress) successNumber := int(*properties.NumberOfInstancesSuccessful) failNumber := int(*properties.NumberOfInstancesFailed) errorString := "" logErrorFunction := func(properties *armappservice.CsmDeploymentStatusProperties, message string) { for _, err := range properties.Errors { if err.Message != nil { errorString += fmt.Sprintf("Error: %s\n", *err.Message) } } for _, log := range properties.FailedInstancesLogs { errorString += fmt.Sprintf("Please check the %slogs for more info: %s\n", message, *log) } if traceId != "" { errorString += fmt.Sprintf("Trace ID: %s\n", traceId) } } status := *properties.Status switch status { case armappservice.DeploymentBuildStatusBuildRequestReceived: progressLog("Received build request, starting build process") return nil case armappservice.DeploymentBuildStatusBuildInProgress: progressLog("Running build process") return nil case armappservice.DeploymentBuildStatusRuntimeStarting: progressLog(fmt.Sprintf("Starting runtime process, %d in progress instances, %d successful instances", inProgressNumber, successNumber)) return nil case armappservice.DeploymentBuildStatusRuntimeSuccessful, armappservice.DeploymentBuildStatusBuildSuccessful: return nil case armappservice.DeploymentBuildStatusRuntimeFailed: totalNumber := inProgressNumber + successNumber + failNumber if successNumber > 0 { errorString += fmt.Sprintf("%d/%d instances failed to start successfully\n", failNumber, totalNumber) } else if totalNumber > 0 { errorString += fmt.Sprintf("Deployment failed because the runtime process failed. In progress instances: %d, "+ "Successful instances: %d, Failed Instances: %d\n", inProgressNumber, successNumber, failNumber) } logErrorFunction(properties, "runtime ") return errors.New(errorString) case armappservice.DeploymentBuildStatusBuildFailed: errorString += "Deployment failed because the build process failed\n" logErrorFunction(properties, "build ") return errors.New(errorString) // Progress Log for other states default: if len(status) > 0 { progressLog(fmt.Sprintf("Running deployment status api in stage %s", status)) } return nil } } func (c *ZipDeployClient) DeployTrackStatus( ctx context.Context, zipFile io.ReadSeeker, subscriptionId string, resourceGroup string, appName string, progressLog func(string)) error { var response armappservice.WebAppsClientGetProductionSiteDeploymentStatusResponse poller, err := c.BeginDeployTrackStatus(ctx, zipFile, subscriptionId, resourceGroup, appName) if err != nil { return err } delay := 3 * time.Second pollCount := 0 for { var resp *http.Response resp, err = poller.Poll(ctx) if err != nil { return err } if resp.StatusCode == http.StatusInternalServerError { return runtime.NewResponseError(resp) } if err := runtime.UnmarshalAsJSON(resp, &response); err != nil { return err } if poller.Done() { status := *response.Properties.Status if status != armappservice.DeploymentBuildStatusRuntimeSuccessful && status != armappservice.DeploymentBuildStatusBuildFailed && status != armappservice.DeploymentBuildStatusRuntimeFailed { return fmt.Errorf("deployment status API unexpectedly terminated at stage %s", status) } spanCtx := trace.SpanContextFromContext(ctx) traceId := spanCtx.TraceID().String() if err = logWebAppDeploymentStatus(response, traceId, progressLog); err != nil { return err } break } if err = logWebAppDeploymentStatus(response, "", progressLog); err != nil { return err } // Wait longer after a few initial tries if pollCount > 20 { delay = 20 * time.Second } select { case <-ctx.Done(): return ctx.Err() case <-time.After(delay): pollCount++ } } return nil } // Deploys the specified application zip to the azure app service and waits for completion func (c *ZipDeployClient) Deploy(ctx context.Context, zipFile io.ReadSeeker) (*DeployResponse, error) { poller, err := c.BeginDeploy(ctx, zipFile) if err != nil { return nil, err } response, err := poller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{ Frequency: deployStatusInterval, }) if err != nil { return nil, err } return response, nil } // Creates the HTTP request for the zip deployment operation func (c *ZipDeployClient) createDeployRequest( ctx context.Context, zipFile io.ReadSeeker, ) (*policy.Request, error) { endpoint := fmt.Sprintf("https://%s/api/zipdeploy", c.hostName) req, err := runtime.NewRequest(ctx, http.MethodPost, endpoint) if err != nil { return nil, fmt.Errorf("creating deploy request: %w", err) } if err = req.SetBody(streaming.NopCloser(zipFile), "application/octet-stream"); err != nil { return nil, fmt.Errorf("setting request body: %w", err) } rawRequest := req.Raw() query := rawRequest.URL.Query() query.Set("isAsync", "true") rawRequest.Header.Set("Accept", "application/json") rawRequest.URL.RawQuery = query.Encode() return req, nil } // Implementation of a Go SDK polling handler for async zip deploy operations type deployPollingHandler struct { pipeline runtime.Pipeline response *http.Response result *DeployStatusResponse } func newDeployPollingHandler(pipeline runtime.Pipeline, response *http.Response) *deployPollingHandler { return &deployPollingHandler{ pipeline: pipeline, response: response, } } // Checks whether the long running deploy operation is complete func (h *deployPollingHandler) Done() bool { return h.result != nil && h.result.Complete } // Executing the polling logic to check the status of the deploy operation func (h *deployPollingHandler) Poll(ctx context.Context) (*http.Response, error) { location := h.response.Header.Get("Location") if strings.TrimSpace(location) == "" { return nil, fmt.Errorf("missing polling location header") } req, err := runtime.NewRequest(ctx, http.MethodGet, location) if err != nil { return nil, err } response, err := h.pipeline.Do(req) if err != nil { return nil, err } if !runtime.HasStatusCode(response, http.StatusAccepted) && !runtime.HasStatusCode(response, http.StatusOK) { return nil, runtime.NewResponseError(response) } // If response is 202 - we're still waiting if runtime.HasStatusCode(response, http.StatusAccepted) { return response, nil } // Status code is 200 if we get to this point - transform the response deploymentStatus, err := httputil.ReadRawResponse[DeployStatusResponse](response) if err != nil { return nil, err } h.result = deploymentStatus return response, nil } // Gets the result of the deploy operation func (h *deployPollingHandler) Result(ctx context.Context, out **DeployResponse) error { *out = &DeployResponse{ DeployStatus: h.result.DeployStatus, } return nil }