├── .gitignore ├── LICENSE ├── PlanMover.sln ├── README.md └── src ├── .gitignore ├── ArmClient.cs ├── Constants.cs ├── Functions ├── CosmosDbFunctions.cs ├── DurableScalerFunctions.cs └── FunctionPlanFunctions.cs ├── IArmClient.cs ├── Models ├── CosmosDbScaleRequest.cs ├── CreateAndScaleRequest.cs ├── MovePlanRequest.cs ├── Plan.cs └── PremiumPlanResource.cs ├── PlanMover.csproj ├── host.json └── local.settings.json.sample /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | 33 | # Visual Studio 2015/2017 cache/options directory 34 | .vs/ 35 | # Uncomment if you have tasks that create the project's static files in wwwroot 36 | #wwwroot/ 37 | 38 | # Visual Studio 2017 auto generated files 39 | Generated\ Files/ 40 | 41 | # MSTest test Results 42 | [Tt]est[Rr]esult*/ 43 | [Bb]uild[Ll]og.* 44 | 45 | # NUNIT 46 | *.VisualState.xml 47 | TestResult.xml 48 | 49 | # Build Results of an ATL Project 50 | [Dd]ebugPS/ 51 | [Rr]eleasePS/ 52 | dlldata.c 53 | 54 | # Benchmark Results 55 | BenchmarkDotNet.Artifacts/ 56 | 57 | # .NET Core 58 | project.lock.json 59 | project.fragment.lock.json 60 | artifacts/ 61 | 62 | # StyleCop 63 | StyleCopReport.xml 64 | 65 | # Files built by Visual Studio 66 | *_i.c 67 | *_p.c 68 | *_h.h 69 | *.ilk 70 | *.meta 71 | *.obj 72 | *.iobj 73 | *.pch 74 | *.pdb 75 | *.ipdb 76 | *.pgc 77 | *.pgd 78 | *.rsp 79 | *.sbr 80 | *.tlb 81 | *.tli 82 | *.tlh 83 | *.tmp 84 | *.tmp_proj 85 | *_wpftmp.csproj 86 | *.log 87 | *.vspscc 88 | *.vssscc 89 | .builds 90 | *.pidb 91 | *.svclog 92 | *.scc 93 | 94 | # Chutzpah Test files 95 | _Chutzpah* 96 | 97 | # Visual C++ cache files 98 | ipch/ 99 | *.aps 100 | *.ncb 101 | *.opendb 102 | *.opensdf 103 | *.sdf 104 | *.cachefile 105 | *.VC.db 106 | *.VC.VC.opendb 107 | 108 | # Visual Studio profiler 109 | *.psess 110 | *.vsp 111 | *.vspx 112 | *.sap 113 | 114 | # Visual Studio Trace Files 115 | *.e2e 116 | 117 | # TFS 2012 Local Workspace 118 | $tf/ 119 | 120 | # Guidance Automation Toolkit 121 | *.gpState 122 | 123 | # ReSharper is a .NET coding add-in 124 | _ReSharper*/ 125 | *.[Rr]e[Ss]harper 126 | *.DotSettings.user 127 | 128 | # JustCode is a .NET coding add-in 129 | .JustCode 130 | 131 | # TeamCity is a build add-in 132 | _TeamCity* 133 | 134 | # DotCover is a Code Coverage Tool 135 | *.dotCover 136 | 137 | # AxoCover is a Code Coverage Tool 138 | .axoCover/* 139 | !.axoCover/settings.json 140 | 141 | # Visual Studio code coverage results 142 | *.coverage 143 | *.coveragexml 144 | 145 | # NCrunch 146 | _NCrunch_* 147 | .*crunch*.local.xml 148 | nCrunchTemp_* 149 | 150 | # MightyMoose 151 | *.mm.* 152 | AutoTest.Net/ 153 | 154 | # Web workbench (sass) 155 | .sass-cache/ 156 | 157 | # Installshield output folder 158 | [Ee]xpress/ 159 | 160 | # DocProject is a documentation generator add-in 161 | DocProject/buildhelp/ 162 | DocProject/Help/*.HxT 163 | DocProject/Help/*.HxC 164 | DocProject/Help/*.hhc 165 | DocProject/Help/*.hhk 166 | DocProject/Help/*.hhp 167 | DocProject/Help/Html2 168 | DocProject/Help/html 169 | 170 | # Click-Once directory 171 | publish/ 172 | 173 | # Publish Web Output 174 | *.[Pp]ublish.xml 175 | *.azurePubxml 176 | # Note: Comment the next line if you want to checkin your web deploy settings, 177 | # but database connection strings (with potential passwords) will be unencrypted 178 | *.pubxml 179 | *.publishproj 180 | 181 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 182 | # checkin your Azure Web App publish settings, but sensitive information contained 183 | # in these scripts will be unencrypted 184 | PublishScripts/ 185 | 186 | # NuGet Packages 187 | *.nupkg 188 | # The packages folder can be ignored because of Package Restore 189 | **/[Pp]ackages/* 190 | # except build/, which is used as an MSBuild target. 191 | !**/[Pp]ackages/build/ 192 | # Uncomment if necessary however generally it will be regenerated when needed 193 | #!**/[Pp]ackages/repositories.config 194 | # NuGet v3's project.json files produces more ignorable files 195 | *.nuget.props 196 | *.nuget.targets 197 | 198 | # Microsoft Azure Build Output 199 | csx/ 200 | *.build.csdef 201 | 202 | # Microsoft Azure Emulator 203 | ecf/ 204 | rcf/ 205 | 206 | # Windows Store app package directories and files 207 | AppPackages/ 208 | BundleArtifacts/ 209 | Package.StoreAssociation.xml 210 | _pkginfo.txt 211 | *.appx 212 | *.appxbundle 213 | *.appxupload 214 | 215 | # Visual Studio cache files 216 | # files ending in .cache can be ignored 217 | *.[Cc]ache 218 | # but keep track of directories ending in .cache 219 | !?*.[Cc]ache/ 220 | 221 | # Others 222 | ClientBin/ 223 | ~$* 224 | *~ 225 | *.dbmdl 226 | *.dbproj.schemaview 227 | *.jfm 228 | *.pfx 229 | *.publishsettings 230 | orleans.codegen.cs 231 | 232 | # Including strong name files can present a security risk 233 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 234 | #*.snk 235 | 236 | # Since there are multiple workflows, uncomment next line to ignore bower_components 237 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 238 | #bower_components/ 239 | # ASP.NET Core default setup: bower directory is configured as wwwroot/lib/ and bower restore is true 240 | **/wwwroot/lib/ 241 | 242 | # RIA/Silverlight projects 243 | Generated_Code/ 244 | 245 | # Backup & report files from converting an old project file 246 | # to a newer Visual Studio version. Backup files are not needed, 247 | # because we have git ;-) 248 | _UpgradeReport_Files/ 249 | Backup*/ 250 | UpgradeLog*.XML 251 | UpgradeLog*.htm 252 | ServiceFabricBackup/ 253 | *.rptproj.bak 254 | 255 | # SQL Server files 256 | *.mdf 257 | *.ldf 258 | *.ndf 259 | 260 | # Business Intelligence projects 261 | *.rdl.data 262 | *.bim.layout 263 | *.bim_*.settings 264 | *.rptproj.rsuser 265 | *- Backup*.rdl 266 | 267 | # Microsoft Fakes 268 | FakesAssemblies/ 269 | 270 | # GhostDoc plugin setting file 271 | *.GhostDoc.xml 272 | 273 | # Node.js Tools for Visual Studio 274 | .ntvs_analysis.dat 275 | node_modules/ 276 | 277 | # Visual Studio 6 build log 278 | *.plg 279 | 280 | # Visual Studio 6 workspace options file 281 | *.opt 282 | 283 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 284 | *.vbw 285 | 286 | # Visual Studio LightSwitch build output 287 | **/*.HTMLClient/GeneratedArtifacts 288 | **/*.DesktopClient/GeneratedArtifacts 289 | **/*.DesktopClient/ModelManifest.xml 290 | **/*.Server/GeneratedArtifacts 291 | **/*.Server/ModelManifest.xml 292 | _Pvt_Extensions 293 | 294 | # Paket dependency manager 295 | .paket/paket.exe 296 | paket-files/ 297 | 298 | # FAKE - F# Make 299 | .fake/ 300 | 301 | # CodeRush personal settings 302 | .cr/personal 303 | 304 | # Python Tools for Visual Studio (PTVS) 305 | __pycache__/ 306 | *.pyc 307 | 308 | # Cake - Uncomment if you are using it 309 | # tools/** 310 | # !tools/packages.config 311 | 312 | # Tabs Studio 313 | *.tss 314 | 315 | # Telerik's JustMock configuration file 316 | *.jmconfig 317 | 318 | # BizTalk build output 319 | *.btp.cs 320 | *.btm.cs 321 | *.odx.cs 322 | *.xsd.cs 323 | 324 | # OpenCover UI analysis results 325 | OpenCover/ 326 | 327 | # Azure Stream Analytics local run output 328 | ASALocalRun/ 329 | 330 | # MSBuild Binary and Structured Log 331 | *.binlog 332 | 333 | # NVidia Nsight GPU debugger configuration file 334 | *.nvuser 335 | 336 | # MFractors (Xamarin productivity tool) working folder 337 | .mfractor/ 338 | 339 | # Local History for Visual Studio 340 | .localhistory/ 341 | 342 | # BeatPulse healthcheck temp database 343 | healthchecksdb 344 | 345 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 346 | MigrationBackup/ 347 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jeff Hollan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /PlanMover.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.28711.60 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlanMover", "src\PlanMover.csproj", "{86B37910-3D6F-4A3A-9608-74B8AA81BC95}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {86B37910-3D6F-4A3A-9608-74B8AA81BC95}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {86B37910-3D6F-4A3A-9608-74B8AA81BC95}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {86B37910-3D6F-4A3A-9608-74B8AA81BC95}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {86B37910-3D6F-4A3A-9608-74B8AA81BC95}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {19613AE7-CCCD-40E0-99ED-AD790F43608B} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Azure Functions plan switcher 2 | 3 | .NET Function that uses Azure Durable Functions to schedule the scale of a function app + CosmosDB to a pre-warmed premium plan for 4 hours. After the 4 hours it moves the app back to the consumption plan, and scales down CosmosDB database. 4 | 5 | ## Scenario 6 | 7 | Contoso is building an app to process weekly timecards for employees. While timecards may be submitted at anytime during the week, the bulk of requests are made late Friday afternoon. To ensure high performance even with large bursts of data, Contoso can use this sample code to pre-emptively scale their serverless components on Friday afternoons, and scale down on Friday evenings. This gets the app warmed and ready in advance of the burst of data. 8 | 9 | ## Setup 10 | 11 | ⚠ The Azure Functions Premium plan is only available in a sub-set of infrastructure in each region. Internally we call these "webspaces" or "stamps." You will only be able to move your function between plans if the webspace supports both consumption and premium. To make sure your consumption and premium functions land in an enabled webspace you should [create a premium plan](https://aka.ms/funcpremiumpreview) in a new resource group. Then create a consumption plan in the same resource group. You can then remove the premium plan. This will ensure the consumption function is in a premium-enabled webspace. ⚠ 12 | 13 | Before deploying the project you should have an: 14 | * Azure Function deployed to a consumption plan (the plan and app should be in the same resource group - see note above as well) 15 | * Azure CosmosDB database 16 | 17 | You can clone and run the project locally, or deploy to Azure to run. The following app settings should be configured (in `local.settings.json` locally or in the Application Settings in the cloud). 18 | 19 | |Setting name|Example|Description| 20 | |--|--|--| 21 | |SUBSCRIPTION_ID|80d4fe69-xxxx-xxxx-a938-9250f1c8ab03|Azure Subscription Id| 22 | |RESOURCE_GROUP|functions-premium|Resource Group with deployed function and consumption plan. This will be used to create your new temporary premium plan in| 23 | |LOCATION|southcentralus|Azure region of the resource group and function / plan| 24 | |APPNAME|my-function|Name of the Azure Function that should be moved| 25 | |AzureWebJobsStorage|AccountEndpoint=.....|Connection string for the storage account for state of the durable function| 26 | |BASE_PLAN_NAME|SouthCentralUSPlan|Name of the Azure Function consumption plan that will be the starting and ending destination of the function| 27 | |SCALED_PLAN_NAME|my-premium-plan|Name of the Azure Function premium plan you want the app to create and move the function to during the scale duration| 28 | |SCALED_SKU|EP1|The Azure Functions premium plan SKU to use for the instance size. EP1, EP2, or EP3| 29 | |SCALED_PREWARMED_INSTANCES|1|How many app instances to pre-warm during the scale operation| 30 | |COSMOSDB_DATABASENAME|timecardsDb|Name of the CosmosDB database to scale| 31 | |COSMOSDB_CONTAINERNAME|timecards|Name of the CosmosDB container to scale| 32 | |COSMOSDB_CONNECTIONSTRING|AccountEndpoint=.....|Connection string to manage the CosmosDB database| 33 | |BASE_COSMOSDB_RU|400|Initial RUs as the starting and finishing point for CosmosDB| 34 | |SCALED_COSMOSDB_RU|1200|The number to temporarily scale CosmosDB RUs during the scale duration| 35 | |DURATION_SECONDS|14400|The number of seconds to keep the app in a scaled out premium state. NOTE: CosmosDB will only allow scale operations once every 4 hours, so if less than 4 hours make sure base and scaled CosmosDB are the same| 36 | |SERVICE_PRINCIPAL_APP_ID|b91f48f9-xxxx-xxxx-xxxx-614df8f4ca1a|Optional: App ID of a service principal to authenticate and perform operations on the resource group| 37 | |SERVICE_PRINCIPAL_CLIENT_SECRET|as35235a|Optional: Client secret of a service principal to authenticate and perform operations on the resource group| 38 | |SERVICE_PRINCIPAL_TENANT_ID|72f988bf-xxxx-xxxx-xxxx-2d7cd011db47|Optional: Tenant ID of a service principal to authenticate and perform operations on the resource group| 39 | 40 | ## Authentication 41 | 42 | By default the app will attempt to use [Managed Identities for Azure Services](https://docs.microsoft.com/azure/app-service/overview-managed-identity) to authenticate and perform operations on your subscription. You will need to make sure the managed identity has access to contribute to the resource group. Alternative and for local testing you can use a service principal and provide an app ID and client secret in the application settings. 43 | 44 | ## Testing 45 | 46 | To trigger this functionality locally you can call the admin API of the function to start a run. When running locally the request would look something like: 47 | 48 | ```http 49 | POST http://localhost:7071/admin/functions/Timer 50 | 51 | { 52 | "input": "test" 53 | } 54 | ``` 55 | -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # Azure Functions localsettings file 5 | local.settings.json 6 | 7 | # User-specific files 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Build results 17 | [Dd]ebug/ 18 | [Dd]ebugPublic/ 19 | [Rr]elease/ 20 | [Rr]eleases/ 21 | x64/ 22 | x86/ 23 | bld/ 24 | [Bb]in/ 25 | [Oo]bj/ 26 | [Ll]og/ 27 | 28 | # Visual Studio 2015 cache/options directory 29 | .vs/ 30 | # Uncomment if you have tasks that create the project's static files in wwwroot 31 | #wwwroot/ 32 | 33 | # MSTest test Results 34 | [Tt]est[Rr]esult*/ 35 | [Bb]uild[Ll]og.* 36 | 37 | # NUNIT 38 | *.VisualState.xml 39 | TestResult.xml 40 | 41 | # Build Results of an ATL Project 42 | [Dd]ebugPS/ 43 | [Rr]eleasePS/ 44 | dlldata.c 45 | 46 | # DNX 47 | project.lock.json 48 | project.fragment.lock.json 49 | artifacts/ 50 | 51 | *_i.c 52 | *_p.c 53 | *_i.h 54 | *.ilk 55 | *.meta 56 | *.obj 57 | *.pch 58 | *.pdb 59 | *.pgc 60 | *.pgd 61 | *.rsp 62 | *.sbr 63 | *.tlb 64 | *.tli 65 | *.tlh 66 | *.tmp 67 | *.tmp_proj 68 | *.log 69 | *.vspscc 70 | *.vssscc 71 | .builds 72 | *.pidb 73 | *.svclog 74 | *.scc 75 | 76 | # Chutzpah Test files 77 | _Chutzpah* 78 | 79 | # Visual C++ cache files 80 | ipch/ 81 | *.aps 82 | *.ncb 83 | *.opendb 84 | *.opensdf 85 | *.sdf 86 | *.cachefile 87 | *.VC.db 88 | *.VC.VC.opendb 89 | 90 | # Visual Studio profiler 91 | *.psess 92 | *.vsp 93 | *.vspx 94 | *.sap 95 | 96 | # TFS 2012 Local Workspace 97 | $tf/ 98 | 99 | # Guidance Automation Toolkit 100 | *.gpState 101 | 102 | # ReSharper is a .NET coding add-in 103 | _ReSharper*/ 104 | *.[Rr]e[Ss]harper 105 | *.DotSettings.user 106 | 107 | # JustCode is a .NET coding add-in 108 | .JustCode 109 | 110 | # TeamCity is a build add-in 111 | _TeamCity* 112 | 113 | # DotCover is a Code Coverage Tool 114 | *.dotCover 115 | 116 | # NCrunch 117 | _NCrunch_* 118 | .*crunch*.local.xml 119 | nCrunchTemp_* 120 | 121 | # MightyMoose 122 | *.mm.* 123 | AutoTest.Net/ 124 | 125 | # Web workbench (sass) 126 | .sass-cache/ 127 | 128 | # Installshield output folder 129 | [Ee]xpress/ 130 | 131 | # DocProject is a documentation generator add-in 132 | DocProject/buildhelp/ 133 | DocProject/Help/*.HxT 134 | DocProject/Help/*.HxC 135 | DocProject/Help/*.hhc 136 | DocProject/Help/*.hhk 137 | DocProject/Help/*.hhp 138 | DocProject/Help/Html2 139 | DocProject/Help/html 140 | 141 | # Click-Once directory 142 | publish/ 143 | 144 | # Publish Web Output 145 | *.[Pp]ublish.xml 146 | *.azurePubxml 147 | # TODO: Comment the next line if you want to checkin your web deploy settings 148 | # but database connection strings (with potential passwords) will be unencrypted 149 | #*.pubxml 150 | *.publishproj 151 | 152 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 153 | # checkin your Azure Web App publish settings, but sensitive information contained 154 | # in these scripts will be unencrypted 155 | PublishScripts/ 156 | 157 | # NuGet Packages 158 | *.nupkg 159 | # The packages folder can be ignored because of Package Restore 160 | **/packages/* 161 | # except build/, which is used as an MSBuild target. 162 | !**/packages/build/ 163 | # Uncomment if necessary however generally it will be regenerated when needed 164 | #!**/packages/repositories.config 165 | # NuGet v3's project.json files produces more ignoreable files 166 | *.nuget.props 167 | *.nuget.targets 168 | 169 | # Microsoft Azure Build Output 170 | csx/ 171 | *.build.csdef 172 | 173 | # Microsoft Azure Emulator 174 | ecf/ 175 | rcf/ 176 | 177 | # Windows Store app package directories and files 178 | AppPackages/ 179 | BundleArtifacts/ 180 | Package.StoreAssociation.xml 181 | _pkginfo.txt 182 | 183 | # Visual Studio cache files 184 | # files ending in .cache can be ignored 185 | *.[Cc]ache 186 | # but keep track of directories ending in .cache 187 | !*.[Cc]ache/ 188 | 189 | # Others 190 | ClientBin/ 191 | ~$* 192 | *~ 193 | *.dbmdl 194 | *.dbproj.schemaview 195 | *.jfm 196 | *.pfx 197 | *.publishsettings 198 | node_modules/ 199 | orleans.codegen.cs 200 | 201 | # Since there are multiple workflows, uncomment next line to ignore bower_components 202 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 203 | #bower_components/ 204 | 205 | # RIA/Silverlight projects 206 | Generated_Code/ 207 | 208 | # Backup & report files from converting an old project file 209 | # to a newer Visual Studio version. Backup files are not needed, 210 | # because we have git ;-) 211 | _UpgradeReport_Files/ 212 | Backup*/ 213 | UpgradeLog*.XML 214 | UpgradeLog*.htm 215 | 216 | # SQL Server files 217 | *.mdf 218 | *.ldf 219 | 220 | # Business Intelligence projects 221 | *.rdl.data 222 | *.bim.layout 223 | *.bim_*.settings 224 | 225 | # Microsoft Fakes 226 | FakesAssemblies/ 227 | 228 | # GhostDoc plugin setting file 229 | *.GhostDoc.xml 230 | 231 | # Node.js Tools for Visual Studio 232 | .ntvs_analysis.dat 233 | 234 | # Visual Studio 6 build log 235 | *.plg 236 | 237 | # Visual Studio 6 workspace options file 238 | *.opt 239 | 240 | # Visual Studio LightSwitch build output 241 | **/*.HTMLClient/GeneratedArtifacts 242 | **/*.DesktopClient/GeneratedArtifacts 243 | **/*.DesktopClient/ModelManifest.xml 244 | **/*.Server/GeneratedArtifacts 245 | **/*.Server/ModelManifest.xml 246 | _Pvt_Extensions 247 | 248 | # Paket dependency manager 249 | .paket/paket.exe 250 | paket-files/ 251 | 252 | # FAKE - F# Make 253 | .fake/ 254 | 255 | # JetBrains Rider 256 | .idea/ 257 | *.sln.iml 258 | 259 | # CodeRush 260 | .cr/ 261 | 262 | # Python Tools for Visual Studio (PTVS) 263 | __pycache__/ 264 | *.pyc -------------------------------------------------------------------------------- /src/ArmClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Net.Http; 4 | using System.Net.Http.Formatting; 5 | using System.Net.Http.Headers; 6 | using System.Threading.Tasks; 7 | using Microsoft.Azure.Services.AppAuthentication; 8 | using Microsoft.Extensions.Logging; 9 | using Microsoft.IdentityModel.Clients.ActiveDirectory; 10 | using Newtonsoft.Json.Linq; 11 | using PlanMover.Models; 12 | 13 | namespace PlanMover 14 | { 15 | public class ArmClient : IArmClient 16 | { 17 | private static string _accessToken; 18 | private HttpClient _httpClient; 19 | 20 | public ArmClient(HttpClient httpClient) 21 | { 22 | _httpClient = httpClient; 23 | } 24 | 25 | public async Task GetAsync(string url, ILogger log) 26 | { 27 | await CheckToken(); 28 | var result = await _httpClient.GetAsync(url); 29 | return result; 30 | } 31 | 32 | public async Task PatchAsync(string url, JObject body, ILogger log) 33 | { 34 | log.LogInformation($"PATCH URL: {url} \n Request body: {body.ToString()} \n"); 35 | await CheckToken(); 36 | var content = new StringContent(body.ToString(Newtonsoft.Json.Formatting.None)); 37 | content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json"); 38 | var result = await _httpClient.PatchAsync(url, content); 39 | return result; 40 | } 41 | 42 | public async Task PutAsync(string url, JObject body, ILogger log) 43 | { 44 | log.LogInformation($"PUT URL: {url} \n Request body: {body.ToString()} \n"); 45 | await CheckToken(); 46 | var content = new StringContent(body.ToString(Newtonsoft.Json.Formatting.None)); 47 | content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json"); 48 | var result = await _httpClient.PutAsync(url, content); 49 | return result; 50 | } 51 | 52 | public async Task DeleteAsync(string url, ILogger log) 53 | { 54 | log.LogInformation($"DELETE {url}"); 55 | await CheckToken(); 56 | var result = await _httpClient.DeleteAsync(url); 57 | return result; 58 | } 59 | 60 | private async Task CheckToken() 61 | { 62 | if (_accessToken == null && Constants.SERVICE_PRINCIPAL_APP_ID == null) 63 | { 64 | _accessToken = await GetTokenManagedIdentities(); 65 | } 66 | else if (_accessToken == null) 67 | { 68 | _accessToken = await GetTokenServicePrincipal(); 69 | } 70 | _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken); 71 | } 72 | 73 | private async Task GetTokenManagedIdentities() 74 | { 75 | var azureServiceTokenProvider = new AzureServiceTokenProvider(); 76 | string accessToken = await azureServiceTokenProvider.GetAccessTokenAsync("https://management.core.windows.net/"); 77 | return accessToken; 78 | } 79 | 80 | private async Task GetTokenServicePrincipal() 81 | { 82 | AuthenticationContext ac = new AuthenticationContext($"https://login.microsoftonline.com/{Constants.SERVICE_PRINCIPAL_TENANT_ID}", true); 83 | var ar = await ac.AcquireTokenAsync("https://management.core.windows.net/", new ClientCredential(Constants.SERVICE_PRINCIPAL_APP_ID, Constants.SERVICE_PRINCIPAL_CLIENT_SECRET)); 84 | return ar.AccessToken; 85 | } 86 | 87 | public void SetAccessToken(string accessToken) 88 | { 89 | _accessToken = accessToken; 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /src/Constants.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace PlanMover 6 | { 7 | internal static class Constants 8 | { 9 | internal static string SUBSCRIPTION_ID = Environment.GetEnvironmentVariable("SUBSCRIPTION_ID"); 10 | internal static string BASE_PLAN_NAME = Environment.GetEnvironmentVariable("BASE_PLAN_NAME"); 11 | internal static string SCALED_PLAN_NAME = Environment.GetEnvironmentVariable("SCALED_PLAN_NAME"); 12 | internal static int SCALED_PREWARMED_INSTANCES = int.Parse(Environment.GetEnvironmentVariable("SCALED_PREWARMED_INSTANCES")); 13 | internal static string SCALED_SKU = Environment.GetEnvironmentVariable("SCALED_SKU"); 14 | internal static int BASE_COSMOSDB_RU = int.Parse(Environment.GetEnvironmentVariable("BASE_COSMOSDB_RU")); 15 | internal static int SCALED_COSMOSDB_RU = int.Parse(Environment.GetEnvironmentVariable("SCALED_COSMOSDB_RU")); 16 | internal static string LOCATION = Environment.GetEnvironmentVariable("LOCATION"); 17 | internal static string RESOURCE_GROUP = Environment.GetEnvironmentVariable("RESOURCE_GROUP"); 18 | internal static string COSMOSDB_CONNECTIONSTRING = Environment.GetEnvironmentVariable("COSMOSDB_CONNECTIONSTRING"); 19 | internal static string COSMOSDB_DATABASENAME = Environment.GetEnvironmentVariable("COSMOSDB_DATABASENAME"); 20 | internal static string COSMOSDB_CONTAINERNAME = Environment.GetEnvironmentVariable("COSMOSDB_CONTAINERNAME"); 21 | internal static string APPNAME = Environment.GetEnvironmentVariable("APPNAME"); 22 | internal static string SERVICE_PRINCIPAL_APP_ID = Environment.GetEnvironmentVariable("SERVICE_PRINCIPAL_APP_ID"); 23 | internal static string SERVICE_PRINCIPAL_CLIENT_SECRET = Environment.GetEnvironmentVariable("SERVICE_PRINCIPAL_CLIENT_SECRET"); 24 | internal static string SERVICE_PRINCIPAL_TENANT_ID = Environment.GetEnvironmentVariable("SERVICE_PRINCIPAL_TENANT_ID"); 25 | internal static int DURATION_SECONDS = int.Parse(Environment.GetEnvironmentVariable("DURATION_SECONDS")); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Functions/CosmosDbFunctions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.Cosmos; 2 | using Microsoft.Azure.WebJobs; 3 | using Microsoft.Extensions.Logging; 4 | using PlanMover.Models; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | 10 | namespace PlanMover.Functions 11 | { 12 | public class CosmosDbFunctions 13 | { 14 | //TODO: Refactor to use DI when that gets released 15 | private CosmosClient _client = new CosmosClient(Constants.COSMOSDB_CONNECTIONSTRING); 16 | public CosmosDbFunctions() 17 | { 18 | } 19 | 20 | [FunctionName(nameof(Durable_ScaleCosmosDB))] 21 | public async Task Durable_ScaleCosmosDB( 22 | [ActivityTrigger] CosmosDbScaleRequest request, 23 | ILogger log) 24 | { 25 | log.LogInformation($"Scaling up database {request.databaseName} collection {request.containerName} to {request.desiredRus}"); 26 | await _client.Databases[request.databaseName].Containers[request.containerName].ReplaceProvisionedThroughputAsync(request.desiredRus); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Functions/DurableScalerFunctions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using PlanMover.Models; 4 | using Microsoft.Azure.WebJobs; 5 | using Microsoft.Extensions.Logging; 6 | using Newtonsoft.Json.Linq; 7 | using System.Threading; 8 | 9 | namespace PlanMover.Functions 10 | { 11 | public class DurableScalerFunctions 12 | { 13 | public DurableScalerFunctions() 14 | { 15 | } 16 | 17 | [FunctionName("Timer")] 18 | public async Task Durable_TimerStart( 19 | [TimerTrigger("0 0 16 * * FRI")]TimerInfo timerInfo, 20 | [OrchestrationClient]DurableOrchestrationClient starter, 21 | ILogger log) 22 | { 23 | log.LogInformation($"Timer fired at: {DateTime.UtcNow} \n\n {nameof(Durable_TimerStart)}"); 24 | 25 | // Create the move request 26 | var moveRequest = new CreateAndScaleRequest 27 | { 28 | appName = Constants.APPNAME, 29 | subscriptionId = Constants.SUBSCRIPTION_ID, 30 | basePlanName = Constants.BASE_PLAN_NAME, 31 | scaledPlanName = Constants.SCALED_PLAN_NAME, 32 | scaledPrewarmedInstances = Constants.SCALED_PREWARMED_INSTANCES, 33 | scaledSku = Constants.SCALED_SKU, 34 | baseCosmosDbRequestUnit = Constants.BASE_COSMOSDB_RU, 35 | scaledCosmosDbRequestUnit = Constants.SCALED_COSMOSDB_RU, 36 | location = Constants.LOCATION, 37 | resourceGroup = Constants.RESOURCE_GROUP, 38 | databaseName = Constants.COSMOSDB_DATABASENAME, 39 | containerName = Constants.COSMOSDB_CONTAINERNAME 40 | }; 41 | 42 | // Kick off the orchestration 43 | string instanceId = await starter.StartNewAsync(nameof(Durable_Orchestrator), moveRequest); 44 | 45 | log.LogInformation($"Started orchestration with ID = '{instanceId}'."); 46 | } 47 | 48 | [FunctionName(nameof(Durable_Orchestrator))] 49 | public async Task Durable_Orchestrator( 50 | [OrchestrationTrigger] DurableOrchestrationContext context, 51 | ILogger log, 52 | CancellationToken ctx) 53 | { 54 | if (!context.IsReplaying) log.LogInformation("Starting the plan mover orchestration"); 55 | 56 | var input = context.GetInput(); 57 | 58 | // Create premium plan 59 | string premiumPlanId = await context.CallActivityAsync(nameof(FunctionPlanFunctions.Durable_CreatePlan), 60 | new Plan 61 | { 62 | subscriptionId = input.subscriptionId, 63 | location = input.location, 64 | resourceGroup = input.resourceGroup, 65 | name = input.scaledPlanName, 66 | prewarmedInstances = input.scaledPrewarmedInstances, 67 | sku = input.scaledSku 68 | }); 69 | if (!context.IsReplaying) log.LogInformation($"Created plan with ID {premiumPlanId}"); 70 | 71 | // Scale up cosmos DB 72 | await context.CallActivityAsync(nameof(CosmosDbFunctions.Durable_ScaleCosmosDB), 73 | new CosmosDbScaleRequest 74 | { 75 | databaseName = input.databaseName, 76 | containerName = input.containerName, 77 | desiredRus = input.scaledCosmosDbRequestUnit 78 | }); 79 | if (!context.IsReplaying) log.LogInformation($"Scaled CosmosDB database {input.databaseName} to {input.scaledCosmosDbRequestUnit} RUs"); 80 | 81 | // Move function app to premium plan 82 | await context.CallActivityAsync(nameof(FunctionPlanFunctions.Durable_MoveApp), 83 | new MovePlanRequest 84 | { 85 | appName = input.appName, 86 | resourceGroup = input.resourceGroup, 87 | subscriptionId = input.subscriptionId, 88 | startingPlanName = input.basePlanName, 89 | destinationPlanName = input.scaledPlanName 90 | }); 91 | if (!context.IsReplaying) log.LogInformation($"Moved function {input.appName} to plan {input.scaledPlanName}"); 92 | 93 | // Wait for 3 hours 94 | if (!context.IsReplaying) log.LogInformation($"Waiting for 4 hours..."); 95 | await context.CreateTimer(context.CurrentUtcDateTime.AddSeconds(Constants.DURATION_SECONDS), ctx); 96 | if (!context.IsReplaying) log.LogInformation($"Waited for 4 hours. Now scaling down..."); 97 | 98 | // Move function app to consumption plan 99 | await context.CallActivityAsync(nameof(FunctionPlanFunctions.Durable_MoveApp), 100 | new MovePlanRequest 101 | { 102 | appName = input.appName, 103 | resourceGroup = input.resourceGroup, 104 | subscriptionId = input.subscriptionId, 105 | startingPlanName = input.scaledPlanName, 106 | destinationPlanName = input.basePlanName 107 | }); 108 | if (!context.IsReplaying) log.LogInformation($"Moved function {input.appName} back to plan {input.basePlanName}"); 109 | 110 | // Scale down cosmos DB 111 | await context.CallActivityAsync(nameof(CosmosDbFunctions.Durable_ScaleCosmosDB), 112 | new CosmosDbScaleRequest 113 | { 114 | databaseName = input.databaseName, 115 | containerName = input.containerName, 116 | desiredRus = input.baseCosmosDbRequestUnit 117 | }); 118 | if (!context.IsReplaying) log.LogInformation($"Scaled CosmosDB database {input.databaseName} back to {input.baseCosmosDbRequestUnit} RUs"); 119 | 120 | // Delete premium plan 121 | await context.CallActivityAsync(nameof(FunctionPlanFunctions.Durable_DeletePlan), premiumPlanId); 122 | if (!context.IsReplaying) log.LogInformation($"Premium plan deleted"); 123 | 124 | return JObject.FromObject(new 125 | { 126 | status = "completed", 127 | request = input 128 | }); 129 | } 130 | } 131 | } -------------------------------------------------------------------------------- /src/Functions/FunctionPlanFunctions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.WebJobs; 2 | using Microsoft.Extensions.Logging; 3 | using Newtonsoft.Json.Linq; 4 | using PlanMover.Models; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | 10 | namespace PlanMover.Functions 11 | { 12 | public class FunctionPlanFunctions 13 | { 14 | //TODO: refactor to use DI when that gets released 15 | private static IArmClient _armClient = new ArmClient(new System.Net.Http.HttpClient()); 16 | public FunctionPlanFunctions() 17 | { 18 | } 19 | 20 | [FunctionName(nameof(Durable_CreatePlan))] 21 | public async Task Durable_CreatePlan( 22 | [ActivityTrigger] Plan plan, 23 | ILogger log) 24 | { 25 | string resourceId = $"/subscriptions/{plan.subscriptionId}/resourceGroups/{plan.resourceGroup}/providers/Microsoft.Web/serverfarms/{plan.name}"; 26 | 27 | var response = await _armClient.PutAsync( 28 | url: $"https://management.azure.com{resourceId}?api-version=2018-02-01", 29 | body: JObject.FromObject(new PremiumPlanResource(plan.location, 20, plan.sku, plan.prewarmedInstances)), 30 | log: log); 31 | if (response.IsSuccessStatusCode) 32 | return resourceId; 33 | else 34 | throw new ArgumentException($"HTTP Response was not successful. Status code {response.StatusCode}:{response.ReasonPhrase} -- {await response.Content.ReadAsStringAsync()}"); 35 | } 36 | 37 | [FunctionName(nameof(Durable_MoveApp))] 38 | public async Task Durable_MoveApp( 39 | [ActivityTrigger] MovePlanRequest request, 40 | ILogger log) 41 | { 42 | log.LogInformation($"Moving app {request.appName} from plan {request.startingPlanName} to {request.destinationPlanName}"); 43 | 44 | string appResourceId = $"/subscriptions/{request.subscriptionId}/resourceGroups/{request.resourceGroup}/providers/Microsoft.Web/sites/{request.appName}"; 45 | string planResourceId = $"/subscriptions/{request.subscriptionId}/resourceGroups/{request.resourceGroup}/providers/Microsoft.Web/serverfarms/{request.destinationPlanName}"; 46 | var response = await _armClient.PatchAsync( 47 | url: $"https://management.azure.com{appResourceId}?api-version=2018-02-01", 48 | body: JObject.FromObject(new { 49 | properties = new 50 | { 51 | serverFarmId = planResourceId 52 | } 53 | }), 54 | log: log); 55 | if (!response.IsSuccessStatusCode) 56 | throw new ArgumentException($"HTTP Response was not successful. Status code {response.StatusCode}:{response.ReasonPhrase} -- {await response.Content.ReadAsStringAsync()}"); 57 | } 58 | 59 | [FunctionName(nameof(Durable_DeletePlan))] 60 | public async Task Durable_DeletePlan( 61 | [ActivityTrigger] string planId, 62 | ILogger log) 63 | { 64 | var response = await _armClient.DeleteAsync($"https://management.azure.com{planId}?api-version=2018-02-01", log); 65 | if (!response.IsSuccessStatusCode) 66 | throw new ArgumentException($"HTTP Response was not successful. Status code {response.StatusCode}:{response.ReasonPhrase} -- {await response.Content.ReadAsStringAsync()}"); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/IArmClient.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using Newtonsoft.Json.Linq; 3 | using PlanMover.Models; 4 | using System.Net.Http; 5 | using System.Threading.Tasks; 6 | 7 | namespace PlanMover 8 | { 9 | public interface IArmClient 10 | { 11 | Task GetAsync(string url, ILogger log); 12 | Task PutAsync(string url, JObject body, ILogger log); 13 | 14 | Task PatchAsync(string url, JObject body, ILogger log); 15 | Task DeleteAsync(string url, ILogger log); 16 | } 17 | } -------------------------------------------------------------------------------- /src/Models/CosmosDbScaleRequest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace PlanMover.Models 6 | { 7 | public class CosmosDbScaleRequest 8 | { 9 | public string databaseName { get; set; } 10 | public int desiredRus { get; set; } 11 | public string containerName { get; set; } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Models/CreateAndScaleRequest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace PlanMover.Models 6 | { 7 | public class CreateAndScaleRequest 8 | { 9 | public string subscriptionId { get; set; } 10 | public string basePlanName { get; set; } 11 | public string scaledPlanName { get; set; } 12 | public int scaledPrewarmedInstances { get; set; } 13 | public string scaledSku { get; set; } 14 | public string resourceGroup { get; set; } 15 | public string location { get; set; } 16 | public int baseCosmosDbRequestUnit { get; set; } 17 | public int scaledCosmosDbRequestUnit { get; set; } 18 | public string databaseName { get; set; } 19 | public string appName { get; set; } 20 | public string containerName { get; set; } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Models/MovePlanRequest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace PlanMover.Models 6 | { 7 | public class MovePlanRequest 8 | { 9 | public string appName { get; set; } 10 | public string subscriptionId { get; set; } 11 | public string resourceGroup { get; set; } 12 | public string startingPlanName { get; set; } 13 | public string destinationPlanName { get; set; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Models/Plan.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace PlanMover.Models 6 | { 7 | public class Plan 8 | { 9 | public string name { get; set; } 10 | public string resourceGroup { get; set; } 11 | public int prewarmedInstances { get; set; } = 1; 12 | public string sku { get; set; } = "EP1"; 13 | public string location { get; set; } = "southcentralus"; 14 | public string subscriptionId { get; set; } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Models/PremiumPlanResource.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace PlanMover.Models 6 | { 7 | public class PremiumPlanResource 8 | { 9 | public PremiumPlanResource(string location, int maximumElasticWorkerCount, string skuName, int skuCapacity) 10 | { 11 | this.location = location; 12 | properties.maximumElasticWorkerCount = maximumElasticWorkerCount; 13 | sku.name = skuName; 14 | sku.capacity = skuCapacity; 15 | } 16 | public string location { get; set; } 17 | public Properties properties { get; set; } = new Properties(); 18 | public Sku sku { get; set; } = new Sku(); 19 | 20 | public class Properties 21 | { 22 | public int maximumElasticWorkerCount { get; set; } 23 | } 24 | 25 | public class Sku 26 | { 27 | public string name { get; set; } 28 | public string tier { get; set; } = "ElasticPremium"; 29 | public int capacity { get; set; } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/PlanMover.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netcoreapp2.1 4 | v2 5 | PlanMover 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | PreserveNewest 17 | 18 | 19 | PreserveNewest 20 | Never 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0" 3 | } -------------------------------------------------------------------------------- /src/local.settings.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "AzureWebJobsStorage": "UseDevelopmentStorage=true", 5 | "FUNCTIONS_WORKER_RUNTIME": "dotnet", 6 | "APPNAME": "my-timecard-functionapp", 7 | "SUBSCRIPTION_ID": "ef90e930-xxxx-xxxx-xxxx-748e0eea69de", 8 | "BASE_PLAN_NAME": "SouthCentralUSPlan", 9 | "SCALED_PLAN_NAME": "my-premium-plan", 10 | "SCALED_PREWARMED_INSTANCES": "1", 11 | "SCALED_SKU": "EP1", 12 | "COSMOSDB_DATABASENAME": "timecardDb", 13 | "COSMOSDB_CONTAINERNAME": "timecard", 14 | "BASE_COSMOSDB_RU": "400", 15 | "SCALED_COSMOSDB_RU": "10000", 16 | "LOCATION": "southcentralus", 17 | "RESOURCE_GROUP": "jehollan-plan-mover", 18 | "COSMOSDB_CONNECTIONSTRING": "", 19 | "DURATION_SECONDS": "14400", 20 | "SERVICE_PRINCIPAL_APP_ID": "b91f48f9-xxxx-xxxx-xxxx-614df8f4ca1a", 21 | "SERVICE_PRINCIPAL_CLIENT_SECRET": "", 22 | "SERVICE_PRINCIPAL_TENANT_ID": "72f988bf-xxxx-xxxx-xxxx-2d7cd011db47" 23 | } 24 | } --------------------------------------------------------------------------------