MaxOffice.TasksByMe.Azure.psm1
# MaxOffice.TasksByMe.Azure.psm1 # PowerShell Module for deploying Tasks by Me to Azure App Service #Requires -Modules Az.Resources, Az.Websites, MaxOffice.TasksByMe.Entra # Module Constants $script:ArmTemplateUrl = "https://raw.githubusercontent.com/MaxOffice/tasksbyme/refs/heads/main/deploy/azure/arm-template.json" $script:DefaultGitRepoUrl = "https://github.com/maxoffice/tasksbyme" # Helper function to show browser UI for login function Show-LoginUI { Write-Host "No active Azure connection found. Connecting to Azure..." Write-Host "In your browser, please sign in using an account with Azure admin privileges." -ForegroundColor Yellow Connect-AzAccount | Out-Null Write-Host "Successfully connected to Azure" } # Helper function to ensure Azure connection function EnsureAzureConnection { try { $context = Get-AzContext if (-not $context) { Show-LoginUI } else { Write-Verbose "Using existing Azure connection for subscription: $($context.Subscription.Name)" } return $true } catch { Write-Error "Failed to connect to Azure: $_" return $false } } # Helper function to download ARM template function DownloadArmTemplate { try { Write-Verbose "Downloading ARM template from: $script:ArmTemplateUrl" $tempFile = [System.IO.Path]::GetTempFileName() + ".json" Invoke-WebRequest -Uri $script:ArmTemplateUrl -OutFile $tempFile -TimeoutSec 30 # Validate JSON content $content = Get-Content $tempFile -Raw | ConvertFrom-Json if (-not $content.'$schema') { throw "Downloaded file does not appear to be a valid ARM template" } Write-Verbose "ARM template downloaded and validated successfully" return $tempFile } catch { Write-Error "Failed to download ARM template: $_" if (Test-Path $tempFile) { Remove-Item $tempFile -Force -ErrorAction SilentlyContinue } return $null } } # Helper function to check if website exists function TestWebsiteExists { param( [Parameter(Mandatory = $true)] [string]$WebAppName ) try { $url = "https://$WebAppName.azurewebsites.net" Write-Verbose "Checking if website exists at: $url" $response = Invoke-WebRequest -Uri $url -Method Head -TimeoutSec 10 -ErrorAction Stop Write-Verbose "Website exists and responded with status: $($response.StatusCode)" return $true } catch { if ($_.Exception.Response.StatusCode -eq 404) { Write-Verbose "Website does not exist (404 response)" return $false } elseif ($_.Exception -is [System.Net.WebException] -or $_.Exception.InnerException -is [System.Net.WebException]) { Write-Verbose "Website does not exist (connection failed)" return $false } elseif ($_ -like "*Name or service not known*") { Write-Verbose "Website does not exist (Azure Web App service not found)" return $false } elseif ($_ -like "*No such host is known*") { Write-Verbose "Website does not exist (Azure Web App service not found)" return $false } else { # Website exists but returned an error (500, etc.) Write-Verbose "Website exists but returned error: $_" return $true } } } # Helper function to generate session secret function GenerateSessionSecret { $bytes = New-Object byte[] 32 [System.Security.Cryptography.RNGCryptoServiceProvider]::Create().GetBytes($bytes) return [Convert]::ToBase64String($bytes) } <# .SYNOPSIS Installs the Tasks by Me application to Azure App Service. .DESCRIPTION Installs the Tasks by Me Node.js application to Azure App Service using an ARM template. Can either use provided authentication parameters or create a new Entra ID application. .PARAMETER WebAppName The name for the Azure Web App. Must be globally unique. .PARAMETER ResourceGroupName The name of the Azure Resource Group where the app will be deployed. If not specified, defaults to "TasksByMe-{WebAppName}-rg". Will be created if it doesn't exist. .PARAMETER TenantId The Azure tenant ID. If not provided, will create new Entra ID application. .PARAMETER ClientId The Entra ID application client ID. If not provided, will create new Entra ID application. .PARAMETER ClientSecret The Entra ID application client secret. If not provided, will create new Entra ID application. .PARAMETER GitRepoUrl The Git repository URL for deployment. Defaults to the standard repository. .EXAMPLE Install-TasksByMeAzureWebApp -WebAppName "mytasks" .EXAMPLE Install-TasksByMeAzureWebApp -WebAppName "mytasks" -ResourceGroupName "myresources" .EXAMPLE Install-TasksByMeAzureWebApp -WebAppName "mytasks" -TenantId "xxx" -ClientId "yyy" -ClientSecret "zzz" #> function Install-TasksByMeAzureWebApp { [CmdletBinding(DefaultParameterSetName = 'CreateApp')] param( [Parameter(Mandatory = $true)] [string]$WebAppName, [Parameter()] [string]$ResourceGroupName, [Parameter(Mandatory = $true, ParameterSetName = 'UseExisting')] [string]$TenantId, [Parameter(Mandatory = $true, ParameterSetName = 'UseExisting')] [string]$ClientId, [Parameter(Mandatory = $true, ParameterSetName = 'UseExisting')] [string]$ClientSecret, [Parameter()] [string]$GitRepoUrl = $script:DefaultGitRepoUrl ) # Initialize tracking variables $createdEntraApp = $false $createdResourceGroup = $false $templateFile = $null try { # Set default resource group name if not provided if (-not $ResourceGroupName) { $ResourceGroupName = "TasksByMe-$WebAppName-rg" Write-Verbose "Using default resource group name: $ResourceGroupName" } # Check if website already exists if (TestWebsiteExists -WebAppName $WebAppName) { Write-Error "Website $WebAppName.azurewebsites.net already exists. Please choose a different name." return $null } # Download ARM template $templateFile = DownloadArmTemplate if (-not $templateFile) { Write-Error "Failed to download ARM template. Installation aborted." return $null } # Get authentication parameters if ($PSCmdlet.ParameterSetName -eq 'CreateApp') { Write-Verbose "Creating new Entra ID application..." $appResult = Install-TasksByMeApp if (-not $appResult) { Write-Error "Failed to create Entra ID application. Installation aborted." return $null } $createdEntraApp = $true # TRACKING: We created this app $TenantId = $appResult.TenantId $ClientId = $appResult.ClientId $ClientSecret = $appResult.ClientSecret Write-Verbose "Created Entra ID application with Client ID: $ClientId" } # Generate session secret $sessionSecret = GenerateSessionSecret Write-Verbose "Generated session secret" # Now ensure Azure connection is available for deployment if (-not (EnsureAzureConnection)) { throw "Failed to establish Azure connection" } # Create resource group if it doesn't exist $existingRg = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue if (-not $existingRg) { Write-Verbose "Creating resource group: $ResourceGroupName" $location = "East US" # Default location New-AzResourceGroup -Name $ResourceGroupName -Location $location | Out-Null $createdResourceGroup = $true # TRACKING: We created this resource group Write-Verbose "Created resource group in location: $location" } else { Write-Verbose "Using existing resource group: $ResourceGroupName" } # Prepare deployment parameters $deploymentParams = @{ webAppName = $WebAppName tenantId = $TenantId clientId = $ClientId clientSecret = $ClientSecret sessionSecret = $sessionSecret gitRepoUrl = $GitRepoUrl } # Deploy ARM template Write-Verbose "Starting ARM template deployment..." $deploymentName = "TasksByMe-$(Get-Date -Format 'yyyyMMdd-HHmmss')" $deployment = New-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $templateFile -TemplateParameterObject $deploymentParams -Name $deploymentName if ($deployment.ProvisioningState -eq 'Succeeded') { $webAppUrl = $deployment.Outputs.webAppUrl.Value Write-Verbose "Deployment completed successfully" # Update Entra ID app with correct URLs if we created it if ($createdEntraApp) { # TRACKING: Only update if we created the app try { Write-Verbose "Updating Entra ID application URLs..." Set-TasksByMeAppUrl -BaseUri $webAppUrl -Confirm:$false } catch { Write-Warning "Failed to update Entra ID application URLs: $_. You may need to update them manually." } } return [PSCustomObject]@{ WebAppName = $WebAppName WebAppUrl = $webAppUrl ResourceGroupName = $ResourceGroupName TenantId = $TenantId ClientId = $ClientId DeploymentStatus = 'Succeeded' } } else { throw "Deployment failed with status: $($deployment.ProvisioningState)" } } catch { Write-Error "Installation failed: $_" # CLEANUP LOGIC using tracking variables Write-Verbose "Attempting to clean up created resources..." # Clean up Azure resources if deployment failed try { if ($createdResourceGroup) { # TRACKING: We created the RG, remove it entirely Write-Verbose "Cleaning up created resource group: $ResourceGroupName" $rgResources = Get-AzResource -ResourceGroupName $ResourceGroupName -ErrorAction SilentlyContinue if (-not $rgResources -or $rgResources.Count -eq 0) { Remove-AzResourceGroup -Name $ResourceGroupName -Force -ErrorAction SilentlyContinue Write-Verbose "Removed created resource group" } else { Write-Warning "Cannot clean up resource group $ResourceGroupName as it contains resources" } } else { # TRACKING: RG existed before, only clean up our resources $webApp = Get-AzWebApp -ResourceGroupName $ResourceGroupName -Name $WebAppName -ErrorAction SilentlyContinue if ($webApp) { Write-Verbose "Cleaning up partially created web app: $WebAppName" Remove-AzWebApp -ResourceGroupName $ResourceGroupName -Name $WebAppName -Force -ErrorAction SilentlyContinue } $appServicePlan = Get-AzAppServicePlan -ResourceGroupName $ResourceGroupName -Name "$WebAppName-plan" -ErrorAction SilentlyContinue if ($appServicePlan) { Write-Verbose "Cleaning up partially created app service plan: $WebAppName-plan" Remove-AzAppServicePlan -ResourceGroupName $ResourceGroupName -Name "$WebAppName-plan" -Force -ErrorAction SilentlyContinue } } } catch { Write-Warning "Failed to clean up Azure resources: $_" } # Clean up Entra ID app if we created it if ($createdEntraApp) { # TRACKING: Only remove if we created it try { Write-Verbose "Cleaning up created Entra ID application" Remove-TasksByMeApp -Confirm:$false Write-Verbose "Removed created Entra ID application" } catch { Write-Warning "Failed to clean up Entra ID application: $_. You may need to remove it manually." } } return $null } finally { # Always clean up template file if ($templateFile -and (Test-Path $templateFile)) { Remove-Item $templateFile -Force -ErrorAction SilentlyContinue } } } <# .SYNOPSIS Gets information about a deployed Tasks by Me Azure Web App. .DESCRIPTION Retrieves information about a Tasks by Me application deployed to Azure App Service. .PARAMETER WebAppName The name of the Azure Web App. .PARAMETER ResourceGroupName The name of the Azure Resource Group containing the app. .EXAMPLE Get-TasksByMeAzureWebApp -WebAppName "mytasks" -ResourceGroupName "myresources" #> function Get-TasksByMeAzureWebApp { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$WebAppName, [Parameter()] [string]$ResourceGroupName ) try { # Ensure Azure connection if (-not (EnsureAzureConnection)) { return $null } # Set default resource group name if not provided if (-not $ResourceGroupName) { $ResourceGroupName = "TasksByMe-$WebAppName-rg" Write-Verbose "Using default resource group name: $ResourceGroupName" } # Get web app information $webApp = Get-AzWebApp -ResourceGroupName $ResourceGroupName -Name $WebAppName -ErrorAction Stop # Get app settings $appSettings = @{} foreach ($setting in $webApp.SiteConfig.AppSettings) { $appSettings[$setting.Name] = $setting.Value } return [PSCustomObject]@{ WebAppName = $webApp.Name ResourceGroupName = $webApp.ResourceGroup WebAppUrl = "https://$($webApp.DefaultHostName)" Location = $webApp.Location State = $webApp.State TenantId = $appSettings['TENANT_ID'] ClientId = $appSettings['CLIENT_ID'] NodeVersion = $webApp.SiteConfig.LinuxFxVersion LastModifiedTime = $webApp.LastModifiedTimeUtc } } catch { if ($_.Exception.Message -like "*ResourceNotFound*") { Write-Warning "Web app '$WebAppName' not found in resource group '$ResourceGroupName'." return $null } else { Write-Error "Failed to retrieve web app information: $_" return $null } } } <# .SYNOPSIS Removes a deployed Tasks by Me Azure Web App. .DESCRIPTION Removes a Tasks by Me application and its associated resources from Azure App Service. .PARAMETER WebAppName The name of the Azure Web App to remove. .PARAMETER ResourceGroupName The name of the Azure Resource Group containing the app. .PARAMETER RemoveResourceGroup Switch to also remove the resource group. Defaults to $true if the resource group follows the auto-generated naming pattern. .PARAMETER RemoveEntraApp Switch to also remove the associated Entra ID application. .EXAMPLE Remove-TasksByMeAzureWebApp -WebAppName "mytasks" .EXAMPLE Remove-TasksByMeAzureWebApp -WebAppName "mytasks" -ResourceGroupName "myresources" -RemoveResourceGroup:$false .EXAMPLE Remove-TasksByMeAzureWebApp -WebAppName "mytasks" -RemoveEntraApp -RemoveResourceGroup #> function Remove-TasksByMeAzureWebApp { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Mandatory = $true)] [string]$WebAppName, [Parameter()] [string]$ResourceGroupName, [Parameter()] [switch]$RemoveResourceGroup, [Parameter()] [switch]$RemoveEntraApp ) try { # Ensure Azure connection if (-not (EnsureAzureConnection)) { return } # Set default resource group name if not provided if (-not $ResourceGroupName) { $ResourceGroupName = "TasksByMe-$WebAppName-rg" Write-Verbose "Using default resource group name: $ResourceGroupName" } # Determine if we should remove the resource group $shouldRemoveRg = $RemoveResourceGroup.IsPresent if (-not $RemoveResourceGroup.IsPresent) { # Default to true if resource group follows our naming convention if ($ResourceGroupName -eq "TasksByMe-$WebAppName-rg") { $shouldRemoveRg = $true Write-Verbose "Resource group follows auto-generated pattern, will remove by default" } } # Check if resource group exists $resourceGroup = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue if (-not $resourceGroup) { Write-Warning "Resource group '$ResourceGroupName' not found." return } # Check if web app exists $webApp = Get-AzWebApp -ResourceGroupName $ResourceGroupName -Name $WebAppName -ErrorAction SilentlyContinue if (-not $webApp) { Write-Warning "Web app '$WebAppName' not found in resource group '$ResourceGroupName'." # If we should remove RG and it's empty or only contains our resources, proceed if ($shouldRemoveRg) { $rgResources = Get-AzResource -ResourceGroupName $ResourceGroupName if (-not $rgResources -or $rgResources.Count -eq 0) { if ($PSCmdlet.ShouldProcess($ResourceGroupName, "Remove empty resource group")) { Write-Verbose "Removing empty resource group: $ResourceGroupName" Remove-AzResourceGroup -Name $ResourceGroupName -Force Write-Output "Removed empty resource group '$ResourceGroupName'." } } } return } if ($PSCmdlet.ShouldProcess("$WebAppName (and associated resources)", "Remove Azure Web App")) { # Remove web app Write-Verbose "Removing web app: $WebAppName" Remove-AzWebApp -ResourceGroupName $ResourceGroupName -Name $WebAppName -Force # Remove associated App Service Plan $planName = "$WebAppName-plan" $plan = Get-AzAppServicePlan -ResourceGroupName $ResourceGroupName -Name $planName -ErrorAction SilentlyContinue if ($plan) { Write-Verbose "Removing App Service Plan: $planName" Remove-AzAppServicePlan -ResourceGroupName $ResourceGroupName -Name $planName -Force } Write-Output "Successfully removed web app '$WebAppName' and associated resources." # Remove resource group if requested and it only contains our resources if ($shouldRemoveRg) { Write-Verbose "Checking if resource group should be removed..." $remainingResources = Get-AzResource -ResourceGroupName $ResourceGroupName if (-not $remainingResources -or $remainingResources.Count -eq 0) { Write-Verbose "Resource group is empty, removing: $ResourceGroupName" Remove-AzResourceGroup -Name $ResourceGroupName -Force Write-Output "Removed resource group '$ResourceGroupName'." } else { Write-Warning "Resource group '$ResourceGroupName' contains other resources and will not be removed. Remaining resources: $($remainingResources.Count)" } } # Remove Entra ID app if requested if ($RemoveEntraApp) { Write-Verbose "Removing associated Entra ID application..." Remove-TasksByMeApp -Confirm:$false } } } catch { Write-Error "Failed to remove web app: $_" } } # Export module members Export-ModuleMember -Function @( 'Install-TasksByMeAzureWebApp', 'Get-TasksByMeAzureWebApp', 'Remove-TasksByMeAzureWebApp' ) |