Azure Functions - Learnings from executing PowerShell from C#
Our SaaS (ARGOS Cloud Security) is developed in C# and 95% of our application runs on Azure Functions. As ARGOS is about to get its latest new feature, the capability to assess more M365 components, we now have a requirement to execute PowerShell scripts from this c# code as certain M365 APIs are only exposed via Microsoft’s own PowerShell modules. (looking at you, Exchange Online).
This sounded like a straightforward task, but it turned out to be a bit more complex than expected. Here are some of the challenges we faced and how we solved them.
- We use Azure Functions Flex Consumption plan, which means we do not have access to Kudu. Most of the examples on the internet assume access to Kudu in order to install the PowerShell modules. This is not possible in our case, so we had to find a different way to install the modules.
- Azure Functions says “
pwsh
what?” - We mostly develop on Windows, but our Azure Functions run on Linux. This means that we have to be careful about the modules we install and make sure they are compatible with Linux. While we live in a cross-platform world, down to the OS and file system level, there are subtle differences that can cause issues.
Installing PowerShell modules in Azure Functions
The first step was to decide how to install the required PowerShell modules. As we couldn’t copy the modules to the Kudu site (and really, that’s a pretty clunky way to do it), we decided to bundle the modules with our code.
The folder structure above shows how we bundled the modules with our code. The modules
folder contains the PowerShell modules we need, and the PowerShell
folder contains the PowerShell scripts we want to execute. The way we did this is by using the Save-Module
cmdlet to save the modules to a folder locally. We then committed this folder to our repository. This way, we can be sure that the modules are always available when we deploy our code. This was probably the easiest part of the whole process.
Executing PowerShell from C#
Executing PowerShell from c# is not an uncommon task, but with Azure Functions, if you’re not on Windows and using the PowerShell language worker, it can be a bit tricky. powershell
is not available on Linux, and pwsh
is not installed on Flex Consumption. There is, as of now, no way to install third party dependencies in Azure Functions Flex Consumption. Who knows, maybe this will change in the future, but for now, we have to work with what we have.
So, similar to the way we bundled the modules, we also had to bundle the PowerShell executable with our code. (Shoutout to the ever-so-helpful Thiago Almeida for the tip!).
This is how we did it:
- go to the PowerShell GitHub repository and download the latest release for Linux. The
tar.gz
file is a compressed file that contains the PowerShell executable and all the required files to run PowerShell on Linux. - extract the
tar.gz
file and copy thepwsh
executable and all its dependencies to thepowershell
folder in our code. This way, we can execute PowerShell from c# without having to install it on the Azure Functions host. (Important: Linux is case-sensitive, so make sure to reference the files and folders with the correct casing.) - we call the
pwsh
executable from c# and pass the path to the PowerShell script we want to execute as an argument. The modules folder is part of the application, and the PowerShell script itself will do theImport-Module
using the full path to theExchangeOnlineManagement
module, so PowerShell can find it when it executes the script.
string scriptPath = Path.Combine(AppContext.BaseDirectory, "HelperScripts", "GetTransportRule.ps1");
string powerShellPath = Path.Combine(AppContext.BaseDirectory, "powershell", "pwsh");
// Build arguments for the PowerShell script to be executed
var arguments = $"-NoProfile -NonInteractive -File \"{scriptPath}\" -organization \"{organization}\"";
if (!string.IsNullOrWhiteSpace(accessToken))
{
arguments += $" -accessToken \"{accessToken}\"";
}
else if (!string.IsNullOrWhiteSpace(appRegistrationClientId) && !string.IsNullOrWhiteSpace(certificateThumbprint))
{
arguments += $" -entraAppId \"{appRegistrationClientId}\" -thumbprint \"{certificateThumbprint}\"";
}
var startInfo = new ProcessStartInfo
{
FileName = powerShellPath,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
UseShellExecute = false,
};
using var process = new Process { StartInfo = startInfo };
process.Start();
// Capture outputs
var outputJson = await process.StandardOutput.ReadToEndAsync();
var errorOutput = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
The code above shows how we call the pwsh
executable from c#. We use the ProcessStartInfo
class to configure the process and redirect the output. We also wait for the process to exit and capture the output and error messages. It’s not important for this blog post to explain what the script itself does.
The important part is that running this code locally worked perfectly fine on Windows, but when we deployed it to Azure Functions, we got a strange error message. Remember, we mostly develop on Windows, but our Azure Functions run on Linux. File permissions are a common issue when running code on Linux, and this is where we ran into trouble. The error message we got post deployment was:
An error occurred trying to start process '/home/site/wwwroot/powershell/pwsh' with working directory '/home/site/wwwroot'. Permission denied
Huh? Weird. Why? The pwsh
executable is not executable? We checked the permissions on the file in WSL even and it was set to -rwxrwxrwx
, which means that the file is executable. So why is it not working?
-rwxrwxrwx 1 user user 75144 Jan 17 10:18 pwsh*
Is Windows or WSL doing something magic to make it work locally? Is our code somehow using a different version of pwsh
that is installed on our machine?
This led us down a rabbit hole of trying to figure out how to make the pwsh
executable executable on Azure Functions, believing that this is where the issue was. We tried everything we could think of:
- changing the permissions on the file using
chmod
(which didn’t work because the file was already executable) - changing the file permissions in
Program.cs
which is executed when the function worker starts. For some reason, this didn’t work either. - decided to take Windows out of the equation and download the
pwsh
executable during the GitHub Actions build process, instead of bundling it with our code. We then checked the permissions on the file before bundling it into the release package and deploying it to Azure Functions. This also didn’t work, as the file was still not executable after deployment.
This gave us a clue that the problem was not with how we called the pwsh
executable or Azure Functions itself even, but how it was deployed.
The Solution
Here is the final GitHub Actions workflow that we use to build and deploy our code. It includes the steps to download the pwsh
executable, set the permissions, and deploy the code to Azure Functions.
One will note the tar
command in the workflow. This is used to preserve the permissions on the files when we bundle them into a tarball. This is because of this little gem of information I found on the internet: GitHub Actions - Upload Artifact
File permissions are not maintained during artifact upload. All directories will have 755 and all files will have 644. For example, if you make a file executable using chmod and then upload that file, post-download the file is no longer guaranteed to be set as an executable. |
This explains why even though the actions/upload-artifact@v4
action packages the artifact, the permissions are lost when we download it again in the publish-scanflex
job using the actions/download-artifact@v4
action. The tar
command preserves the permissions on the files, so when we extract the tarball, the permissions are set correctly.
name: Deploy ARGOS Scan Azure Functions
on:
repository_dispatch:
types: deploy-functions
push:
branches:
- main
paths:
- <paths>
env:
AZURE_FUNCTIONAPP_FLEX_NAME: <functionapp-name>
AZURE_FUNCTIONAPP_PACKAGE_PATH: <path-to-package>
DOTNET_VERSION: '8.x.x'
POWERSHELL_VERSION: '7.5.0'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: 'Checkout GitHub Action'
uses: actions/checkout@v4
- name: Setup DotNet $ Environment
uses: actions/setup-dotnet@v4
with:
dotnet-version: $
- name: 'Build Package'
run: |
cd "$"
dotnet publish --configuration ReleaseScan --output ./output
- name: 'Download and Extract PowerShell'
run: |
cd "$/output"
curl -L -o ./powershell.tar.gz https://github.com/PowerShell/PowerShell/releases/download/v$/powershell-$-linux-x64.tar.gz
mkdir -p powershell
tar zxf ./powershell.tar.gz -C ./powershell
rm -rf powershell.tar.gz
- name: 'Make pwsh Executable'
run: |
cd "$/output/powershell"
chmod +x pwsh
- name: 'Tar output directory to preserve permissions'
run: |
cd "$"
tar -cvf output.tar output
- name: 'Upload Build Artifacts'
uses: actions/upload-artifact@v4
with:
name: argos-scan-build
path: Argos/Argos.Scan/output.tar
include-hidden-files: true
publish-scanflex:
runs-on: ubuntu-latest
needs: build
steps:
- name: 'Download Build Artifacts'
uses: actions/download-artifact@v4
with:
name: argos-scan-build
path: .
- name: 'Extract output tarball'
run: |
tar -xvf output.tar
- name: 'Publish Azure Flex Functions'
uses: Azure/[email protected]
with:
app-name: $
package: output
publish-profile: $
sku: flexconsumption
remote-build: false
Once we deployed the code with the correct permissions, everything worked as expected. We were able to execute PowerShell scripts from c# in Azure Functions without any issues.