Skip to content

Commit 79cfd1b

Browse files
Copilotvhvb1989Copilot
authored
Add App Service deployment slot routing based on deployment history (#6627)
* fix security issue with playwright/test 1.49.1 (#6592) * Checkpoint from VS Code for cloud agent session * Add unit tests for app service slot deployment methods Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> * Address code review feedback: add nil checks, improve error messages, and fix file handle cleanup Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> * Final implementation of App Service slots deployment - Phase 1 Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> * Remove unintended changes to extension files Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> * support env var to skip prompt in multi-slot scenario * cspell little fix * add functional test for slot deployment * Update cli/azd/pkg/project/service_target_appservice.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * endpoints * Refactor test health probe to use serviceHealthProber struct with session-based retry delay Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> * Remove ctx and t from serviceHealthProber struct, pass as method parameters instead Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> * Replace cleanupResourceGroup with existing cleanupRg function Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> * recording * Update deployment messages to mirror Azure Portal terminology Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> --------- Co-authored-by: Victor Vazquez <vhvb1989@gmail.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: vhvb1989 <24213737+vhvb1989@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent c45c7fd commit 79cfd1b

File tree

13 files changed

+4417
-22
lines changed

13 files changed

+4417
-22
lines changed

cli/azd/pkg/azapi/webapp.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,115 @@ func (cli *AzureClient) createZipDeployClient(
226226

227227
return client, nil
228228
}
229+
230+
// HasAppServiceDeployments checks if the web app has at least one previous deployment.
231+
func (cli *AzureClient) HasAppServiceDeployments(
232+
ctx context.Context,
233+
subscriptionId string,
234+
resourceGroup string,
235+
appName string,
236+
) (bool, error) {
237+
client, err := cli.createWebAppsClient(ctx, subscriptionId)
238+
if err != nil {
239+
return false, err
240+
}
241+
242+
pager := client.NewListDeploymentsPager(resourceGroup, appName, nil)
243+
if pager.More() {
244+
page, err := pager.NextPage(ctx)
245+
if err != nil {
246+
return false, fmt.Errorf("listing webapp deployments: %w", err)
247+
}
248+
return len(page.Value) > 0, nil
249+
}
250+
251+
return false, nil
252+
}
253+
254+
// AppServiceSlot represents an App Service deployment slot.
255+
type AppServiceSlot struct {
256+
Name string
257+
}
258+
259+
// GetAppServiceSlots returns a list of deployment slots for the specified web app.
260+
func (cli *AzureClient) GetAppServiceSlots(
261+
ctx context.Context,
262+
subscriptionId string,
263+
resourceGroup string,
264+
appName string,
265+
) ([]AppServiceSlot, error) {
266+
client, err := cli.createWebAppsClient(ctx, subscriptionId)
267+
if err != nil {
268+
return nil, err
269+
}
270+
271+
var slots []AppServiceSlot
272+
pager := client.NewListSlotsPager(resourceGroup, appName, nil)
273+
for pager.More() {
274+
page, err := pager.NextPage(ctx)
275+
if err != nil {
276+
return nil, fmt.Errorf("listing webapp slots: %w", err)
277+
}
278+
for _, slot := range page.Value {
279+
if slot.Name != nil {
280+
// Slot names are returned as "appName/slotName", extract just the slot name
281+
slotName := *slot.Name
282+
if idx := strings.LastIndex(slotName, "/"); idx != -1 {
283+
slotName = slotName[idx+1:]
284+
}
285+
slots = append(slots, AppServiceSlot{Name: slotName})
286+
}
287+
}
288+
}
289+
290+
return slots, nil
291+
}
292+
293+
// DeployAppServiceSlotZip deploys a zip file to a specific deployment slot.
294+
func (cli *AzureClient) DeployAppServiceSlotZip(
295+
ctx context.Context,
296+
subscriptionId string,
297+
resourceGroup string,
298+
appName string,
299+
slotName string,
300+
deployZipFile io.ReadSeeker,
301+
progressLog func(string),
302+
) (*string, error) {
303+
client, err := cli.createWebAppsClient(ctx, subscriptionId)
304+
if err != nil {
305+
return nil, err
306+
}
307+
308+
slot, err := client.GetSlot(ctx, resourceGroup, appName, slotName, nil)
309+
if err != nil {
310+
return nil, fmt.Errorf("failed retrieving webapp slot: %w", err)
311+
}
312+
313+
// Find the repository hostname for the slot
314+
hostName := ""
315+
if slot.Properties != nil {
316+
for _, item := range slot.Properties.HostNameSSLStates {
317+
if item != nil && item.HostType != nil && item.Name != nil &&
318+
*item.HostType == armappservice.HostTypeRepository {
319+
hostName = *item.Name
320+
break
321+
}
322+
}
323+
}
324+
325+
if hostName == "" {
326+
return nil, fmt.Errorf("failed to find repository host name for slot %s", slotName)
327+
}
328+
329+
zipDeployClient, err := cli.createZipDeployClient(ctx, subscriptionId, hostName)
330+
if err != nil {
331+
return nil, err
332+
}
333+
334+
response, err := zipDeployClient.Deploy(ctx, deployZipFile)
335+
if err != nil {
336+
return nil, err
337+
}
338+
339+
return to.Ptr(response.StatusText), nil
340+
}
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package azapi
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"net/http"
10+
"strings"
11+
"testing"
12+
13+
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
14+
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2"
15+
"github.com/azure/azure-dev/cli/azd/pkg/azsdk"
16+
"github.com/azure/azure-dev/cli/azd/test/mocks"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
// Test HasAppServiceDeployments
21+
func Test_HasAppServiceDeployments(t *testing.T) {
22+
t.Run("HasDeployments", func(t *testing.T) {
23+
mockContext := mocks.NewMockContext(context.Background())
24+
azCli := newAzureClientFromMockContext(mockContext)
25+
26+
mockContext.HttpClient.When(func(request *http.Request) bool {
27+
return request.Method == http.MethodGet &&
28+
strings.Contains(request.URL.Path, "/deployments")
29+
}).RespondFn(func(request *http.Request) (*http.Response, error) {
30+
response := armappservice.WebAppsClientListDeploymentsResponse{
31+
DeploymentCollection: armappservice.DeploymentCollection{
32+
Value: []*armappservice.Deployment{
33+
{
34+
ID: to.Ptr("deployment-1"),
35+
Name: to.Ptr("deployment-1"),
36+
},
37+
},
38+
},
39+
}
40+
return mocks.CreateHttpResponseWithBody(request, http.StatusOK, response)
41+
})
42+
43+
hasDeployments, err := azCli.HasAppServiceDeployments(
44+
*mockContext.Context,
45+
"SUBSCRIPTION_ID",
46+
"RESOURCE_GROUP_ID",
47+
"WEB_APP_NAME",
48+
)
49+
50+
require.NoError(t, err)
51+
require.True(t, hasDeployments)
52+
})
53+
54+
t.Run("NoDeployments", func(t *testing.T) {
55+
mockContext := mocks.NewMockContext(context.Background())
56+
azCli := newAzureClientFromMockContext(mockContext)
57+
58+
mockContext.HttpClient.When(func(request *http.Request) bool {
59+
return request.Method == http.MethodGet &&
60+
strings.Contains(request.URL.Path, "/deployments")
61+
}).RespondFn(func(request *http.Request) (*http.Response, error) {
62+
response := armappservice.WebAppsClientListDeploymentsResponse{
63+
DeploymentCollection: armappservice.DeploymentCollection{
64+
Value: []*armappservice.Deployment{},
65+
},
66+
}
67+
return mocks.CreateHttpResponseWithBody(request, http.StatusOK, response)
68+
})
69+
70+
hasDeployments, err := azCli.HasAppServiceDeployments(
71+
*mockContext.Context,
72+
"SUBSCRIPTION_ID",
73+
"RESOURCE_GROUP_ID",
74+
"WEB_APP_NAME",
75+
)
76+
77+
require.NoError(t, err)
78+
require.False(t, hasDeployments)
79+
})
80+
}
81+
82+
// Test GetAppServiceSlots
83+
func Test_GetAppServiceSlots(t *testing.T) {
84+
t.Run("WithSlots", func(t *testing.T) {
85+
mockContext := mocks.NewMockContext(context.Background())
86+
azCli := newAzureClientFromMockContext(mockContext)
87+
88+
mockContext.HttpClient.When(func(request *http.Request) bool {
89+
return request.Method == http.MethodGet &&
90+
strings.Contains(request.URL.Path, "/slots")
91+
}).RespondFn(func(request *http.Request) (*http.Response, error) {
92+
response := armappservice.WebAppsClientListSlotsResponse{
93+
WebAppCollection: armappservice.WebAppCollection{
94+
Value: []*armappservice.Site{
95+
{
96+
Name: to.Ptr("WEB_APP_NAME/staging"),
97+
},
98+
{
99+
Name: to.Ptr("WEB_APP_NAME/production"),
100+
},
101+
},
102+
},
103+
}
104+
return mocks.CreateHttpResponseWithBody(request, http.StatusOK, response)
105+
})
106+
107+
slots, err := azCli.GetAppServiceSlots(
108+
*mockContext.Context,
109+
"SUBSCRIPTION_ID",
110+
"RESOURCE_GROUP_ID",
111+
"WEB_APP_NAME",
112+
)
113+
114+
require.NoError(t, err)
115+
require.Len(t, slots, 2)
116+
require.Equal(t, "staging", slots[0].Name)
117+
require.Equal(t, "production", slots[1].Name)
118+
})
119+
120+
t.Run("NoSlots", func(t *testing.T) {
121+
mockContext := mocks.NewMockContext(context.Background())
122+
azCli := newAzureClientFromMockContext(mockContext)
123+
124+
mockContext.HttpClient.When(func(request *http.Request) bool {
125+
return request.Method == http.MethodGet &&
126+
strings.Contains(request.URL.Path, "/slots")
127+
}).RespondFn(func(request *http.Request) (*http.Response, error) {
128+
response := armappservice.WebAppsClientListSlotsResponse{
129+
WebAppCollection: armappservice.WebAppCollection{
130+
Value: []*armappservice.Site{},
131+
},
132+
}
133+
return mocks.CreateHttpResponseWithBody(request, http.StatusOK, response)
134+
})
135+
136+
slots, err := azCli.GetAppServiceSlots(
137+
*mockContext.Context,
138+
"SUBSCRIPTION_ID",
139+
"RESOURCE_GROUP_ID",
140+
"WEB_APP_NAME",
141+
)
142+
143+
require.NoError(t, err)
144+
require.Len(t, slots, 0)
145+
})
146+
}
147+
148+
// Test DeployAppServiceSlotZip
149+
func Test_DeployAppServiceSlotZip(t *testing.T) {
150+
t.Run("Success", func(t *testing.T) {
151+
mockContext := mocks.NewMockContext(context.Background())
152+
azCli := newAzureClientFromMockContext(mockContext)
153+
154+
// Mock GetSlot call
155+
mockContext.HttpClient.When(func(request *http.Request) bool {
156+
return request.Method == http.MethodGet &&
157+
strings.Contains(request.URL.Path, "/slots/staging")
158+
}).RespondFn(func(request *http.Request) (*http.Response, error) {
159+
response := armappservice.WebAppsClientGetSlotResponse{
160+
Site: armappservice.Site{
161+
Location: to.Ptr("eastus2"),
162+
Kind: to.Ptr("app"),
163+
Name: to.Ptr("WEB_APP_NAME/staging"),
164+
Properties: &armappservice.SiteProperties{
165+
DefaultHostName: to.Ptr("WEB_APP_NAME-staging.azurewebsites.net"),
166+
HostNameSSLStates: []*armappservice.HostNameSSLState{
167+
{
168+
HostType: to.Ptr(armappservice.HostTypeRepository),
169+
Name: to.Ptr("WEB_APP_NAME_STAGING_SCM_HOST"),
170+
},
171+
},
172+
},
173+
},
174+
}
175+
return mocks.CreateHttpResponseWithBody(request, http.StatusOK, response)
176+
})
177+
178+
// Mock zip deploy call (returns accepted with location header)
179+
mockContext.HttpClient.When(func(request *http.Request) bool {
180+
return request.Method == http.MethodPost &&
181+
request.URL.Host == "WEB_APP_NAME_STAGING_SCM_HOST" &&
182+
strings.Contains(request.URL.Path, "/api/zipdeploy")
183+
}).RespondFn(func(request *http.Request) (*http.Response, error) {
184+
response, _ := mocks.CreateEmptyHttpResponse(request, http.StatusAccepted)
185+
response.Header.Set("Location", "https://WEB_APP_NAME_STAGING_SCM_HOST/deployments/latest")
186+
return response, nil
187+
})
188+
189+
// Mock polling for deployment status
190+
mockContext.HttpClient.When(func(request *http.Request) bool {
191+
return request.Method == http.MethodGet &&
192+
request.URL.Host == "WEB_APP_NAME_STAGING_SCM_HOST" &&
193+
strings.Contains(request.URL.Path, "/deployments/latest")
194+
}).RespondFn(func(request *http.Request) (*http.Response, error) {
195+
completeStatus := azsdk.DeployStatusResponse{
196+
DeployStatus: azsdk.DeployStatus{
197+
Id: "deployment-id",
198+
Status: http.StatusOK,
199+
StatusText: "OK",
200+
Message: "Deployment Complete",
201+
Complete: true,
202+
Active: true,
203+
SiteName: "WEB_APP_NAME",
204+
},
205+
}
206+
return mocks.CreateHttpResponseWithBody(request, http.StatusOK, completeStatus)
207+
})
208+
209+
zipFile := bytes.NewReader([]byte{})
210+
211+
res, err := azCli.DeployAppServiceSlotZip(
212+
*mockContext.Context,
213+
"SUBSCRIPTION_ID",
214+
"RESOURCE_GROUP_ID",
215+
"WEB_APP_NAME",
216+
"staging",
217+
zipFile,
218+
func(s string) {},
219+
)
220+
221+
require.NoError(t, err)
222+
require.NotNil(t, res)
223+
})
224+
225+
t.Run("SlotNotFound", func(t *testing.T) {
226+
mockContext := mocks.NewMockContext(context.Background())
227+
azCli := newAzureClientFromMockContext(mockContext)
228+
229+
// Mock GetSlot call to return 404
230+
mockContext.HttpClient.When(func(request *http.Request) bool {
231+
return request.Method == http.MethodGet &&
232+
strings.Contains(request.URL.Path, "/slots/nonexistent")
233+
}).RespondFn(func(request *http.Request) (*http.Response, error) {
234+
response, _ := mocks.CreateEmptyHttpResponse(request, http.StatusNotFound)
235+
return response, nil
236+
})
237+
238+
zipFile := bytes.NewReader([]byte{})
239+
240+
res, err := azCli.DeployAppServiceSlotZip(
241+
*mockContext.Context,
242+
"SUBSCRIPTION_ID",
243+
"RESOURCE_GROUP_ID",
244+
"WEB_APP_NAME",
245+
"nonexistent",
246+
zipFile,
247+
func(s string) {},
248+
)
249+
250+
require.Error(t, err)
251+
require.Nil(t, res)
252+
})
253+
}

0 commit comments

Comments
 (0)