From a489143adfdf1a653f2b7b6a24dd80ad96063656 Mon Sep 17 00:00:00 2001 From: Witness Patrol Date: Mon, 26 Oct 2026 07:41:56 -0500 Subject: [PATCH 001/390] feat(azureappservice): deployment slots for web apps and function apps Slot CRUD, per-slot config/settings/lifecycle, slot hostnames and Kudu on the shared listener, slotConfigNames sticky settings, swap, swap with preview, slotsdiffs, plan-tier slot limits, cascade delete and persistence. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FGWXecoauvZN2AYe1vkvgM (cherry picked from commit 62f144c8dbf40c7c87b4c2dac5608236e34ab909) --- .../app/azureappservice_slots_sdk_test.go | 747 +++++++++++++++ internal/app/azurefunctions_sdk_test.go | 26 +- internal/app/azurefunctions_wiring.go | 13 +- .../testdata/snapshot_inventory.json | 11 + services/azurearm/errors.go | 4 + services/azurearm/interfaces_web.go | 8 +- services/azurearm/rp_web.go | 54 +- services/azurearm/rp_web_config.go | 44 +- services/azurearm/rp_web_functions.go | 26 +- services/azurearm/rp_web_routes.go | 84 +- services/azurearm/rp_web_slots.go | 895 ++++++++++++++++++ services/azurefunctions/PARITY.md | 7 +- services/azurefunctions/README.md | 6 +- services/azurefunctions/backend.go | 80 +- services/azurefunctions/lifecycle.go | 3 +- services/azurefunctions/persistence.go | 2 +- services/azurefunctions/serve.go | 12 +- services/azurefunctions/slots.go | 59 ++ services/azurefunctions/slots_test.go | 211 +++++ services/azurefunctions/webapp.go | 1 + services/azurefunctions/webapp_docker_test.go | 61 ++ 21 files changed, 2295 insertions(+), 59 deletions(-) create mode 100644 internal/app/azureappservice_slots_sdk_test.go create mode 100644 services/azurearm/rp_web_slots.go create mode 100644 services/azurefunctions/slots.go create mode 100644 services/azurefunctions/slots_test.go diff --git a/internal/app/azureappservice_slots_sdk_test.go b/internal/app/azureappservice_slots_sdk_test.go new file mode 100644 index 0000000000..1ac880f725 --- /dev/null +++ b/internal/app/azureappservice_slots_sdk_test.go @@ -0,0 +1,747 @@ +package app + +import ( + "archive/zip" + "bytes" + "net/http" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const slotSDKApp = "swapapp" + +func (s *fnSDKStack) createPlanSKU(t *testing.T, name, kind, sku, tier string) *armappservice.Plan { + t.Helper() + + poller, err := s.factory.NewPlansClient().BeginCreateOrUpdate(t.Context(), webSDKRG, name, armappservice.Plan{ + Location: new("local"), Kind: new(kind), + SKU: &armappservice.SKUDescription{Name: new(sku), Tier: new(tier)}, + Properties: &armappservice.PlanProperties{Reserved: new(true)}, + }, nil) + require.NoError(t, err) + + res, err := poller.PollUntilDone(t.Context(), pollOpts()) + require.NoError(t, err) + + return &res.Plan +} + +func (s *fnSDKStack) createSlot( + t *testing.T, app, slot string, props *armappservice.SiteProperties, +) (*armappservice.Site, error) { + t.Helper() + + poller, err := s.factory.NewWebAppsClient().BeginCreateOrUpdateSlot(t.Context(), webSDKRG, app, slot, + armappservice.Site{Location: new("local"), Kind: new("app,linux"), Properties: props}, nil) + if err != nil { + return nil, err + } + + res, err := poller.PollUntilDone(t.Context(), pollOpts()) + if err != nil { + return nil, err + } + + return &res.Site, nil +} + +// slotStack is a Standard-tier web app "swapapp" with a "staging" slot, both running the same stack. +type slotStack struct { + *fnSDKStack + planID string +} + +func newSlotStack(t *testing.T) *slotStack { + t.Helper() + + s := newFnSDKStack(t) + plan := s.createPlanSKU(t, "slotplan", "linux", "S1", "Standard") + _, err := s.createWebApp(t, slotSDKApp, *plan.ID, &armappservice.SiteConfig{LinuxFxVersion: new("NODE|20-lts")}) + require.NoError(t, err) + + _, err = s.createSlot(t, slotSDKApp, "staging", &armappservice.SiteProperties{ + SiteConfig: &armappservice.SiteConfig{LinuxFxVersion: new("NODE|20-lts")}, + }) + require.NoError(t, err) + + return &slotStack{fnSDKStack: s, planID: *plan.ID} +} + +func (s *slotStack) host(slot string) string { + if slot == "" { + return slotSDKApp + "." + fnSDKDomain + } + + return slotSDKApp + "-" + slot + "." + fnSDKDomain +} + +func (s *slotStack) deploy(t *testing.T, slot, marker string) { + t.Helper() + + name := slotSDKApp + if slot != "" { + name += "/" + slot + } + + user, pass, _, err := s.backend.PublishingCredentials(name) + require.NoError(t, err) + + zipped := zipOfFiles(t, map[string]string{"server.js": "1", "marker.txt": marker}) + scmHost := slotSDKApp + ".scm." + fnSDKDomain + + if slot != "" { + scmHost = slotSDKApp + "-" + slot + ".scm." + fnSDKDomain + } + + code, body := s.call(t, http.MethodPost, scmHost, "/api/zipdeploy", zipped, user, pass) + require.Equal(t, http.StatusOK, code, body) +} + +func (s *slotStack) get(t *testing.T, slot, path string) string { + t.Helper() + + code, body := s.call(t, http.MethodGet, s.host(slot), path, nil, "", "") + require.Equal(t, http.StatusOK, code, body) + + return body +} + +func (s *slotStack) setSettings(t *testing.T, slot string, kv map[string]string) { + t.Helper() + + props := map[string]*string{} + for k, v := range kv { + props[k] = new(v) + } + + _, err := s.factory.NewWebAppsClient().UpdateApplicationSettingsSlot( + t.Context(), webSDKRG, slotSDKApp, slot, armappservice.StringDictionary{Properties: props}, nil) + require.NoError(t, err) +} + +func (s *slotStack) setProdSettings(t *testing.T, kv map[string]string) { + t.Helper() + + props := map[string]*string{} + for k, v := range kv { + props[k] = new(v) + } + + _, err := s.factory.NewWebAppsClient().UpdateApplicationSettings( + t.Context(), webSDKRG, slotSDKApp, armappservice.StringDictionary{Properties: props}, nil) + require.NoError(t, err) +} + +func (s *slotStack) setConnStrings(t *testing.T, slot string, kv map[string]string) { + t.Helper() + + props := map[string]*armappservice.ConnStringValueTypePair{} + for k, v := range kv { + props[k] = &armappservice.ConnStringValueTypePair{Value: new(v), Type: new(armappservice.ConnectionStringTypeSQLAzure)} + } + + dict := armappservice.ConnectionStringDictionary{Properties: props} + apps := s.factory.NewWebAppsClient() + + var err error + if slot == "" { + _, err = apps.UpdateConnectionStrings(t.Context(), webSDKRG, slotSDKApp, dict, nil) + } else { + _, err = apps.UpdateConnectionStringsSlot(t.Context(), webSDKRG, slotSDKApp, slot, dict, nil) + } + + require.NoError(t, err) +} + +func (s *slotStack) stickyNames(t *testing.T, appSettings, connStrings []string) { + t.Helper() + + names := &armappservice.SlotConfigNames{} + for _, n := range appSettings { + names.AppSettingNames = append(names.AppSettingNames, new(n)) + } + + for _, n := range connStrings { + names.ConnectionStringNames = append(names.ConnectionStringNames, new(n)) + } + + _, err := s.factory.NewWebAppsClient().UpdateSlotConfigurationNames( + t.Context(), webSDKRG, slotSDKApp, armappservice.SlotConfigNamesResource{Properties: names}, nil) + require.NoError(t, err) +} + +func (s *slotStack) swap(t *testing.T, source, target string) error { + t.Helper() + + entity := armappservice.CsmSlotEntity{TargetSlot: new(target), PreserveVnet: new(false)} + apps := s.factory.NewWebAppsClient() + + if source == "" { + poller, err := apps.BeginSwapSlotWithProduction(t.Context(), webSDKRG, slotSDKApp, entity, nil) + if err != nil { + return err + } + + _, err = poller.PollUntilDone(t.Context(), pollOpts()) + + return err + } + + poller, err := apps.BeginSwapSlot(t.Context(), webSDKRG, slotSDKApp, source, entity, nil) + if err != nil { + return err + } + + _, err = poller.PollUntilDone(t.Context(), pollOpts()) + + return err +} + +func responseCode(err error) int { + if respErr, ok := err.(*azcore.ResponseError); ok { + return respErr.StatusCode + } + + return 0 +} + +func TestAzureAppServiceARMSDK_SlotCRUD(t *testing.T) { + t.Parallel() + + s := newSlotStack(t) + ctx := t.Context() + apps := s.factory.NewWebAppsClient() + + got, err := apps.GetSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, err) + assert.Equal(t, slotSDKApp+"/staging", *got.Name) + assert.Equal(t, "Microsoft.Web/sites/slots", *got.Type) + assert.Equal(t, slotSDKApp+"-staging."+fnSDKDomain+":10012", *got.Properties.DefaultHostName) + assert.Contains(t, got.Properties.EnabledHostNames, new(slotSDKApp+"-staging.scm."+fnSDKDomain+":10012")) + assert.Equal(t, s.planID, *got.Properties.ServerFarmID, "a slot runs in its site's plan") + assert.Equal(t, "Running", *got.Properties.State) + + var names []string + + pager := apps.NewListSlotsPager(webSDKRG, slotSDKApp, nil) + for pager.More() { + page, pageErr := pager.NextPage(ctx) + require.NoError(t, pageErr) + + for _, v := range page.Value { + names = append(names, *v.Name) + } + } + + assert.Equal(t, []string{slotSDKApp + "/staging"}, names) + + prodSites := s.factory.NewWebAppsClient().NewListByResourceGroupPager(webSDKRG, nil) + + var siteNames []string + + for prodSites.More() { + page, pageErr := prodSites.NextPage(ctx) + require.NoError(t, pageErr) + + for _, v := range page.Value { + siteNames = append(siteNames, *v.Name) + } + } + + assert.Equal(t, []string{slotSDKApp}, siteNames, "slots are not listed as sites") + + t.Run("settings are per slot", func(t *testing.T) { + s.setProdSettings(t, map[string]string{"WHO": "prod"}) + s.setSettings(t, "staging", map[string]string{"WHO": "stg"}) + + slotList, listErr := apps.ListApplicationSettingsSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, listErr) + assert.Equal(t, "stg", *slotList.Properties["WHO"]) + + prodList, listErr := apps.ListApplicationSettings(ctx, webSDKRG, slotSDKApp, nil) + require.NoError(t, listErr) + assert.Equal(t, "prod", *prodList.Properties["WHO"]) + + s.deploy(t, "", "prod") + s.deploy(t, "staging", "stg") + assert.Equal(t, "prod", s.get(t, "", "/env/WHO")) + assert.Equal(t, "stg", s.get(t, "staging", "/env/WHO")) + assert.Equal(t, "staging", s.get(t, "staging", "/env/WEBSITE_SLOT_NAME")) + assert.Equal(t, "Production", s.get(t, "", "/env/WEBSITE_SLOT_NAME")) + }) + + t.Run("start stop restart", func(t *testing.T) { + _, stopErr := apps.StopSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, stopErr) + + code, _ := s.call(t, http.MethodGet, s.host("staging"), "/", nil, "", "") + assert.Equal(t, http.StatusForbidden, code) + + stopped, getErr := apps.GetSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, getErr) + assert.Equal(t, "Stopped", *stopped.Properties.State) + assert.Equal(t, "Running", *s.mustGetProd(t).Properties.State, "production is not affected") + + _, startErr := apps.StartSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, startErr) + _, restartErr := apps.RestartSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, restartErr) + assert.Equal(t, "stg", s.get(t, "staging", "/marker")) + }) + + t.Run("web config", func(t *testing.T) { + _, cfgErr := apps.UpdateConfigurationSlot(ctx, webSDKRG, slotSDKApp, "staging", armappservice.SiteConfigResource{ + Properties: &armappservice.SiteConfig{WebSocketsEnabled: new(true)}, + }, nil) + require.NoError(t, cfgErr) + + cfg, getErr := apps.GetConfigurationSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, getErr) + assert.True(t, *cfg.Properties.WebSocketsEnabled) + assert.Equal(t, "$"+slotSDKApp+"__staging", *cfg.Properties.PublishingUsername) + }) + + t.Run("deleting a slot", func(t *testing.T) { + _, delErr := s.createSlot(t, slotSDKApp, "temp", nil) + require.NoError(t, delErr) + + _, delErr = apps.DeleteSlot(ctx, webSDKRG, slotSDKApp, "temp", nil) + require.NoError(t, delErr) + + _, getErr := apps.GetSlot(ctx, webSDKRG, slotSDKApp, "temp", nil) + require.Error(t, getErr) + assert.Equal(t, http.StatusNotFound, responseCode(getErr)) + + _, ok := s.backend.App(slotSDKApp + "/temp") + assert.False(t, ok) + }) + + t.Run("deleting the site deletes its slots", func(t *testing.T) { + _, ok := s.backend.App(slotSDKApp + "/staging") + require.True(t, ok) + + _, delErr := apps.Delete(ctx, webSDKRG, slotSDKApp, nil) + require.NoError(t, delErr) + + _, ok = s.backend.App(slotSDKApp + "/staging") + assert.False(t, ok) + + _, getErr := apps.GetSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + assert.Equal(t, http.StatusNotFound, responseCode(getErr)) + }) +} + +func (s *fnSDKStack) mustGetProd(t *testing.T) *armappservice.Site { + t.Helper() + + got, err := s.factory.NewWebAppsClient().Get(t.Context(), webSDKRG, slotSDKApp, nil) + require.NoError(t, err) + + return &got.Site +} + +func TestAzureAppServiceARMSDK_SlotSwapStickySettings(t *testing.T) { + t.Parallel() + + s := newSlotStack(t) + ctx := t.Context() + apps := s.factory.NewWebAppsClient() + + s.stickyNames(t, []string{"STICKY"}, []string{"StickyDB"}) + + names, err := apps.ListSlotConfigurationNames(ctx, webSDKRG, slotSDKApp, nil) + require.NoError(t, err) + require.Len(t, names.Properties.AppSettingNames, 1) + assert.Equal(t, "STICKY", *names.Properties.AppSettingNames[0]) + + s.setProdSettings(t, map[string]string{ + "SWAPPED": "from-prod", "STICKY": "prod-only", "FUNCTIONS_EXTENSION_VERSION": "~4", + }) + s.setSettings(t, "staging", map[string]string{ + "SWAPPED": "from-staging", "STICKY": "staging-only", "FUNCTIONS_EXTENSION_VERSION": "~3", + }) + s.setConnStrings(t, "", map[string]string{"DB": "prod-db", "StickyDB": "prod-sticky-db"}) + s.setConnStrings(t, "staging", map[string]string{"DB": "stg-db", "StickyDB": "stg-sticky-db"}) + s.deploy(t, "", "prod-content") + s.deploy(t, "staging", "staging-content") + + require.Equal(t, "prod-content", s.get(t, "", "/marker")) + startedBefore := s.rt.started() + + require.NoError(t, s.swap(t, "staging", "production")) + + assert.Equal(t, "staging-content", s.get(t, "", "/marker"), "production serves the swapped-in content") + assert.Equal(t, "prod-content", s.get(t, "staging", "/marker"), "staging holds the previous production") + assert.Greater(t, s.rt.started(), startedBefore, "both apps restarted") + + assert.Equal(t, "from-staging", s.get(t, "", "/env/SWAPPED")) + assert.Equal(t, "from-prod", s.get(t, "staging", "/env/SWAPPED")) + assert.Equal(t, "prod-only", s.get(t, "", "/env/STICKY"), "slot settings stay with the slot") + assert.Equal(t, "staging-only", s.get(t, "staging", "/env/STICKY")) + assert.Equal(t, "~4", s.get(t, "", "/env/FUNCTIONS_EXTENSION_VERSION"), "_EXTENSION_VERSION settings never swap") + assert.Equal(t, "~3", s.get(t, "staging", "/env/FUNCTIONS_EXTENSION_VERSION")) + + prodConn, err := apps.ListConnectionStrings(ctx, webSDKRG, slotSDKApp, nil) + require.NoError(t, err) + assert.Equal(t, "stg-db", *prodConn.Properties["DB"].Value) + assert.Equal(t, "prod-sticky-db", *prodConn.Properties["StickyDB"].Value) + + slotConn, err := apps.ListConnectionStringsSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, err) + assert.Equal(t, "prod-db", *slotConn.Properties["DB"].Value) + assert.Equal(t, "stg-sticky-db", *slotConn.Properties["StickyDB"].Value) + + prod := s.mustGetProd(t) + require.NotNil(t, prod.Properties.SlotSwapStatus) + assert.Equal(t, "staging", *prod.Properties.SlotSwapStatus.SourceSlotName) + assert.Equal(t, "production", *prod.Properties.SlotSwapStatus.DestinationSlotName) + + t.Run("swapping back restores the previous production", func(t *testing.T) { + require.NoError(t, s.swap(t, "staging", "production")) + assert.Equal(t, "prod-content", s.get(t, "", "/marker")) + assert.Equal(t, "from-prod", s.get(t, "", "/env/SWAPPED")) + assert.Equal(t, "prod-only", s.get(t, "", "/env/STICKY")) + }) + + t.Run("the production slot may be the swap source", func(t *testing.T) { + require.NoError(t, s.swap(t, "", "staging")) + assert.Equal(t, "staging-content", s.get(t, "", "/marker")) + assert.Equal(t, "prod-only", s.get(t, "", "/env/STICKY")) + }) + + t.Run("web config follows the content", func(t *testing.T) { + _, cfgErr := apps.UpdateConfigurationSlot(ctx, webSDKRG, slotSDKApp, "staging", armappservice.SiteConfigResource{ + Properties: &armappservice.SiteConfig{LinuxFxVersion: new("NODE|22-lts"), AlwaysOn: new(true)}, + }, nil) + require.NoError(t, cfgErr) + require.NoError(t, s.swap(t, "staging", "production")) + + prodCfg, getErr := apps.GetConfiguration(ctx, webSDKRG, slotSDKApp, nil) + require.NoError(t, getErr) + assert.Equal(t, "NODE|22-lts", *prodCfg.Properties.LinuxFxVersion) + assert.False(t, *prodCfg.Properties.AlwaysOn, "alwaysOn is not swapped") + + slotCfg, getErr := apps.GetConfigurationSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, getErr) + assert.Equal(t, "NODE|20-lts", *slotCfg.Properties.LinuxFxVersion) + assert.True(t, *slotCfg.Properties.AlwaysOn) + }) +} + +func TestAzureAppServiceARMSDK_SlotSwapWithPreview(t *testing.T) { + t.Parallel() + + s := newSlotStack(t) + ctx := t.Context() + apps := s.factory.NewWebAppsClient() + entity := armappservice.CsmSlotEntity{TargetSlot: new("production"), PreserveVnet: new(false)} + + s.stickyNames(t, []string{"STICKY"}, nil) + s.setProdSettings(t, map[string]string{"SWAPPED": "from-prod", "STICKY": "prod-only"}) + s.setSettings(t, "staging", map[string]string{"SWAPPED": "from-staging", "STICKY": "staging-only"}) + s.deploy(t, "", "prod-content") + s.deploy(t, "staging", "staging-content") + + _, err := apps.ApplySlotConfigurationSlot(ctx, webSDKRG, slotSDKApp, "staging", entity, nil) + require.NoError(t, err) + + t.Run("first phase runs the source with the target's slot settings", func(t *testing.T) { + assert.Equal(t, "prod-only", s.get(t, "staging", "/env/STICKY")) + assert.Equal(t, "from-staging", s.get(t, "staging", "/env/SWAPPED")) + assert.Equal(t, "staging-content", s.get(t, "staging", "/marker"), "content has not moved yet") + assert.Equal(t, "prod-content", s.get(t, "", "/marker")) + }) + + t.Run("a prepared slot cannot be edited", func(t *testing.T) { + _, setErr := apps.UpdateApplicationSettingsSlot(ctx, webSDKRG, slotSDKApp, "staging", + armappservice.StringDictionary{Properties: map[string]*string{"X": new("1")}}, nil) + require.Error(t, setErr) + assert.Equal(t, http.StatusConflict, responseCode(setErr)) + assert.Contains(t, setErr.Error(), "prepared for swap") + + _, applyErr := apps.ApplySlotConfigToProduction(ctx, webSDKRG, slotSDKApp, + armappservice.CsmSlotEntity{TargetSlot: new("staging"), PreserveVnet: new(false)}, nil) + require.Error(t, applyErr, "the target cannot start another preview") + }) + + t.Run("reset cancels", func(t *testing.T) { + _, resetErr := apps.ResetSlotConfigurationSlot(ctx, webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, resetErr) + + assert.Equal(t, "staging-only", s.get(t, "staging", "/env/STICKY")) + assert.Equal(t, "prod-content", s.get(t, "", "/marker")) + + s.setSettings(t, "staging", map[string]string{"SWAPPED": "from-staging", "STICKY": "staging-only", "X": "1"}) + }) + + t.Run("complete after the preview", func(t *testing.T) { + _, applyErr := apps.ApplySlotConfigurationSlot(ctx, webSDKRG, slotSDKApp, "staging", entity, nil) + require.NoError(t, applyErr) + require.NoError(t, s.swap(t, "staging", "production")) + + assert.Equal(t, "staging-content", s.get(t, "", "/marker")) + assert.Equal(t, "prod-content", s.get(t, "staging", "/marker")) + assert.Equal(t, "prod-only", s.get(t, "", "/env/STICKY")) + assert.Equal(t, "staging-only", s.get(t, "staging", "/env/STICKY"), "the slot's own setting is back") + assert.Equal(t, "1", s.get(t, "", "/env/X")) + + s.setSettings(t, "staging", map[string]string{"STICKY": "staging-only", "X": "2"}) + }) + + t.Run("production as the preview source", func(t *testing.T) { + _, prodApplyErr := apps.ApplySlotConfigToProduction(ctx, webSDKRG, slotSDKApp, + armappservice.CsmSlotEntity{TargetSlot: new("staging"), PreserveVnet: new(false)}, nil) + require.NoError(t, prodApplyErr) + assert.Equal(t, "staging-only", s.get(t, "", "/env/STICKY")) + + _, resetErr := apps.ResetProductionSlotConfig(ctx, webSDKRG, slotSDKApp, nil) + require.NoError(t, resetErr) + assert.Equal(t, "prod-only", s.get(t, "", "/env/STICKY")) + }) +} + +func TestAzureAppServiceARMSDK_SlotDifferences(t *testing.T) { + t.Parallel() + + s := newSlotStack(t) + apps := s.factory.NewWebAppsClient() + + s.stickyNames(t, []string{"STICKY"}, nil) + s.setProdSettings(t, map[string]string{"A": "prod", "STICKY": "p", "SAME": "x"}) + s.setSettings(t, "staging", map[string]string{"A": "stg", "STICKY": "s", "SAME": "x", "ONLYSTG": "1"}) + + pager := apps.NewListSlotDifferencesSlotPager(webSDKRG, slotSDKApp, "staging", + armappservice.CsmSlotEntity{TargetSlot: new("production"), PreserveVnet: new(false)}, nil) + + got := map[string][2]string{} + + for pager.More() { + page, err := pager.NextPage(t.Context()) + require.NoError(t, err) + + for _, d := range page.Value { + assert.Equal(t, "AppSetting", *d.Properties.SettingType) + + var target string + if d.Properties.ValueInTargetSlot != nil { + target = *d.Properties.ValueInTargetSlot + } + + got[*d.Properties.SettingName] = [2]string{*d.Properties.ValueInCurrentSlot, target} + } + } + + assert.Equal(t, map[string][2]string{"A": {"stg", "prod"}, "ONLYSTG": {"1", ""}}, got, + "only swapped settings that differ are listed") +} + +func TestAzureAppServiceARMSDK_SlotLimits(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + kind string + siteKind string + sku, tier string + want int + }{ + {"free", "linux", "app,linux", "F1", "Free", 0}, + {"basic", "linux", "app,linux", "B1", "Basic", 0}, + {"standard", "linux", "app,linux", "S1", "Standard", 5}, + {"premium v3", "linux", "app,linux", "P1v3", "PremiumV3", 20}, + {"isolated", "linux", "app,linux", "I1v2", "IsolatedV2", 20}, + {"function consumption", "functionapp", "functionapp,linux", "Y1", "Dynamic", 1}, + {"function premium", "functionapp", "functionapp,linux", "EP1", "ElasticPremium", 2}, + {"function flex", "functionapp", "functionapp,linux", "FC1", "FlexConsumption", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := newFnSDKStack(t) + plan := s.createPlanSKU(t, "limitplan", tt.kind, tt.sku, tt.tier) + + poller, err := s.factory.NewWebAppsClient().BeginCreateOrUpdate(t.Context(), webSDKRG, "limited", + armappservice.Site{ + Location: new("local"), Kind: new(tt.siteKind), + Properties: &armappservice.SiteProperties{ + ServerFarmID: new(*plan.ID), + SiteConfig: &armappservice.SiteConfig{LinuxFxVersion: new(limitFx(tt.siteKind))}, + }, + }, nil) + require.NoError(t, err) + _, err = poller.PollUntilDone(t.Context(), pollOpts()) + require.NoError(t, err) + + for i := range tt.want { + _, slotErr := s.createSlot(t, "limited", "s"+string(rune('a'+i)), nil) + require.NoError(t, slotErr, "slot %d is within the limit", i+1) + } + + _, overErr := s.createSlot(t, "limited", "over", nil) + require.Error(t, overErr) + assert.Equal(t, http.StatusConflict, responseCode(overErr)) + + if tt.want > 0 { + _, updErr := s.createSlot(t, "limited", "sa", nil) + require.NoError(t, updErr, "updating an existing slot is not a new slot") + } + }) + } +} + +func limitFx(kind string) string { + if kind == "app,linux" { + return "NODE|20-lts" + } + + return "NODE|20" +} + +func TestAzureAppServiceARMSDK_SlotErrors(t *testing.T) { + t.Parallel() + + s := newSlotStack(t) + apps := s.factory.NewWebAppsClient() + + _, err := s.createSlot(t, slotSDKApp, "second", &armappservice.SiteProperties{ + SiteConfig: &armappservice.SiteConfig{LinuxFxVersion: new("NODE|20-lts")}, + }) + require.NoError(t, err) + + tests := []struct { + fn func() error + name string + want int + }{ + {func() error { return s.swap(t, "staging", "staging") }, "swap with itself", http.StatusBadRequest}, + {func() error { return s.swap(t, "staging", "nope") }, "unknown target", http.StatusNotFound}, + {func() error { return s.swap(t, "nope", "production") }, "unknown source", http.StatusNotFound}, + {func() error { return s.swap(t, "", "production") }, "production with production", http.StatusBadRequest}, + {func() error { _, e := s.createSlot(t, slotSDKApp, "production", nil); return e }, "reserved name", http.StatusBadRequest}, + {func() error { _, e := s.createSlot(t, slotSDKApp, "bad_name", nil); return e }, "bad characters", http.StatusBadRequest}, + {func() error { _, e := s.createSlot(t, slotSDKApp, "averyveryverylongslotname", nil); return e }, "name too long", http.StatusBadRequest}, + {func() error { _, e := s.createSlot(t, "ghost", "x", nil); return e }, "unknown site", http.StatusNotFound}, + {func() error { + _, e := s.createSlot(t, slotSDKApp, "other", &armappservice.SiteProperties{ServerFarmID: new(s.planID + "x")}) + return e + }, "different plan", http.StatusBadRequest}, + {func() error { _, e := apps.GetSlot(t.Context(), webSDKRG, slotSDKApp, "nope", nil); return e }, "get unknown", http.StatusNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.fn() + require.Error(t, err) + assert.Equal(t, tt.want, responseCode(err), err.Error()) + }) + } + + t.Run("a swap between two slots", func(t *testing.T) { + s.deploy(t, "staging", "one") + s.deploy(t, "second", "two") + require.NoError(t, s.swap(t, "staging", "second")) + assert.Equal(t, "two", s.get(t, "staging", "/marker")) + assert.Equal(t, "one", s.get(t, "second", "/marker")) + }) + + t.Run("a slot may not take a site's hostname", func(t *testing.T) { + plan := s.planID + _, siteErr := s.createWebApp(t, slotSDKApp+"-staging", plan, &armappservice.SiteConfig{LinuxFxVersion: new("NODE|20-lts")}) + require.Error(t, siteErr) + assert.Equal(t, http.StatusConflict, responseCode(siteErr)) + }) + + t.Run("cloning copies settings, not content", func(t *testing.T) { + s.setSettings(t, "staging", map[string]string{"CLONED": "yes", "OVERRIDE": "src"}) + s.deploy(t, "staging", "src-content") + + src := armappservice.CloningInfo{ + SourceWebAppID: new(*s.mustGetSlot(t, "staging").ID), + AppSettingsOverrides: map[string]*string{"OVERRIDE": new("dst")}, + } + _, cloneErr := s.createSlot(t, slotSDKApp, "clone", &armappservice.SiteProperties{CloningInfo: &src}) + require.NoError(t, cloneErr) + + list, listErr := apps.ListApplicationSettingsSlot(t.Context(), webSDKRG, slotSDKApp, "clone", nil) + require.NoError(t, listErr) + assert.Equal(t, "yes", *list.Properties["CLONED"]) + assert.Equal(t, "dst", *list.Properties["OVERRIDE"]) + + code, body := s.call(t, http.MethodGet, s.host("clone"), "/", nil, "", "") + assert.Equal(t, http.StatusOK, code) + assert.Contains(t, body, "waiting for your content") + }) +} + +func (s *fnSDKStack) mustGetSlot(t *testing.T, slot string) *armappservice.Site { + t.Helper() + + got, err := s.factory.NewWebAppsClient().GetSlot(t.Context(), webSDKRG, slotSDKApp, slot, nil) + require.NoError(t, err) + + return &got.Site +} + +func TestAzureAppServiceARMSDK_SlotPersistence(t *testing.T) { + t.Parallel() + + src := newSlotStack(t) + src.stickyNames(t, []string{"STICKY"}, nil) + src.setSettings(t, "staging", map[string]string{"A": "slot"}) + src.deploy(t, "staging", "staged") + require.NoError(t, src.arm.Restore(t.Context(), src.arm.Snapshot(t.Context())), "own snapshot restores") + + dst := newFnSDKStack(t) + require.NoError(t, dst.arm.Restore(t.Context(), src.arm.Snapshot(t.Context()))) + require.NoError(t, dst.backend.Restore(t.Context(), src.backend.Snapshot(t.Context()))) + + got, err := dst.factory.NewWebAppsClient().GetSlot(t.Context(), webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, err) + assert.Equal(t, slotSDKApp+"/staging", *got.Name) + + names, err := dst.factory.NewWebAppsClient().ListSlotConfigurationNames(t.Context(), webSDKRG, slotSDKApp, nil) + require.NoError(t, err) + assert.Equal(t, "STICKY", *names.Properties.AppSettingNames[0]) + + list, err := dst.factory.NewWebAppsClient().ListApplicationSettingsSlot(t.Context(), webSDKRG, slotSDKApp, "staging", nil) + require.NoError(t, err) + assert.Equal(t, "slot", *list.Properties["A"]) + + d := &slotStack{fnSDKStack: dst} + assert.Equal(t, "staged", d.get(t, "staging", "/marker"), "the restored slot serves its restored package") +} + +func zipOfFiles(t *testing.T, files map[string]string) []byte { + t.Helper() + + var buf bytes.Buffer + + zw := zip.NewWriter(&buf) + + for name, content := range files { + w, err := zw.Create(name) + require.NoError(t, err) + + _, err = w.Write([]byte(content)) + require.NoError(t, err) + } + + require.NoError(t, zw.Close()) + + return buf.Bytes() +} + +func (f *fnFakeRuntime) started() int { + f.mu.Lock() + defer f.mu.Unlock() + + return len(f.specs) +} diff --git a/internal/app/azurefunctions_sdk_test.go b/internal/app/azurefunctions_sdk_test.go index fdbf8ceb6b..2504dd4621 100644 --- a/internal/app/azurefunctions_sdk_test.go +++ b/internal/app/azurefunctions_sdk_test.go @@ -9,6 +9,8 @@ import ( "net" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "sync" "testing" @@ -66,6 +68,27 @@ func (f *fnFakeRuntime) CreateAndStart(_ context.Context, spec container.Spec) ( return } + if name, ok := strings.CutPrefix(r.URL.Path, "/env/"); ok { + for _, e := range spec.Env { + if k, v, _ := strings.Cut(e, "="); k == name { + _, _ = w.Write([]byte(v)) + } + } + + return + } + + if r.URL.Path == "/marker" { + for _, m := range spec.Mounts { + if dir, _, found := strings.Cut(m, ":/home/site/wwwroot"); found { + raw, _ := os.ReadFile(filepath.Join(dir, "marker.txt")) + _, _ = w.Write(raw) + } + } + + return + } + if r.URL.Path == "/admin/functions" { _, _ = w.Write([]byte(`[{"name":"hello","language":"node","isDisabled":false,"config":{"bindings":` + `[{"type":"httpTrigger","authLevel":"function","direction":"in","name":"req","route":"greet"}]}},` + @@ -126,6 +149,7 @@ func (f *fnFakeRuntime) lastEnv(site string) map[string]string { } type fnSDKStack struct { + arm *azurearm.Handler factory *armappservice.ClientFactory backend *azurefunctions.InMemoryBackend rt *fnFakeRuntime @@ -168,7 +192,7 @@ func newFnSDKStack(t *testing.T) *fnSDKStack { }) require.NoError(t, err) - return &fnSDKStack{factory: f, backend: backend, rt: rt, fnSrv: fnSrv} + return &fnSDKStack{arm: armH, factory: f, backend: backend, rt: rt, fnSrv: fnSrv} } // call sends a request to the Functions listener addressed to host. diff --git a/internal/app/azurefunctions_wiring.go b/internal/app/azurefunctions_wiring.go index 9aacada89a..44b3e1c739 100644 --- a/internal/app/azurefunctions_wiring.go +++ b/internal/app/azurefunctions_wiring.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "github.com/blackbirdworks/gopherstack/pkgs/service" "github.com/blackbirdworks/gopherstack/services/azurearm" @@ -61,7 +62,7 @@ func (a *azureFunctionAppsAdapter) PutApp( Kind: cfg.Kind, LinuxFx: cfg.LinuxFx, AppCommandLine: cfg.AppCommandLine, HealthCheckPath: cfg.HealthCheckPath, } - if err := azurefunctions.ValidateConfig(appCfg); err != nil { + if err := azurefunctions.ValidateConfig(appCfg); err != nil && !(isSlotName(name) && cfg.LinuxFx == "") { return azurearm.FunctionAppInfo{}, fmt.Errorf("%w: %w", azurearm.ErrWebInvalidSiteConfig, err) } @@ -160,3 +161,13 @@ func (a *azureFunctionAppsAdapter) PublishingCredentials(name string) (string, s return user, password, scmURI, mapFunctionsError(err) } + +func isSlotName(name string) bool { return strings.Contains(name, azurefunctions.SlotSep) } + +func (a *azureFunctionAppsAdapter) SwapContent(first, second string) error { + return mapFunctionsError(a.backend.SwapContent(first, second)) +} + +func (a *azureFunctionAppsAdapter) WarmUp(ctx context.Context, name string) error { + return mapFunctionsError(a.backend.WarmUp(ctx, name)) +} diff --git a/pkgs/persistence/testdata/snapshot_inventory.json b/pkgs/persistence/testdata/snapshot_inventory.json index be9994f90a..31564bca6a 100644 --- a/pkgs/persistence/testdata/snapshot_inventory.json +++ b/pkgs/persistence/testdata/snapshot_inventory.json @@ -3276,6 +3276,9 @@ "keyVaultRecord.Tags map[string]string `json:\"tags,omitempty\"`", "keyVaultSnapshot.Deleted map[string]deletedKeyVaultRecord `json:\"deleted,omitempty\"`", "keyVaultSnapshot.Vaults map[string]keyVaultRecord `json:\"vaults,omitempty\"`", + "pendingSwap.ConnStrings map[string]any `json:\"connStrings,omitempty\"`", + "pendingSwap.Settings map[string]string `json:\"settings\"`", + "pendingSwap.Target string `json:\"target\"`", "redisFirewall.End string `json:\"end\"`", "redisFirewall.Name string `json:\"name\"`", "redisFirewall.Start string `json:\"start\"`", @@ -3362,10 +3365,17 @@ "storedWebSite.Extra map[string]any `json:\"extra,omitempty\"`", "storedWebSite.Identity map[string]any `json:\"identity,omitempty\"`", "storedWebSite.Kind string `json:\"kind\"`", + "storedWebSite.LastSwap *swapStatus `json:\"lastSwap,omitempty\"`", "storedWebSite.Location string `json:\"location\"`", "storedWebSite.Name string `json:\"name\"`", + "storedWebSite.Pending *pendingSwap `json:\"pendingSwap,omitempty\"`", "storedWebSite.ResourceGroup string `json:\"resourceGroup\"`", + "storedWebSite.Slot string `json:\"slot,omitempty\"`", + "storedWebSite.Slots map[string]*storedWebSite `json:\"slots,omitempty\"`", "storedWebSite.Tags map[string]string `json:\"tags,omitempty\"`", + "swapStatus.Destination string `json:\"destination\"`", + "swapStatus.Source string `json:\"source\"`", + "swapStatus.Time time.Time `json:\"time\"`", "webSnapshot.Plans map[string]*storedWebPlan `json:\"plans,omitempty\"`", "webSnapshot.Sites map[string]*storedWebSite `json:\"sites,omitempty\"`" ], @@ -3706,6 +3716,7 @@ "appState.Name string `json:\"name\"`", "appState.PublishPassword string `json:\"publishPassword\"`", "appState.Settings map[string]string `json:\"settings\"`", + "appState.Slot string `json:\"slot,omitempty\"`", "appState.Stopped bool `json:\"stopped\"`", "appState.SystemKeys map[string]string `json:\"systemKeys\"`", "appState.Zip []byte `json:\"zip\"`", diff --git a/services/azurearm/errors.go b/services/azurearm/errors.go index 8f1b368d01..07df3f889f 100644 --- a/services/azurearm/errors.go +++ b/services/azurearm/errors.go @@ -64,6 +64,10 @@ var ( ErrWebPlanNotLinux = errors.New("azurearm: Linux app needs a Linux (reserved) plan") ErrWebNotFunctionApp = errors.New("azurearm: operation is only valid for function apps") ErrWebInvalidKeyType = errors.New("azurearm: key type must be functionKeys or systemKeys") + ErrWebSwapPending = errors.New("azurearm: slot is prepared for a swap") + ErrWebSlotLimit = errors.New("azurearm: deployment slot limit reached for the plan tier") + ErrWebSlotName = errors.New("azurearm: invalid deployment slot name") + ErrWebSwapFailed = errors.New("azurearm: slot swap failed") ErrWebFunctionsUnavailable = errors.New("azurearm: azure functions data plane is not available") ErrACRRegistryNotFound = errors.New("azurearm: container registry not found") diff --git a/services/azurearm/interfaces_web.go b/services/azurearm/interfaces_web.go index 7bade9f865..5736c7ab72 100644 --- a/services/azurearm/interfaces_web.go +++ b/services/azurearm/interfaces_web.go @@ -50,7 +50,7 @@ type FunctionHostKeys struct { // FunctionApps is the seam the Microsoft.Web resource provider uses to keep function apps and Linux web apps in // step with the Azure Functions data plane. Primitive types only, so this package never imports the data-plane service. -// Implementations return ErrWebSiteNotFound, ErrWebFunctionNotFound, ErrWebKeyNotFound or ErrWebInvalidKeyType. +// Slots are addressed as "/". Implementations return ErrWebSiteNotFound, ErrWebFunctionNotFound, ErrWebKeyNotFound or ErrWebInvalidKeyType. type FunctionApps interface { PutApp(name string, cfg FunctionAppConfig) (FunctionAppInfo, error) DeleteApp(name string) error @@ -67,6 +67,8 @@ type FunctionApps interface { SetFunctionKey(name, fn, keyName, value string) (string, error) DeleteFunctionKey(name, fn, keyName string) error PublishingCredentials(name string) (user, password, scmURI string, err error) + SwapContent(first, second string) error + WarmUp(ctx context.Context, name string) error } type noopFunctionApps struct{} @@ -122,4 +124,8 @@ func (noopFunctionApps) PublishingCredentials(string) (string, string, string, e return "", "", "", ErrWebFunctionsUnavailable } +func (noopFunctionApps) SwapContent(string, string) error { return ErrWebFunctionsUnavailable } + +func (noopFunctionApps) WarmUp(context.Context, string) error { return ErrWebFunctionsUnavailable } + var _ FunctionApps = noopFunctionApps{} diff --git a/services/azurearm/rp_web.go b/services/azurearm/rp_web.go index a66b255069..53e334618c 100644 --- a/services/azurearm/rp_web.go +++ b/services/azurearm/rp_web.go @@ -62,7 +62,11 @@ type storedWebSite struct { Identity map[string]any `json:"identity,omitempty"` Extra map[string]any `json:"extra,omitempty"` Configs map[string]map[string]any `json:"configs,omitempty"` + Slots map[string]*storedWebSite `json:"slots,omitempty"` + Pending *pendingSwap `json:"pendingSwap,omitempty"` + LastSwap *swapStatus `json:"lastSwap,omitempty"` Name string `json:"name"` + Slot string `json:"slot,omitempty"` ResourceGroup string `json:"resourceGroup"` Location string `json:"location"` Kind string `json:"kind"` @@ -113,6 +117,7 @@ func (p *WebProvider) ResourceTypes() []ResourceTypeDef { return []ResourceTypeDef{ {Type: webSitesType, APIVersions: v, HasChildren: true}, {Type: webPlansType, APIVersions: v, HasChildren: false}, + {Type: webSitesType + "/slots", APIVersions: v, HasChildren: true}, {Type: webSitesType + "/config", APIVersions: v, HasChildren: false}, {Type: webSitesType + "/functions", APIVersions: v, HasChildren: false}, {Type: webSitesType + "/basicPublishingCredentialsPolicies", APIVersions: v, HasChildren: false}, @@ -128,6 +133,10 @@ func errUnsupportedWebType(id ResourceID) error { } func webKind(id ResourceID) string { + if len(id.Types) == 2 && strings.EqualFold(id.Types[0], webSitesType) && strings.EqualFold(id.Types[1], webSlotsType) { + return webSlotsType + } + if len(id.Types) != 1 { return "" } @@ -146,7 +155,7 @@ func badWebRequest(err error) error { if errors.Is(err, ErrWebFunctionsUnavailable) || errors.Is(err, ErrWebNameTaken) || errors.Is(err, ErrWebUnsupportedKind) || errors.Is(err, ErrWebPlanNotFound) || errors.Is(err, ErrWebPlanNotLinux) || errors.Is(err, ErrWebNotFunctionApp) || - errors.Is(err, ErrWebInvalidSiteConfig) { + errors.Is(err, ErrWebInvalidSiteConfig) || errors.Is(err, ErrWebSlotName) { return err } @@ -174,6 +183,8 @@ func (p *WebProvider) Put(ctx context.Context, id ResourceID, body map[string]an switch webKind(id) { case webSitesType: return p.putSite(ctx, id, body) + case webSlotsType: + return p.putSlot(ctx, id, body) case webPlansType: return p.putPlan(id, body) } @@ -266,6 +277,14 @@ func (p *WebProvider) putSite(ctx context.Context, id ResourceID, body map[strin return nil, ErrWebNameTaken } + if p.slotLabelTaken(name, "") { + return nil, ErrWebNameTaken + } + + if exists && existing.Pending != nil { + return nil, ErrWebSwapPending + } + props, _ := body[fieldProperties].(map[string]any) farm, _ := props[webFieldServerFarmID].(string) @@ -285,6 +304,7 @@ func (p *WebProvider) putSite(ctx context.Context, id ResourceID, body map[strin if exists { rec.Created, rec.Location, rec.Configs = existing.Created, existing.Location, existing.Configs + rec.Slots, rec.LastSwap = existing.Slots, existing.LastSwap } if loc, _ := body[fieldLocation].(string); loc != "" { @@ -345,7 +365,7 @@ func webSiteExtra(props map[string]any) map[string]any { for _, k := range []string{ webFieldSiteConfig, fieldProvisioningState, sqlPropState, "hostNames", "defaultHostName", "enabledHostNames", "hostNameSslStates", "repositorySiteName", "usageState", "availabilityState", fieldName, "lastModifiedTimeUtc", - "outboundIpAddresses", "possibleOutboundIpAddresses", webFieldRG, + "outboundIpAddresses", "possibleOutboundIpAddresses", webFieldRG, "cloningInfo", "slotSwapStatus", } { delete(out, k) } @@ -377,6 +397,11 @@ func (p *WebProvider) siteBody( s *storedWebSite, info FunctionAppInfo, ) map[string]any { + resName, resType := s.Name, namespaceMicrosoftWeb+"/"+webSitesType + if s.Slot != "" { + resName, resType = s.Name+"/"+s.Slot, resType+"/"+webSlotsType + } + state := webStateRunning if info.Stopped { state = webStateStopped @@ -431,10 +456,17 @@ func (p *WebProvider) siteBody( fieldProvisioningState: provisioningStateSucceeded, } + if s.LastSwap != nil { + props["slotSwapStatus"] = map[string]any{ + "timestampUtc": s.LastSwap.Time.UTC().Format(time.RFC3339), "sourceSlotName": s.LastSwap.Source, + "destinationSlotName": s.LastSwap.Destination, + } + } + out := map[string]any{ "id": id.ARMID(), - fieldName: s.Name, - fieldType: namespaceMicrosoftWeb + "/" + webSitesType, + fieldName: resName, + fieldType: resType, webFieldKind: s.Kind, fieldLocation: s.Location, fieldTags: tagsOrEmpty(s.Tags), @@ -454,7 +486,7 @@ func (p *WebProvider) Get(ctx context.Context, id ResourceID) (map[string]any, e defer p.mu.RUnlock() switch webKind(id) { - case webSitesType: + case webSitesType, webSlotsType: return p.getSite(ctx, id) case webPlansType: pl, ok := p.plans[planKey(id.ResourceGroup, id.LeafName())] @@ -469,12 +501,12 @@ func (p *WebProvider) Get(ctx context.Context, id ResourceID) (map[string]any, e } func (p *WebProvider) getSite(ctx context.Context, id ResourceID) (map[string]any, error) { - s, ok := p.site(id.ResourceGroup, id.LeafName()) + s, ok := p.record(id) if !ok { return nil, ErrWebSiteNotFound } - info, found := p.dataPlane.AppInfo(s.Name) + info, found := p.dataPlane.AppInfo(s.dpName()) if !found { return nil, ErrWebSiteNotFound } @@ -501,6 +533,8 @@ func (p *WebProvider) Delete(_ context.Context, id ResourceID) error { delete(p.sites, webSiteKey(s.Name)) return nil + case webSlotsType: + return p.deleteSlot(id) case webPlansType: key := planKey(id.ResourceGroup, id.LeafName()) if _, ok := p.plans[key]; !ok { @@ -527,6 +561,8 @@ func (p *WebProvider) List(ctx context.Context, id ResourceID) ([]map[string]any var out []map[string]any switch strings.ToLower(id.LeafType()) { + case webSlotsType: + return p.listSlots(ctx, id), nil case webSitesType: for _, s := range p.sites { if id.ResourceGroup != "" && !resourceGroupsEqual(s.ResourceGroup, id.ResourceGroup) { @@ -599,7 +635,7 @@ func webErrorDetails(err error) (errorEntry, bool) { return errorEntry{webCodeBadRequest, err.Error(), http.StatusBadRequest}, true } - table := []struct { + table := append(webSlotErrorDetails(), []struct { err error entry errorEntry }{ @@ -638,7 +674,7 @@ func webErrorDetails(err error) (errorEntry, bool) { {ErrWebFunctionsUnavailable, errorEntry{ redisCodeUnavailable, "The Azure Functions data plane is not available.", http.StatusServiceUnavailable, }}, - } + }...) for _, e := range table { if errors.Is(err, e.err) { diff --git a/services/azurearm/rp_web_config.go b/services/azurearm/rp_web_config.go index 662108ec8a..7878173db8 100644 --- a/services/azurearm/rp_web_config.go +++ b/services/azurearm/rp_web_config.go @@ -53,7 +53,7 @@ func webConfigDefaults(s *storedWebSite) map[string]any { "httpLoggingEnabled": false, "logsDirectorySizeLimit": webLogsLimitMB, "detailedErrorLoggingEnabled": false, - "publishingUsername": "$" + s.Name, + "publishingUsername": s.publishUser(), "scmType": webNone, "use32BitWorkerProcess": false, "webSocketsEnabled": false, @@ -140,8 +140,19 @@ func configDefaultProps(name string, s *storedWebSite) map[string]any { return map[string]any{} } +// configHolder is the record that stores configuration name: slotConfigNames is shared by a site and its slots. +func (p *WebProvider) configHolder(s *storedWebSite, name string) *storedWebSite { + if name == webSlotConfigNames && s.Slot != "" { + if parent, ok := p.sites[webSiteKey(s.Name)]; ok { + return parent + } + } + + return s +} + func (p *WebProvider) siteFor(id ResourceID) (*storedWebSite, error) { - s, ok := p.site(id.ResourceGroup, id.LeafName()) + s, ok := p.record(id) if !ok { return nil, ErrWebSiteNotFound } @@ -157,7 +168,7 @@ func configEnvelope(id ResourceID, name string, props map[string]any) map[string func (p *WebProvider) configProps(s *storedWebSite, name string) (map[string]any, error) { if name == webConfigAppSettings { - settings, err := p.dataPlane.Settings(s.Name) + settings, err := p.dataPlane.Settings(s.dpName()) if err != nil { return nil, err } @@ -195,7 +206,7 @@ func (p *WebProvider) GetConfig(_ context.Context, id ResourceID, name string) ( return nil, err } - props, err := p.configProps(s, canon) + props, err := p.configProps(p.configHolder(s, canon), canon) if err != nil { return nil, badWebRequest(err) } @@ -220,6 +231,11 @@ func (p *WebProvider) PutConfig( return nil, err } + if s.Pending != nil && (canon == webConfigAppSettings || canon == webConfigWeb || canon == webConfigConn) { + return nil, ErrWebSwapPending + } + + s = p.configHolder(s, canon) props, _ := body[fieldProperties].(map[string]any) switch canon { @@ -253,7 +269,7 @@ func (p *WebProvider) putAppSettings(s *storedWebSite, props map[string]any, mer next := map[string]string{} if merge { - cur, err := p.dataPlane.Settings(s.Name) + cur, err := p.dataPlane.Settings(s.dpName()) if err != nil { return badWebRequest(err) } @@ -270,7 +286,7 @@ func (p *WebProvider) putAppSettings(s *storedWebSite, props map[string]any, mer next[k] = str } - if err := p.dataPlane.SetSettings(s.Name, next); err != nil { + if err := p.dataPlane.SetSettings(s.dpName(), next); err != nil { return badWebRequest(err) } @@ -283,7 +299,7 @@ func (p *WebProvider) putWebConfig(s *storedWebSite, props map[string]any) error oldCfg, newCfg := siteAppConfig(s.Kind, s.Configs[webConfigWeb]), siteAppConfig(s.Kind, cur) if oldCfg != newCfg { - if _, err := p.dataPlane.PutApp(s.Name, newCfg); err != nil { + if _, err := p.dataPlane.PutApp(s.dpName(), newCfg); err != nil { return badWebRequest(err) } } @@ -303,7 +319,7 @@ func (p *WebProvider) PublishingCredentials(_ context.Context, id ResourceID) (m return nil, err } - user, pass, scm, err := p.dataPlane.PublishingCredentials(s.Name) + user, pass, scm, err := p.dataPlane.PublishingCredentials(s.dpName()) if err != nil { return nil, err } @@ -351,16 +367,16 @@ func (p *WebProvider) PublishXML(_ context.Context, id ResourceID) ([]byte, erro return nil, err } - user, pass, scm, err := p.dataPlane.PublishingCredentials(s.Name) + user, pass, scm, err := p.dataPlane.PublishingCredentials(s.dpName()) if err != nil { return nil, err } - info, _ := p.dataPlane.AppInfo(s.Name) + info, _ := p.dataPlane.AppInfo(s.dpName()) return xml.Marshal(publishData{Profiles: []publishProfile{ { - ProfileName: s.Name + " - Zip Deploy", + ProfileName: s.Name + s.slotSuffix() + " - Zip Deploy", PublishMethod: "ZipDeploy", PublishURL: strings.TrimPrefix(scm, "http://"), UserName: user, @@ -448,6 +464,12 @@ func (p *WebProvider) restore(snap *webSnapshot) { s.Configs = map[string]map[string]any{} } + for _, sl := range s.Slots { + if sl.Configs == nil { + sl.Configs = map[string]map[string]any{} + } + } + p.sites[k] = s } } diff --git a/services/azurearm/rp_web_functions.go b/services/azurearm/rp_web_functions.go index 100fa1e147..a970bff31f 100644 --- a/services/azurearm/rp_web_functions.go +++ b/services/azurearm/rp_web_functions.go @@ -17,7 +17,7 @@ func (p *WebProvider) appOrError(id ResourceID) (*storedWebSite, FunctionAppInfo return nil, FunctionAppInfo{}, err } - info, ok := p.dataPlane.AppInfo(s.Name) + info, ok := p.dataPlane.AppInfo(s.dpName()) if !ok { return nil, FunctionAppInfo{}, ErrWebSiteNotFound } @@ -37,18 +37,18 @@ func (p *WebProvider) SetState(_ context.Context, id ResourceID, action string) switch action { case "start": - return p.dataPlane.SetStopped(s.Name, false) + return p.dataPlane.SetStopped(s.dpName(), false) case "stop": - return p.dataPlane.SetStopped(s.Name, true) + return p.dataPlane.SetStopped(s.dpName(), true) } - return p.dataPlane.Restart(s.Name) + return p.dataPlane.Restart(s.dpName()) } // Patch serves PATCH on a site: properties are merged over the stored ones. func (p *WebProvider) Patch(ctx context.Context, id ResourceID, body map[string]any) (map[string]any, error) { p.mu.RLock("Patch.read") - s, ok := p.site(id.ResourceGroup, id.LeafName()) + s, ok := p.record(id) if !ok { p.mu.RUnlock() @@ -110,7 +110,7 @@ func (p *WebProvider) functionEnvelope(id ResourceID, host string, f FunctionApp } return map[string]any{ - "id": fid, fieldName: id.LeafName() + "/" + f.Name, fieldType: webTypeFunction, fieldProperties: props, + "id": fid, fieldName: strings.Join(id.Names, "/") + "/" + f.Name, fieldType: webTypeFunction, fieldProperties: props, } } @@ -191,7 +191,7 @@ func (p *WebProvider) FunctionKeys(ctx context.Context, id ResourceID, fn string return nil, err } - keys, err := p.dataPlane.FunctionKeys(s.Name, f.Name) + keys, err := p.dataPlane.FunctionKeys(s.dpName(), f.Name) if err != nil { return nil, err } @@ -211,7 +211,7 @@ func (p *WebProvider) FunctionSecrets(ctx context.Context, id ResourceID, fn str return nil, err } - keys, err := p.dataPlane.FunctionKeys(s.Name, f.Name) + keys, err := p.dataPlane.FunctionKeys(s.dpName(), f.Name) if err != nil { return nil, err } @@ -235,7 +235,7 @@ func (p *WebProvider) SetFunctionKey( return nil, err } - val, err := p.dataPlane.SetFunctionKey(s.Name, f.Name, keyName, keyValue(body)) + val, err := p.dataPlane.SetFunctionKey(s.dpName(), f.Name, keyName, keyValue(body)) if err != nil { return nil, err } @@ -250,7 +250,7 @@ func (p *WebProvider) DeleteFunctionKey(ctx context.Context, id ResourceID, fn, return err } - return p.dataPlane.DeleteFunctionKey(s.Name, f.Name, keyName) + return p.dataPlane.DeleteFunctionKey(s.dpName(), f.Name, keyName) } // keyValue reads the key value from a flat or properties-wrapped KeyInfo body. @@ -272,7 +272,7 @@ func (p *WebProvider) HostKeys(_ context.Context, id ResourceID) (map[string]any return nil, err } - hk, err := p.dataPlane.HostKeys(s.Name) + hk, err := p.dataPlane.HostKeys(s.dpName()) if err != nil { return nil, err } @@ -300,7 +300,7 @@ func (p *WebProvider) SetHostKey( return nil, err } - val, err := p.dataPlane.SetHostKey(s.Name, canonicalKeyType(keyType), keyName, keyValue(body)) + val, err := p.dataPlane.SetHostKey(s.dpName(), canonicalKeyType(keyType), keyName, keyValue(body)) if err != nil { return nil, err } @@ -315,7 +315,7 @@ func (p *WebProvider) DeleteHostKey(_ context.Context, id ResourceID, keyType, k return err } - return p.dataPlane.DeleteHostKey(s.Name, canonicalKeyType(keyType), keyName) + return p.dataPlane.DeleteHostKey(s.dpName(), canonicalKeyType(keyType), keyName) } func canonicalKeyType(t string) string { diff --git a/services/azurearm/rp_web_routes.go b/services/azurearm/rp_web_routes.go index f8086ec519..de412b1643 100644 --- a/services/azurearm/rp_web_routes.go +++ b/services/azurearm/rp_web_routes.go @@ -44,13 +44,30 @@ var webRoutes = []armRoute{ }, { match: func(segs []string, r *http.Request) bool { - return isWebSiteScope(segs) && (len(segs) > webSiteSegs || r.Method == http.MethodPatch) + return isWebSiteScope(segs) && !isSlotResource(segs, r.Method) && + (len(segs) > webSiteSegs || r.Method == http.MethodPatch) }, handle: func(h *Handler, c *echo.Context, segs []string) error { return h.handleWebSite(c, segs) }, }, } +// isSlotResource reports whether the request is plain GET/PUT/DELETE on sites/{name}/slots/{slot}, which the +// generic resource handler serves through the provider. +func isSlotResource(segs []string, method string) bool { + if len(segs) < webSiteSegs+1 || !strings.EqualFold(segs[webSiteSegs], webSlotsType) { + return false + } + + switch len(segs) { + case webSiteSegs + webSlotRestSegs: + return method == http.MethodGet || method == http.MethodPut || method == http.MethodDelete + } + + return false +} + const ( + webSlotRestSegs = 2 webSubProviderSegIdx = 2 webSubProviderNsIdx = 3 webSubCheckActionIdx = 4 @@ -128,6 +145,11 @@ func webSubRoutes() []webSubRoute { newWebSubRoute(post, []string{"stop"}, webStateAction("stop")), newWebSubRoute(post, []string{webRestartAction}, webStateAction(webRestartAction)), newWebSubRoute(post, []string{webPublishXML}, (*Handler).webPublishXML), + newWebSubRoute(get, []string{webSlotsType}, (*Handler).webListSlots), + newWebSubRoute(post, []string{"slotsswap"}, (*Handler).webSwapSlots), + newWebSubRoute(post, []string{"applySlotConfig"}, (*Handler).webApplySlotConfig), + newWebSubRoute(post, []string{"resetSlotConfig"}, (*Handler).webResetSlotConfig), + newWebSubRoute(post, []string{"slotsdiffs"}, (*Handler).webSlotDiffs), newWebSubRoute(get, []string{webFunctionsSeg}, (*Handler).webListFunctions), newWebSubRoute(get, []string{webFunctionsSeg, "*"}, (*Handler).webGetFunction), newWebSubRoute(post, []string{webFunctionsSeg, "*", webListKeysSeg}, (*Handler).webFunctionKeys), @@ -162,6 +184,15 @@ func (h *Handler) handleWebSite(c *echo.Context, segs []string) error { rest := segs[webSiteSegs:] method := c.Request().Method + if len(rest) >= webSlotRestSegs && strings.EqualFold(rest[0], webSlotsType) { + id, err = ParseGenericResourcePath("/" + joinSegs(segs[:webSiteSegs+webSlotRestSegs])) + if err != nil { + return h.writeAPIError(c, err) + } + + rest = rest[webSlotRestSegs:] + } + if len(rest) == 0 { return h.webPatch(c, p, id) } @@ -191,6 +222,57 @@ func webStateAction(action string) func(*Handler, *echo.Context, *WebProvider, R } } +func (h *Handler) webListSlots(c *echo.Context, p *WebProvider, id ResourceID, _ []string) error { + slots, err := p.ListSlots(c.Request().Context(), id) + if err != nil { + return h.writeAPIError(c, err) + } + + return h.writeJSON(c, http.StatusOK, map[string]any{fieldValue: slots}) +} + +func (h *Handler) webSwapSlots(c *echo.Context, p *WebProvider, id ResourceID, _ []string) error { + return h.writeSlotAction(c, func(body map[string]any) error { return p.SwapSlots(c.Request().Context(), id, body) }) +} + +func (h *Handler) webApplySlotConfig(c *echo.Context, p *WebProvider, id ResourceID, _ []string) error { + return h.writeSlotAction(c, func(body map[string]any) error { + return p.ApplySlotConfig(c.Request().Context(), id, body) + }) +} + +func (h *Handler) webResetSlotConfig(c *echo.Context, p *WebProvider, id ResourceID, _ []string) error { + if err := p.ResetSlotConfig(c.Request().Context(), id); err != nil { + return h.writeAPIError(c, err) + } + + return c.NoContent(http.StatusOK) +} + +func (h *Handler) webSlotDiffs(c *echo.Context, p *WebProvider, id ResourceID, _ []string) error { + return h.writeBodyResult(c, func(body map[string]any) (map[string]any, error) { + diffs, err := p.SlotDifferences(c.Request().Context(), id, body) + if err != nil { + return nil, err + } + + return map[string]any{fieldValue: diffs}, nil + }) +} + +func (h *Handler) writeSlotAction(c *echo.Context, fn func(body map[string]any) error) error { + body, err := decodeJSONBody(c.Request()) + if err != nil { + return h.writeAPIError(c, err) + } + + if err = fn(body); err != nil { + return h.writeAPIError(c, err) + } + + return c.NoContent(http.StatusOK) +} + func (h *Handler) webPublishXML(c *echo.Context, p *WebProvider, id ResourceID, _ []string) error { raw, err := p.PublishXML(c.Request().Context(), id) if err != nil { diff --git a/services/azurearm/rp_web_slots.go b/services/azurearm/rp_web_slots.go new file mode 100644 index 0000000000..bf109140ab --- /dev/null +++ b/services/azurearm/rp_web_slots.go @@ -0,0 +1,895 @@ +package azurearm + +import ( + "context" + "fmt" + "maps" + "net/http" + "regexp" + "sort" + "strings" + "time" +) + +const ( + webSlotsType = "slots" + webSlotProduction = "production" + webSlotConfigNames = "slotConfigNames" + webFieldTargetSlot = "targetSlot" + webFieldCloning = "cloningInfo" + webFieldCloneSrc = "sourceWebAppId" + webFieldCloneOver = "appSettingsOverrides" + webSlotSiteMax = 40 + webSlotNameMax = 19 + webSlotCombinedMax = 59 + webTypeSlotsDiff = "Microsoft.Web/sites/slotsdiffs" + + webDiffGeneral = "General" + webDiffSetting = "AppSetting" + webDiffConnStr = "ConnectionString" +) + +var webSlotNameRe = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$`) + +// pendingSwap marks a slot whose configuration was prepared for a swap with preview. +type pendingSwap struct { + Target string `json:"target"` + Settings map[string]string `json:"settings"` + ConnStrings map[string]any `json:"connStrings,omitempty"` +} + +type swapStatus struct { + Time time.Time `json:"time"` + Source string `json:"source"` + Destination string `json:"destination"` +} + +// swappedWebConfig are the siteConfig keys that follow the content across a swap: language framework versions, +// platform bitness, WebSockets and handler mappings. appCommandLine travels with linuxFxVersion because the +// startup command only makes sense for the image it was written for. +func swappedWebConfig() []string { + return []string{ + webFieldLinuxFx, webFieldAppCmd, "netFrameworkVersion", "phpVersion", "pythonVersion", "nodeVersion", + "javaVersion", "javaContainer", "javaContainerVersion", "use32BitWorkerProcess", "webSocketsEnabled", + "handlerMappings", + } +} + +func (s *storedWebSite) dpName() string { + if s.Slot == "" { + return s.Name + } + + return s.Name + "/" + s.Slot +} + +func (s *storedWebSite) publishUser() string { + if s.Slot == "" { + return "$" + s.Name + } + + return "$" + s.Name + "__" + s.Slot +} + +func (s *storedWebSite) slotSuffix() string { + if s.Slot == "" { + return "" + } + + return "-" + s.Slot +} + +func isProductionSlot(name string) bool { return name == "" || strings.EqualFold(name, webSlotProduction) } + +// record resolves a site or slot id to its stored record. +func (p *WebProvider) record(id ResourceID) (*storedWebSite, bool) { + if len(id.Names) == 2 { + parent, ok := p.site(id.ResourceGroup, id.Names[0]) + if !ok { + return nil, false + } + + sl, found := parent.Slots[strings.ToLower(id.Names[1])] + + return sl, found + } + + return p.site(id.ResourceGroup, id.LeafName()) +} + +// slotRecord returns the production record (slot "" or "production") or the named slot of parent. +func slotRecord(parent *storedWebSite, slot string) (*storedWebSite, bool) { + if isProductionSlot(slot) { + return parent, true + } + + sl, ok := parent.Slots[strings.ToLower(slot)] + + return sl, ok +} + +func slotLabel(app, slot string) string { return strings.ToLower(app + "-" + slot) } + +// slotLabelTaken reports whether