diff --git a/examples/V2App/.funcignore b/examples/V2App/.funcignore new file mode 100644 index 00000000..d42c5253 --- /dev/null +++ b/examples/V2App/.funcignore @@ -0,0 +1,5 @@ +.git* +.vscode +local.settings.json +test +*.tests.ps1 diff --git a/examples/V2App/HttpFunctions.psm1 b/examples/V2App/HttpFunctions.psm1 new file mode 100644 index 00000000..8f16e2a3 --- /dev/null +++ b/examples/V2App/HttpFunctions.psm1 @@ -0,0 +1,61 @@ +# HTTP Trigger functions using the V2 attribute-based programming model. +# No function.json files are needed — the worker indexes these automatically. + +function HttpExample { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = 'Anonymous', Methods = 'GET', 'POST', Route = 'hello')] + $Request + ) + + $name = $Request.Query['name'] + if (-not $name) { + $name = $Request.Body.name + } + + if ($name) { + Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{ + StatusCode = [System.Net.HttpStatusCode]::OK + Body = "Hello, $name!" + }) + } + else { + Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{ + StatusCode = [System.Net.HttpStatusCode]::BadRequest + Body = 'Please pass a name on the query string or in the request body.' + }) + } +} + +function GetAzureVm { + [AzFunction(Name = 'GetAzureVm')] + param( + [HttpTrigger(AuthLevel = 'Function', Methods = 'GET')] + $Request + ) + + $vmName = $Request.Query['vmName'] + $resourceGroup = $Request.Query['resourceGroup'] + + if ($vmName -and $resourceGroup) { + $vm = Get-AzVM -ResourceGroupName $resourceGroup -Name $vmName -ErrorAction SilentlyContinue + if ($vm) { + Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{ + StatusCode = [System.Net.HttpStatusCode]::OK + Body = ($vm | ConvertTo-Json -Depth 5) + }) + } + else { + Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{ + StatusCode = [System.Net.HttpStatusCode]::NotFound + Body = "VM '$vmName' not found in resource group '$resourceGroup'." + }) + } + } + else { + Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{ + StatusCode = [System.Net.HttpStatusCode]::BadRequest + Body = 'Please provide vmName and resourceGroup query parameters.' + }) + } +} diff --git a/examples/V2App/QueueFunctions.psm1 b/examples/V2App/QueueFunctions.psm1 new file mode 100644 index 00000000..f8e7edf4 --- /dev/null +++ b/examples/V2App/QueueFunctions.psm1 @@ -0,0 +1,23 @@ +# Queue Trigger with Queue Output using the V2 attribute-based programming model. + +function ProcessQueueMessage { + [AzFunction()] + param( + [QueueTrigger(QueueName = 'input-queue', Connection = 'AzureWebJobsStorage')] + $QueueItem, + + [QueueOutput(QueueName = 'output-queue', Connection = 'AzureWebJobsStorage')] + $OutputQueue + ) + + Write-Information "Processing queue message: $QueueItem" + + # Transform the message and send to output queue + $result = @{ + OriginalMessage = $QueueItem + ProcessedAt = (Get-Date).ToString('o') + Status = 'Processed' + } | ConvertTo-Json + + Push-OutputBinding -Name OutputQueue -Value $result +} diff --git a/examples/V2App/README.md b/examples/V2App/README.md new file mode 100644 index 00000000..563389f1 --- /dev/null +++ b/examples/V2App/README.md @@ -0,0 +1,40 @@ +# V2 Programming Model Example App + +This example demonstrates the **V2 attribute-based programming model** for Azure Functions with PowerShell. Functions are defined using PowerShell attributes — no `function.json` files are needed. + +## Key Differences from V1 + +| Feature | V1 (Classic) | V2 (Attribute-based) | +|---|---|---| +| Function definition | `function.json` + `run.ps1` per function | `[AzFunction()]` attribute in `.psm1` files | +| File layout | One folder per function | Functions organized by domain in `.psm1` files | +| Binding configuration | JSON in `function.json` | Attributes on parameters | +| Indexing | Host reads `function.json` | Worker indexes via AST parsing | + +## Functions Included + +### HttpFunctions.psm1 +- **HttpExample** — Anonymous HTTP trigger (GET/POST) with custom route `/hello` +- **GetAzureVm** — Function-level auth HTTP trigger that fetches an Azure VM + +### TimerFunctions.psm1 +- **CleanupJob** — Timer trigger that runs every 6 hours + +### QueueFunctions.psm1 +- **ProcessQueueMessage** — Queue trigger with queue output binding + +## Running Locally + +```powershell +cd examples/V2App +func start +``` + +## .funcignore + +The `.funcignore` file excludes files from deployment (similar to `.gitignore`): +- `.git*` — Git metadata +- `.vscode` — Editor settings +- `local.settings.json` — Local-only settings +- `test` — Test directories +- `*.tests.ps1` — Test files diff --git a/examples/V2App/TimerFunctions.psm1 b/examples/V2App/TimerFunctions.psm1 new file mode 100644 index 00000000..01cbcb87 --- /dev/null +++ b/examples/V2App/TimerFunctions.psm1 @@ -0,0 +1,19 @@ +# Timer Trigger function using the V2 attribute-based programming model. + +function CleanupJob { + [AzFunction(Name = 'CleanupJob')] + param( + [TimerTrigger(Schedule = '0 0 */6 * * *')] + $Timer + ) + + Write-Information "Cleanup job triggered at: $(Get-Date)" + + if ($Timer.IsPastDue) { + Write-Warning 'Timer is running late!' + } + + # Add your cleanup logic here, for example: + # Remove-Item -Path "$env:TEMP\my-app-cache\*" -Recurse -Force -ErrorAction SilentlyContinue + Write-Information 'Cleanup job completed.' +} diff --git a/examples/V2App/host.json b/examples/V2App/host.json new file mode 100644 index 00000000..7d551012 --- /dev/null +++ b/examples/V2App/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "default": "Information" + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/examples/V2App/local.settings.json b/examples/V2App/local.settings.json new file mode 100644 index 00000000..5cf9eedc --- /dev/null +++ b/examples/V2App/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "powershell", + "AzureWebJobsStorage": "UseDevelopmentStorage=true" + } +} diff --git a/examples/V2App/profile.ps1 b/examples/V2App/profile.ps1 new file mode 100644 index 00000000..302fa781 --- /dev/null +++ b/examples/V2App/profile.ps1 @@ -0,0 +1,7 @@ +# Azure Functions profile.ps1 +# +# This profile.ps1 will get executed every cold start of the Function App. +# Use it to define helper functions, set environment variables, or run initialization logic. + +# Uncomment the next line to enable Az module connectivity via managed identity: +# Connect-AzAccount -Identity diff --git a/examples/V2App/requirements.psd1 b/examples/V2App/requirements.psd1 new file mode 100644 index 00000000..9b54e3e5 --- /dev/null +++ b/examples/V2App/requirements.psd1 @@ -0,0 +1,4 @@ +@{ + # Uncomment to use the Az module for Azure resource management: + # 'Az' = '12.*' +} diff --git a/src/Attributes/AzFunctionAttribute.cs b/src/Attributes/AzFunctionAttribute.cs new file mode 100644 index 00000000..e97862f1 --- /dev/null +++ b/src/Attributes/AzFunctionAttribute.cs @@ -0,0 +1,32 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Marks a PowerShell function for indexing as an Azure Function (V2 programming model). + /// Apply this attribute to the function's [CmdletBinding()] or param block. + /// + /// + /// function HttpExample { + /// [AzFunction()] + /// param( + /// [HttpTrigger(AuthLevel = "Anonymous", Route = "hello")] + /// $Request + /// ) + /// ... + /// } + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public sealed class AzFunctionAttribute : Attribute + { + /// + /// Optional function name override. If not specified, the PowerShell function name is used. + /// + public string Name { get; set; } + } +} diff --git a/src/Attributes/CosmosDB/CosmosDBInputAttribute.cs b/src/Attributes/CosmosDB/CosmosDBInputAttribute.cs new file mode 100644 index 00000000..05b8a085 --- /dev/null +++ b/src/Attributes/CosmosDB/CosmosDBInputAttribute.cs @@ -0,0 +1,29 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a Cosmos DB input binding. + /// + public sealed class CosmosDBInputAttribute : InputBindingBaseAttribute + { + /// + public override string BindingType => "cosmosDB"; + + /// The DatabaseName property. + public string DatabaseName { get; set; } + /// The ContainerName property. + public string ContainerName { get; set; } + /// The Id property. + public string Id { get; set; } + /// The PartitionKey property. + public string PartitionKey { get; set; } + /// The SqlQuery property. + public string SqlQuery { get; set; } + /// The PreferredLocations property. + public string PreferredLocations { get; set; } + } +} diff --git a/src/Attributes/CosmosDB/CosmosDBOutputAttribute.cs b/src/Attributes/CosmosDB/CosmosDBOutputAttribute.cs new file mode 100644 index 00000000..efa7594a --- /dev/null +++ b/src/Attributes/CosmosDB/CosmosDBOutputAttribute.cs @@ -0,0 +1,27 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a Cosmos DB output binding. + /// + public sealed class CosmosDBOutputAttribute : OutputBindingBaseAttribute + { + /// + public override string BindingType => "cosmosDB"; + + /// The DatabaseName property. + public string DatabaseName { get; set; } + /// The ContainerName property. + public string ContainerName { get; set; } + /// The CreateIfNotExists property. + public bool CreateIfNotExists { get; set; } + /// The PartitionKey property. + public string PartitionKey { get; set; } + /// The PreferredLocations property. + public string PreferredLocations { get; set; } + } +} diff --git a/src/Attributes/CosmosDB/CosmosDBTriggerAttribute.cs b/src/Attributes/CosmosDB/CosmosDBTriggerAttribute.cs new file mode 100644 index 00000000..05dc9a95 --- /dev/null +++ b/src/Attributes/CosmosDB/CosmosDBTriggerAttribute.cs @@ -0,0 +1,38 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a Cosmos DB trigger binding. + /// + public sealed class CosmosDBTriggerAttribute : TriggerBindingBaseAttribute + { + /// + public override string BindingType => "cosmosDBTrigger"; + + /// + public override string RequiredProperties => "DatabaseName,ContainerName"; + + /// The DatabaseName property. + public string DatabaseName { get; set; } + /// The ContainerName property. + public string ContainerName { get; set; } + /// The LeaseContainerName property. + public string LeaseContainerName { get; set; } + /// The CreateLeaseContainerIfNotExists property. + public bool CreateLeaseContainerIfNotExists { get; set; } + /// The LeaseContainerPrefix property. + public string LeaseContainerPrefix { get; set; } + /// The FeedPollDelay property. + public int FeedPollDelay { get; set; } = -1; + /// The StartFromBeginning property. + public bool StartFromBeginning { get; set; } + /// The MaxItemsPerInvocation property. + public int MaxItemsPerInvocation { get; set; } = -1; + /// The PreferredLocations property. + public string PreferredLocations { get; set; } + } +} diff --git a/src/Attributes/Durable/ActivityTriggerAttribute.cs b/src/Attributes/Durable/ActivityTriggerAttribute.cs new file mode 100644 index 00000000..66a07bbe --- /dev/null +++ b/src/Attributes/Durable/ActivityTriggerAttribute.cs @@ -0,0 +1,16 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a Durable Functions activity trigger binding. + /// + public sealed class ActivityTriggerAttribute : TriggerBindingBaseAttribute + { + /// + public override string BindingType => "activityTrigger"; + } +} diff --git a/src/Attributes/Durable/DurableClientAttribute.cs b/src/Attributes/Durable/DurableClientAttribute.cs new file mode 100644 index 00000000..c2300821 --- /dev/null +++ b/src/Attributes/Durable/DurableClientAttribute.cs @@ -0,0 +1,26 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a Durable Functions client input binding (starter). + /// + public sealed class DurableClientAttribute : InputBindingBaseAttribute + { + /// + public override string BindingType => "durableClient"; + + /// + /// The task hub name. Overrides the default task hub name from host.json. + /// + public string TaskHub { get; set; } + + /// + /// The connection name for the durable storage backend. + /// + public string ConnectionName { get; set; } + } +} diff --git a/src/Attributes/Durable/EntityTriggerAttribute.cs b/src/Attributes/Durable/EntityTriggerAttribute.cs new file mode 100644 index 00000000..8304d22a --- /dev/null +++ b/src/Attributes/Durable/EntityTriggerAttribute.cs @@ -0,0 +1,16 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a Durable Functions entity trigger binding. + /// + public sealed class EntityTriggerAttribute : TriggerBindingBaseAttribute + { + /// + public override string BindingType => "entityTrigger"; + } +} diff --git a/src/Attributes/Durable/OrchestrationTriggerAttribute.cs b/src/Attributes/Durable/OrchestrationTriggerAttribute.cs new file mode 100644 index 00000000..7b691244 --- /dev/null +++ b/src/Attributes/Durable/OrchestrationTriggerAttribute.cs @@ -0,0 +1,16 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a Durable Functions orchestration trigger binding. + /// + public sealed class OrchestrationTriggerAttribute : TriggerBindingBaseAttribute + { + /// + public override string BindingType => "orchestrationTrigger"; + } +} diff --git a/src/Attributes/EventGrid/EventGridOutputAttribute.cs b/src/Attributes/EventGrid/EventGridOutputAttribute.cs new file mode 100644 index 00000000..6613ab74 --- /dev/null +++ b/src/Attributes/EventGrid/EventGridOutputAttribute.cs @@ -0,0 +1,21 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines an Event Grid output binding. + /// + public sealed class EventGridOutputAttribute : OutputBindingBaseAttribute + { + /// + public override string BindingType => "eventGrid"; + + /// The TopicEndpointUri property. + public string TopicEndpointUri { get; set; } + /// The TopicKeySetting property. + public string TopicKeySetting { get; set; } + } +} diff --git a/src/Attributes/EventGrid/EventGridTriggerAttribute.cs b/src/Attributes/EventGrid/EventGridTriggerAttribute.cs new file mode 100644 index 00000000..97d705ba --- /dev/null +++ b/src/Attributes/EventGrid/EventGridTriggerAttribute.cs @@ -0,0 +1,16 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines an Event Grid trigger binding. + /// + public sealed class EventGridTriggerAttribute : TriggerBindingBaseAttribute + { + /// + public override string BindingType => "eventGridTrigger"; + } +} diff --git a/src/Attributes/EventHub/EventHubOutputAttribute.cs b/src/Attributes/EventHub/EventHubOutputAttribute.cs new file mode 100644 index 00000000..98cad8de --- /dev/null +++ b/src/Attributes/EventHub/EventHubOutputAttribute.cs @@ -0,0 +1,19 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines an Event Hub output binding. + /// + public sealed class EventHubOutputAttribute : OutputBindingBaseAttribute + { + /// + public override string BindingType => "eventHub"; + + /// The EventHubName property. + public string EventHubName { get; set; } + } +} diff --git a/src/Attributes/EventHub/EventHubTriggerAttribute.cs b/src/Attributes/EventHub/EventHubTriggerAttribute.cs new file mode 100644 index 00000000..a386939f --- /dev/null +++ b/src/Attributes/EventHub/EventHubTriggerAttribute.cs @@ -0,0 +1,28 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines an Event Hub trigger binding. + /// + public sealed class EventHubTriggerAttribute : TriggerBindingBaseAttribute + { + /// + public override string BindingType => "eventHubTrigger"; + + /// + public override string RequiredProperties => "EventHubName"; + + /// The EventHubName property. + public string EventHubName { get; set; } + /// The ConsumerGroup property. + public string ConsumerGroup { get; set; } + /// + /// "one" or "many". Defaults to "many". + /// + public string Cardinality { get; set; } + } +} diff --git a/src/Attributes/GenericInputBindingAttribute.cs b/src/Attributes/GenericInputBindingAttribute.cs new file mode 100644 index 00000000..ce218d31 --- /dev/null +++ b/src/Attributes/GenericInputBindingAttribute.cs @@ -0,0 +1,34 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// A generic input binding attribute for third-party or unsupported input binding types. + /// Use this when no built-in input binding attribute exists for your binding extension. + /// + /// + /// param( + /// [GenericInputBinding(Type = "daprState", Connection = "DaprConn", + /// Properties = @("stateStore=myStore", "key=myKey"))] + /// $State + /// ) + /// + public sealed class GenericInputBindingAttribute : InputBindingBaseAttribute + { + /// + /// The binding type string (e.g., "daprState", "graphWebhookSubscription"). + /// + public string Type { get; set; } + + /// + public override string BindingType => Type; + + /// + /// Additional binding properties as "key=value" strings. + /// + public string Properties { get; set; } + } +} diff --git a/src/Attributes/GenericOutputBindingAttribute.cs b/src/Attributes/GenericOutputBindingAttribute.cs new file mode 100644 index 00000000..f18c2b6b --- /dev/null +++ b/src/Attributes/GenericOutputBindingAttribute.cs @@ -0,0 +1,34 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// A generic output binding attribute for third-party or unsupported output binding types. + /// Use this when no built-in output binding attribute exists for your binding extension. + /// + /// + /// param( + /// [GenericOutputBinding(Type = "daprPublish", Connection = "DaprConn", + /// Properties = @("pubSubName=myPubSub", "topic=myTopic"))] + /// $Output + /// ) + /// + public sealed class GenericOutputBindingAttribute : OutputBindingBaseAttribute + { + /// + /// The binding type string (e.g., "daprPublish", "twilioSms"). + /// + public string Type { get; set; } + + /// + public override string BindingType => Type; + + /// + /// Additional binding properties as "key=value" strings. + /// + public string Properties { get; set; } + } +} diff --git a/src/Attributes/GenericTriggerAttribute.cs b/src/Attributes/GenericTriggerAttribute.cs new file mode 100644 index 00000000..5b84695c --- /dev/null +++ b/src/Attributes/GenericTriggerAttribute.cs @@ -0,0 +1,34 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// A generic trigger binding attribute for third-party or unsupported trigger types. + /// Use this when no built-in trigger attribute exists for your binding extension. + /// + /// + /// param( + /// [GenericTrigger(Type = "kafkaTrigger", Connection = "KafkaConn", + /// Properties = @("brokerList=myBroker", "topic=myTopic", "consumerGroup=myGroup"))] + /// $Message + /// ) + /// + public sealed class GenericTriggerAttribute : TriggerBindingBaseAttribute + { + /// + /// The binding type string (e.g., "kafkaTrigger", "daprBindingTrigger"). + /// + public string Type { get; set; } + + /// + public override string BindingType => Type; + + /// + /// Additional binding properties as "key=value" strings. + /// + public string Properties { get; set; } + } +} diff --git a/src/Attributes/Http/HttpOutputAttribute.cs b/src/Attributes/Http/HttpOutputAttribute.cs new file mode 100644 index 00000000..efb54596 --- /dev/null +++ b/src/Attributes/Http/HttpOutputAttribute.cs @@ -0,0 +1,17 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines an explicit HTTP response output binding. + /// For most HTTP functions this is implicit and not needed. + /// + public sealed class HttpOutputAttribute : OutputBindingBaseAttribute + { + /// + public override string BindingType => "http"; + } +} diff --git a/src/Attributes/Http/HttpTriggerAttribute.cs b/src/Attributes/Http/HttpTriggerAttribute.cs new file mode 100644 index 00000000..bff61265 --- /dev/null +++ b/src/Attributes/Http/HttpTriggerAttribute.cs @@ -0,0 +1,44 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines an HTTP trigger binding for a V2 PowerShell function. + /// + /// + /// param( + /// [HttpTrigger(AuthLevel = "Anonymous", Methods = @("GET", "POST"), Route = "hello")] + /// $Request + /// ) + /// + public sealed class HttpTriggerAttribute : TriggerBindingBaseAttribute + { + /// + public override string BindingType => "httpTrigger"; + /// + public override string ImplicitOutputBindingType => "http"; + /// + public override string ArrayProperties => "Methods"; + + /// + /// The authorization level for the HTTP trigger. + /// Valid values: "Anonymous", "Function", "Admin". Defaults to "Function". + /// + public string AuthLevel { get; set; } = "Function"; + + /// + /// The HTTP methods that the function responds to. + /// If not specified, the function responds to all methods. + /// + public string Methods { get; set; } + + /// + /// The route template for the HTTP trigger. + /// If not specified, the function name is used as the route. + /// + public string Route { get; set; } + } +} diff --git a/src/Attributes/InputBindingBaseAttribute.cs b/src/Attributes/InputBindingBaseAttribute.cs new file mode 100644 index 00000000..5f9c358d --- /dev/null +++ b/src/Attributes/InputBindingBaseAttribute.cs @@ -0,0 +1,38 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Base class for all input binding attributes in the V2 programming model. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] + public abstract class InputBindingBaseAttribute : Attribute + { + /// + /// The binding type string for function.json (e.g., "cosmosDB", "blob"). + /// Each derived class must return its binding type. + /// + public abstract string BindingType { get; } + + /// + /// The connection string setting name for this binding. + /// + public string Connection { get; set; } + + /// + /// Comma-delimited list of property names that should always be serialized as JSON arrays. + /// + public virtual string ArrayProperties => null; + + /// + /// Comma-delimited list of property names that are required for this binding. + /// Override in derived classes to enable indexing-time validation. + /// + public virtual string RequiredProperties => null; + } +} diff --git a/src/Attributes/OutputBindingBaseAttribute.cs b/src/Attributes/OutputBindingBaseAttribute.cs new file mode 100644 index 00000000..4c2b59f1 --- /dev/null +++ b/src/Attributes/OutputBindingBaseAttribute.cs @@ -0,0 +1,38 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Base class for all output binding attributes in the V2 programming model. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] + public abstract class OutputBindingBaseAttribute : Attribute + { + /// + /// The binding type string for function.json (e.g., "queue", "cosmosDB"). + /// Each derived class must return its binding type. + /// + public abstract string BindingType { get; } + + /// + /// The connection string setting name for this binding. + /// + public string Connection { get; set; } + + /// + /// Comma-delimited list of property names that should always be serialized as JSON arrays. + /// + public virtual string ArrayProperties => null; + + /// + /// Comma-delimited list of property names that are required for this binding. + /// Override in derived classes to enable indexing-time validation. + /// + public virtual string RequiredProperties => null; + } +} diff --git a/src/Attributes/ServiceBus/ServiceBusOutputAttribute.cs b/src/Attributes/ServiceBus/ServiceBusOutputAttribute.cs new file mode 100644 index 00000000..eb31eeb6 --- /dev/null +++ b/src/Attributes/ServiceBus/ServiceBusOutputAttribute.cs @@ -0,0 +1,21 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a Service Bus output binding. + /// + public sealed class ServiceBusOutputAttribute : OutputBindingBaseAttribute + { + /// + public override string BindingType => "serviceBus"; + + /// The QueueName property. + public string QueueName { get; set; } + /// The TopicName property. + public string TopicName { get; set; } + } +} diff --git a/src/Attributes/ServiceBus/ServiceBusTriggerAttribute.cs b/src/Attributes/ServiceBus/ServiceBusTriggerAttribute.cs new file mode 100644 index 00000000..6df1ef32 --- /dev/null +++ b/src/Attributes/ServiceBus/ServiceBusTriggerAttribute.cs @@ -0,0 +1,29 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a Service Bus trigger binding. + /// + public sealed class ServiceBusTriggerAttribute : TriggerBindingBaseAttribute + { + /// + public override string BindingType => "serviceBusTrigger"; + + /// The QueueName property. + public string QueueName { get; set; } + /// The TopicName property. + public string TopicName { get; set; } + /// The SubscriptionName property. + public string SubscriptionName { get; set; } + /// The IsSessionsEnabled property. + public bool IsSessionsEnabled { get; set; } + /// + /// "one" or "many". Defaults to "one". + /// + public string Cardinality { get; set; } + } +} diff --git a/src/Attributes/SignalR/SignalRInputAttribute.cs b/src/Attributes/SignalR/SignalRInputAttribute.cs new file mode 100644 index 00000000..a2b3218f --- /dev/null +++ b/src/Attributes/SignalR/SignalRInputAttribute.cs @@ -0,0 +1,25 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a SignalR connection info input binding. + /// + public sealed class SignalRInputAttribute : InputBindingBaseAttribute + { + /// + public override string BindingType => "signalRConnectionInfo"; + + /// The HubName property. + public string HubName { get; set; } + /// The UserId property. + public string UserId { get; set; } + /// The IdToken property. + public string IdToken { get; set; } + /// The ClaimTypeList property. + public string ClaimTypeList { get; set; } + } +} diff --git a/src/Attributes/SignalR/SignalROutputAttribute.cs b/src/Attributes/SignalR/SignalROutputAttribute.cs new file mode 100644 index 00000000..34874db2 --- /dev/null +++ b/src/Attributes/SignalR/SignalROutputAttribute.cs @@ -0,0 +1,19 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a SignalR message output binding. + /// + public sealed class SignalROutputAttribute : OutputBindingBaseAttribute + { + /// + public override string BindingType => "signalR"; + + /// The HubName property. + public string HubName { get; set; } + } +} diff --git a/src/Attributes/SignalR/SignalRTriggerAttribute.cs b/src/Attributes/SignalR/SignalRTriggerAttribute.cs new file mode 100644 index 00000000..2ef0a8ee --- /dev/null +++ b/src/Attributes/SignalR/SignalRTriggerAttribute.cs @@ -0,0 +1,23 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a SignalR trigger binding (for serverless SignalR upstream). + /// + public sealed class SignalRTriggerAttribute : TriggerBindingBaseAttribute + { + /// + public override string BindingType => "signalRTrigger"; + + /// The HubName property. + public string HubName { get; set; } + /// The Category property. + public string Category { get; set; } + /// The Event property. + public string Event { get; set; } + } +} diff --git a/src/Attributes/Storage/BlobInputAttribute.cs b/src/Attributes/Storage/BlobInputAttribute.cs new file mode 100644 index 00000000..050ac7c7 --- /dev/null +++ b/src/Attributes/Storage/BlobInputAttribute.cs @@ -0,0 +1,21 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a blob input binding. + /// + public sealed class BlobInputAttribute : InputBindingBaseAttribute + { + /// + public override string BindingType => "blob"; + + /// + /// The blob path to read from (e.g., "container/{name}"). + /// + public string Path { get; set; } + } +} diff --git a/src/Attributes/Storage/BlobOutputAttribute.cs b/src/Attributes/Storage/BlobOutputAttribute.cs new file mode 100644 index 00000000..1ed167a2 --- /dev/null +++ b/src/Attributes/Storage/BlobOutputAttribute.cs @@ -0,0 +1,21 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a blob output binding. + /// + public sealed class BlobOutputAttribute : OutputBindingBaseAttribute + { + /// + public override string BindingType => "blob"; + + /// + /// The blob path to write to (e.g., "container/{name}"). + /// + public string Path { get; set; } + } +} diff --git a/src/Attributes/Storage/BlobTriggerAttribute.cs b/src/Attributes/Storage/BlobTriggerAttribute.cs new file mode 100644 index 00000000..a6607a84 --- /dev/null +++ b/src/Attributes/Storage/BlobTriggerAttribute.cs @@ -0,0 +1,29 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a blob trigger binding. + /// + public sealed class BlobTriggerAttribute : TriggerBindingBaseAttribute + { + /// + public override string BindingType => "blobTrigger"; + + /// + public override string RequiredProperties => "Path"; + + /// + /// The blob path to monitor (e.g., "container/{name}"). + /// + public string Path { get; set; } + + /// + /// The source of the blob trigger event ("EventGrid" or "LogsAndContainerScan"). + /// + public string Source { get; set; } + } +} diff --git a/src/Attributes/Storage/QueueOutputAttribute.cs b/src/Attributes/Storage/QueueOutputAttribute.cs new file mode 100644 index 00000000..82f105b6 --- /dev/null +++ b/src/Attributes/Storage/QueueOutputAttribute.cs @@ -0,0 +1,21 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a queue output binding. + /// + public sealed class QueueOutputAttribute : OutputBindingBaseAttribute + { + /// + public override string BindingType => "queue"; + + /// + /// The name of the queue to write to. + /// + public string QueueName { get; set; } + } +} diff --git a/src/Attributes/Storage/QueueTriggerAttribute.cs b/src/Attributes/Storage/QueueTriggerAttribute.cs new file mode 100644 index 00000000..15600505 --- /dev/null +++ b/src/Attributes/Storage/QueueTriggerAttribute.cs @@ -0,0 +1,24 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a queue trigger binding. + /// + public sealed class QueueTriggerAttribute : TriggerBindingBaseAttribute + { + /// + public override string BindingType => "queueTrigger"; + + /// + public override string RequiredProperties => "QueueName"; + + /// + /// The name of the queue to monitor. + /// + public string QueueName { get; set; } + } +} diff --git a/src/Attributes/Storage/TableInputAttribute.cs b/src/Attributes/Storage/TableInputAttribute.cs new file mode 100644 index 00000000..049c9aa2 --- /dev/null +++ b/src/Attributes/Storage/TableInputAttribute.cs @@ -0,0 +1,41 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a table input binding. + /// + public sealed class TableInputAttribute : InputBindingBaseAttribute + { + /// + public override string BindingType => "table"; + + /// + /// The name of the storage table. + /// + public string TableName { get; set; } + + /// + /// The row key to read. If not specified, reads all rows matching the filter. + /// + public string RowKey { get; set; } + + /// + /// The partition key to filter by. + /// + public string PartitionKey { get; set; } + + /// + /// An OData filter expression for rows. + /// + public string Filter { get; set; } + + /// + /// The maximum number of rows to return. + /// + public int Take { get; set; } = -1; + } +} diff --git a/src/Attributes/Storage/TableOutputAttribute.cs b/src/Attributes/Storage/TableOutputAttribute.cs new file mode 100644 index 00000000..0ecbcc25 --- /dev/null +++ b/src/Attributes/Storage/TableOutputAttribute.cs @@ -0,0 +1,31 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a table output binding. + /// + public sealed class TableOutputAttribute : OutputBindingBaseAttribute + { + /// + public override string BindingType => "table"; + + /// + /// The name of the storage table. + /// + public string TableName { get; set; } + + /// + /// The row key for the entity to write. + /// + public string RowKey { get; set; } + + /// + /// The partition key for the entity to write. + /// + public string PartitionKey { get; set; } + } +} diff --git a/src/Attributes/Timer/TimerTriggerAttribute.cs b/src/Attributes/Timer/TimerTriggerAttribute.cs new file mode 100644 index 00000000..9d39a64d --- /dev/null +++ b/src/Attributes/Timer/TimerTriggerAttribute.cs @@ -0,0 +1,34 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Defines a timer trigger binding. + /// + public sealed class TimerTriggerAttribute : TriggerBindingBaseAttribute + { + /// + public override string BindingType => "timerTrigger"; + + /// + public override string RequiredProperties => "Schedule"; + + /// + /// A CRON expression or TimeSpan string representing the timer schedule. + /// + public string Schedule { get; set; } + + /// + /// Whether the function should be invoked immediately on startup. + /// + public bool RunOnStartup { get; set; } + + /// + /// Whether to use the schedule monitor to ensure schedules are maintained. + /// + public bool UseMonitor { get; set; } = true; + } +} diff --git a/src/Attributes/TriggerBindingBaseAttribute.cs b/src/Attributes/TriggerBindingBaseAttribute.cs new file mode 100644 index 00000000..892ccba1 --- /dev/null +++ b/src/Attributes/TriggerBindingBaseAttribute.cs @@ -0,0 +1,46 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; + +namespace Microsoft.Azure.Functions.PowerShellWorker.Attributes +{ + /// + /// Base class for all trigger binding attributes in the V2 programming model. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] + public abstract class TriggerBindingBaseAttribute : Attribute + { + /// + /// The binding type string for function.json (e.g., "httpTrigger", "queueTrigger"). + /// Each derived class must return its binding type. + /// + public abstract string BindingType { get; } + + /// + /// If this trigger implies an automatic output binding, return its type (e.g., "http"). + /// Return null if no implicit output. Override in derived classes as needed. + /// + public virtual string ImplicitOutputBindingType => null; + + /// + /// Comma-delimited list of property names that should always be serialized as JSON arrays + /// in the raw binding, even when they contain a single value (e.g., "Methods"). + /// Override in derived classes as needed. + /// + public virtual string ArrayProperties => null; + + /// + /// Comma-delimited list of property names that are required for this binding. + /// Override in derived classes to enable indexing-time validation. + /// + public virtual string RequiredProperties => null; + + /// + /// The connection string setting name for this binding. + /// + public string Connection { get; set; } + } +} diff --git a/src/DependencyManagement/DependencyManager.cs b/src/DependencyManagement/DependencyManager.cs index a2bbe57f..6bed1b15 100644 --- a/src/DependencyManagement/DependencyManager.cs +++ b/src/DependencyManagement/DependencyManager.cs @@ -280,6 +280,14 @@ private static string GetFunctionAppRootPath(string requestMetadataDirectory) throw new ArgumentException("Empty request metadata directory path", nameof(requestMetadataDirectory)); } + // V2 (worker-indexed): directory IS the app root. + // V1: directory is FunctionAppRoot/FunctionName/, go up one level. + if (File.Exists(Path.Join(requestMetadataDirectory, "requirements.psd1")) + || File.Exists(Path.Join(requestMetadataDirectory, "host.json"))) + { + return requestMetadataDirectory; + } + return Path.GetFullPath(Path.Join(requestMetadataDirectory, "..")); } diff --git a/src/FunctionInfo.cs b/src/FunctionInfo.cs index 091eecde..b276f3a9 100644 --- a/src/FunctionInfo.cs +++ b/src/FunctionInfo.cs @@ -57,7 +57,9 @@ internal AzFunctionInfo(RpcFunctionMetadata metadata) // Support 'entryPoint' only if 'scriptFile' is a .psm1 file; // Support .psm1 'scriptFile' only if 'entryPoint' is specified. + // Exception: V2 programming model (worker-indexed) uses entryPoint with .ps1 files. bool isScriptFilePsm1 = ScriptPath.EndsWith(".psm1", StringComparison.OrdinalIgnoreCase); + bool isWorkerIndexed = metadata.Properties.ContainsKey("WorkerIndexed"); bool entryPointNotDefined = string.IsNullOrEmpty(EntryPoint); if (entryPointNotDefined) { @@ -66,7 +68,7 @@ internal AzFunctionInfo(RpcFunctionMetadata metadata) throw new ArgumentException(PowerShellWorkerStrings.RequireEntryPointForScriptModule); } } - else if (!isScriptFilePsm1) + else if (!isScriptFilePsm1 && !isWorkerIndexed) { throw new ArgumentException(PowerShellWorkerStrings.InvalidEntryPointForScriptFile); } @@ -109,6 +111,10 @@ internal AzFunctionInfo(RpcFunctionMetadata metadata) else if (bindingInfo.Direction == BindingInfo.Types.Direction.Out) { outputBindings.Add(bindingName, bindingInfo); + + // V2 programming model: output bindings may also be declared as parameters. + // Remove them from the parameter copy so they aren't flagged as unknown. + parametersCopy.Remove(bindingName); } else { diff --git a/src/FunctionLoader.cs b/src/FunctionLoader.cs index ae33dbf1..5959688e 100644 --- a/src/FunctionLoader.cs +++ b/src/FunctionLoader.cs @@ -74,9 +74,37 @@ internal static void ClearLoadedFunctions() /// internal static void SetupWellKnownPaths(FunctionLoadRequest request, string managedDependenciesPath) { - // Resolve the FunctionApp root path - FunctionAppRootPath = Path.GetFullPath(Path.Join(request.Metadata.Directory, "..")); + // For V2 (worker-indexed), FunctionAppRootPath is already set by SetFunctionAppRootPath + // during ProcessFunctionMetadataRequest. For V1, resolve it from the function directory. + if (FunctionAppRootPath == null) + { + // V1: Metadata.Directory is FunctionAppRoot/FunctionName/, so go up one level + FunctionAppRootPath = Path.GetFullPath(Path.Join(request.Metadata.Directory, "..")); + } + + SetupModuleAndProfilePaths(managedDependenciesPath); + } + /// + /// Sets the FunctionAppRootPath directly from a directory path. + /// Used by the V2 programming model (worker indexing) where the function app directory + /// is provided by FunctionsMetadataRequest before any FunctionLoadRequest arrives. + /// + internal static void SetFunctionAppRootPath(string functionAppDirectory) + { + if (FunctionAppRootPath == null) + { + FunctionAppRootPath = functionAppDirectory; + + // Discover profile.ps1 early for V2 apps + var options = new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive }; + var profiles = Directory.EnumerateFiles(FunctionAppRootPath, "profile.ps1", options); + FunctionAppProfilePath = profiles.FirstOrDefault(); + } + } + + private static void SetupModuleAndProfilePaths(string managedDependenciesPath) + { // Resolve module paths var appLevelModulesPath = Path.Join(FunctionAppRootPath, "Modules"); var workerLevelModulesPath = Path.Join(AppDomain.CurrentDomain.BaseDirectory, "Modules"); diff --git a/src/PowerShell/PowerShellManager.cs b/src/PowerShell/PowerShellManager.cs index a8c506ac..2f140760 100644 --- a/src/PowerShell/PowerShellManager.cs +++ b/src/PowerShell/PowerShellManager.cs @@ -10,6 +10,7 @@ using System.Reflection; using Microsoft.Azure.Functions.PowerShellWorker.Durable; using Microsoft.Azure.Functions.PowerShellWorker.Utility; +using Microsoft.Azure.Functions.PowerShellWorker.Attributes; using Microsoft.Azure.WebJobs.Script.Grpc.Messages; using LogLevel = Microsoft.Azure.WebJobs.Script.Grpc.Messages.RpcLog.Types.Level; @@ -51,7 +52,7 @@ internal class PowerShellManager static PowerShellManager() { - // Set the type accelerators for 'HttpResponseContext' and 'HttpResponseContext'. + // Set the type accelerators for 'HttpResponseContext' and 'HttpRequestContext'. // We probably will expose more public types from the worker in future for the interop between worker and the 'PowerShellWorker' module. // But it's most likely only 'HttpResponseContext' and 'HttpResponseContext' are supposed to be used directly by users, so we only add // type accelerators for these two explicitly. @@ -59,6 +60,64 @@ static PowerShellManager() var addMethod = accelerator.GetMethod("Add", new Type[] { typeof(string), typeof(Type) }); addMethod.Invoke(null, new object[] { "HttpResponseContext", typeof(HttpResponseContext) }); addMethod.Invoke(null, new object[] { "HttpRequestContext", typeof(HttpRequestContext) }); + + // V2 programming model: auto-register type accelerators for all binding attribute types + // so users can write [HttpTrigger()] etc. without 'using namespace'. + // Discovers all concrete classes deriving from our base attribute classes via reflection. + RegisterBindingAttributeAccelerators(addMethod); + } + + /// + /// Discovers all public concrete attribute classes that derive from TriggerBindingBaseAttribute, + /// InputBindingBaseAttribute, OutputBindingBaseAttribute, or AzFunctionAttribute and registers + /// them as PowerShell type accelerators using their short name (without "Attribute" suffix). + /// + private static void RegisterBindingAttributeAccelerators(MethodInfo addMethod) + { + var baseTypes = new[] + { + typeof(TriggerBindingBaseAttribute), + typeof(InputBindingBaseAttribute), + typeof(OutputBindingBaseAttribute), + typeof(AzFunctionAttribute), + }; + + var assembly = typeof(AzFunctionAttribute).Assembly; + + foreach (var type in assembly.GetExportedTypes()) + { + if (type.IsAbstract || type.IsInterface) + continue; + + bool isBindingAttr = false; + foreach (var baseType in baseTypes) + { + if (baseType.IsAssignableFrom(type)) + { + isBindingAttr = true; + break; + } + } + + if (!isBindingAttr) + continue; + + // Strip "Attribute" suffix for the short name + var shortName = type.Name; + if (shortName.EndsWith("Attribute", StringComparison.OrdinalIgnoreCase)) + { + shortName = shortName.Substring(0, shortName.Length - "Attribute".Length); + } + + try + { + addMethod.Invoke(null, new object[] { shortName, type }); + } + catch + { + // If a name is already registered, skip it + } + } } /// @@ -247,7 +306,7 @@ public Hashtable InvokeFunction( { _pwsh.AddCommand(Utils.TracePipelineObjectCmdlet); } - return ExecuteUserCode(isActivityFunction || FunctionInfoUtilities.hasAssistantSkillTrigger(functionInfo), outputBindings); + return ExecuteUserCode(isActivityFunction || FunctionInfoUtilities.hasAssistantSkillTrigger(functionInfo), outputBindings, functionInfo); } } catch (RuntimeException e) @@ -315,10 +374,20 @@ private void SetInputBindingParameterValues( /// /// Execution a function fired by a trigger or an activity function scheduled by an orchestration. /// - private Hashtable ExecuteUserCode(bool addPipelineOutput, IDictionary outputBindings) + private Hashtable ExecuteUserCode(bool addPipelineOutput, IDictionary outputBindings, AzFunctionInfo functionInfo = null) { var pipelineItems = _pwsh.InvokeAndClearCommands(); var result = new Hashtable(outputBindings, StringComparer.OrdinalIgnoreCase); + + // Warn about potential pipeline output leaks: if the function produces multiple + // pipeline objects, some may be unintentional (e.g., cmdlets leaking output). + if (functionInfo != null && pipelineItems.Count > 1) + { + Logger.Log(isUserOnlyLog: false, LogLevel.Warning, + string.Format(PowerShellWorkerStrings.PipelineOutputLeakWarning, + functionInfo.FuncName, pipelineItems.Count)); + } + if (addPipelineOutput) { var returnValue = FunctionReturnValueBuilder.CreateReturnValueFromFunctionOutput(pipelineItems); diff --git a/src/RequestProcessor.cs b/src/RequestProcessor.cs index 7af235d1..fc8f7e2d 100644 --- a/src/RequestProcessor.cs +++ b/src/RequestProcessor.cs @@ -14,6 +14,7 @@ using Microsoft.Azure.Functions.PowerShellWorker.Utility; using Microsoft.Azure.Functions.PowerShellWorker.DependencyManagement; using Microsoft.Azure.Functions.PowerShellWorker.DurableWorker; +using Microsoft.Azure.Functions.PowerShellWorker.WorkerIndexing; using Microsoft.Azure.WebJobs.Script.Grpc.Messages; namespace Microsoft.Azure.Functions.PowerShellWorker @@ -71,6 +72,9 @@ internal RequestProcessor(MessagingStream msgStream, System.Management.Automatio _requestHandlers.Add(StreamingMessage.ContentOneofCase.InvocationCancel, ProcessInvocationCancelRequest); _requestHandlers.Add(StreamingMessage.ContentOneofCase.FunctionEnvironmentReloadRequest, ProcessFunctionEnvironmentReloadRequest); + + // V2 programming model: host requests worker to index functions + _requestHandlers.Add(StreamingMessage.ContentOneofCase.FunctionsMetadataRequest, ProcessFunctionMetadataRequest); } internal async Task ProcessRequestLoop() @@ -226,7 +230,11 @@ internal StreamingMessage ProcessFunctionLoadRequest(StreamingMessage request) var rpcLogger = new RpcLogger(_msgStream); rpcLogger.SetContext(request.RequestId, null); - _dependencyManager = new DependencyManager(request.FunctionLoadRequest.Metadata.Directory, logger: rpcLogger); + // For V2, FunctionAppRootPath is already set correctly by ProcessFunctionMetadataRequest. + // For V1, Metadata.Directory is FunctionAppRoot/FunctionName/ and DependencyManager goes up one level. + // Use FunctionAppRootPath if available (V2), otherwise fall back to Metadata.Directory (V1). + var dependencyManagerPath = FunctionLoader.FunctionAppRootPath ?? request.FunctionLoadRequest.Metadata.Directory; + _dependencyManager = new DependencyManager(dependencyManagerPath, logger: rpcLogger); var managedDependenciesPath = _dependencyManager.Initialize(request, rpcLogger); SetupAppRootPathAndModulePath(functionLoadRequest, managedDependenciesPath); @@ -376,6 +384,56 @@ internal StreamingMessage ProcessInvocationCancelRequest(StreamingMessage reques return null; } + /// + /// Handles the FunctionsMetadataRequest from the host (V2 programming model / worker indexing). + /// Scans the function app directory for PowerShell functions defined with [AzFunction()] attributes + /// and returns their metadata to the host. + /// + internal StreamingMessage ProcessFunctionMetadataRequest(StreamingMessage request) + { + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + StreamingMessage response = NewStreamingMessageTemplate( + request.RequestId, + StreamingMessage.ContentOneofCase.FunctionMetadataResponse, + out StatusResult status); + + try + { + var functionAppDirectory = request.FunctionsMetadataRequest.FunctionAppDirectory; + + // Set the FunctionAppRootPath early so profile.ps1 discovery works for V2 + FunctionLoader.SetFunctionAppRootPath(functionAppDirectory); + + var rpcLogger = new RpcLogger(_msgStream); + rpcLogger.SetContext(request.RequestId, null); + + var indexedFunctions = FunctionIndexer.IndexFunctions(functionAppDirectory, rpcLogger); + var functionList = new List(indexedFunctions); + + if (functionList.Count == 0) + { + // No V2 functions found — tell the host to fall back to its own indexing (V1 function.json) + response.FunctionMetadataResponse.UseDefaultMetadataIndexing = true; + rpcLogger.Log(isUserOnlyLog: false, LogLevel.Trace, PowerShellWorkerStrings.NoV2FunctionsFound); + } + else + { + response.FunctionMetadataResponse.FunctionMetadataResults.AddRange(functionList); + rpcLogger.Log(isUserOnlyLog: false, LogLevel.Trace, + string.Format(PowerShellWorkerStrings.V2FunctionsIndexed, functionList.Count, stopwatch.ElapsedMilliseconds)); + } + } + catch (Exception e) + { + status.Status = StatusResult.Types.Status.Failure; + status.Exception = e.ToRpcException(); + } + + return response; + } + internal StreamingMessage ProcessFunctionEnvironmentReloadRequest(StreamingMessage request) { var stopwatch = new Stopwatch(); @@ -438,6 +496,9 @@ private StreamingMessage NewStreamingMessageTemplate(string requestId, Streaming case StreamingMessage.ContentOneofCase.FunctionEnvironmentReloadResponse: response.FunctionEnvironmentReloadResponse = new FunctionEnvironmentReloadResponse() { Result = status }; break; + case StreamingMessage.ContentOneofCase.FunctionMetadataResponse: + response.FunctionMetadataResponse = new FunctionMetadataResponse() { Result = status }; + break; default: throw new InvalidOperationException("Unreachable code."); } diff --git a/src/Utility/Utils.cs b/src/Utility/Utils.cs index 1c3ce45d..15a1fe4f 100644 --- a/src/Utility/Utils.cs +++ b/src/Utility/Utils.cs @@ -56,7 +56,9 @@ internal static PowerShell NewPwshInstance() description: null)); } - // Setting the execution policy on macOS and Linux throws an exception so only update it on Windows + // Setting the execution policy on macOS and Linux throws an exception so only update it on Windows. + // This must be set unconditionally (regardless of FunctionAppRootPath) because the ISS is a singleton + // created once at worker startup — before V2 worker indexing sets FunctionAppRootPath. if(Platform.IsWindows) { // This sets the execution policy on Windows to Unrestricted which is required to run the user's function scripts on diff --git a/src/WorkerIndexing/AttributeToBindingConverter.cs b/src/WorkerIndexing/AttributeToBindingConverter.cs new file mode 100644 index 00000000..8d9c200b --- /dev/null +++ b/src/WorkerIndexing/AttributeToBindingConverter.cs @@ -0,0 +1,611 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation.Language; +using System.Reflection; + +using Microsoft.Azure.Functions.PowerShellWorker.Attributes; +using Microsoft.Azure.WebJobs.Script.Grpc.Messages; + +namespace Microsoft.Azure.Functions.PowerShellWorker.WorkerIndexing +{ + /// + /// Converts PowerShell attribute AST nodes to Azure Functions BindingInfo and raw binding JSON. + /// Binding metadata is auto-discovered from attribute classes via reflection — the converter + /// has no hardcoded knowledge of specific binding types. + /// + internal static class AttributeToBindingConverter + { + private const string AzFunctionTypeName = "AzFunction"; + + // Generic attribute names that need special handling (Type comes from AST, not reflection) + private static readonly HashSet GenericAttributeNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "GenericTrigger", + "GenericInputBinding", + "GenericOutputBinding", + }; + + // AST named-argument keys to exclude from raw binding serialization (handled separately) + private static readonly HashSet ExcludedPropertyNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Type", // Generic bindings: becomes the "type" field + "Properties", // Generic bindings: flattened into the raw binding + }; + + private enum BindingDirection { Trigger, In, Out } + + private sealed class BindingRegistration + { + public string BindingType { get; } + public BindingDirection Direction { get; } + public string ImplicitOutputType { get; } + public HashSet ArrayPropertyNames { get; } + public HashSet RequiredPropertyNames { get; } + + public BindingRegistration(string bindingType, BindingDirection direction, string implicitOutputType = null, string arrayProperties = null, string requiredProperties = null) + { + BindingType = bindingType; + Direction = direction; + ImplicitOutputType = implicitOutputType; + ArrayPropertyNames = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrEmpty(arrayProperties)) + { + foreach (var name in arrayProperties.Split(',')) + { + var trimmed = name.Trim(); + if (trimmed.Length > 0) ArrayPropertyNames.Add(trimmed); + } + } + RequiredPropertyNames = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrEmpty(requiredProperties)) + { + foreach (var name in requiredProperties.Split(',')) + { + var trimmed = name.Trim(); + if (trimmed.Length > 0) RequiredPropertyNames.Add(trimmed); + } + } + } + } + + // Populated at static init by reflecting over the assembly's attribute classes + private static readonly Dictionary RegisteredBindings + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + static AttributeToBindingConverter() + { + DiscoverBindingAttributes(); + } + + /// + /// Scans the worker assembly for concrete attribute classes deriving from the binding base classes. + /// Each class self-describes its binding type via the abstract BindingType property. + /// + private static void DiscoverBindingAttributes() + { + var assembly = typeof(TriggerBindingBaseAttribute).Assembly; + + foreach (var type in assembly.GetExportedTypes()) + { + if (type.IsAbstract || type.IsInterface) + continue; + + var shortName = StripAttributeSuffix(type.Name); + + try + { + if (typeof(TriggerBindingBaseAttribute).IsAssignableFrom(type)) + { + var instance = (TriggerBindingBaseAttribute)Activator.CreateInstance(type); + if (!string.IsNullOrEmpty(instance.BindingType)) + { + RegisteredBindings[shortName] = new BindingRegistration( + instance.BindingType, BindingDirection.Trigger, instance.ImplicitOutputBindingType, + instance.ArrayProperties, instance.RequiredProperties); + } + } + else if (typeof(InputBindingBaseAttribute).IsAssignableFrom(type)) + { + var instance = (InputBindingBaseAttribute)Activator.CreateInstance(type); + if (!string.IsNullOrEmpty(instance.BindingType)) + { + RegisteredBindings[shortName] = new BindingRegistration( + instance.BindingType, BindingDirection.In, + arrayProperties: instance.ArrayProperties, requiredProperties: instance.RequiredProperties); + } + } + else if (typeof(OutputBindingBaseAttribute).IsAssignableFrom(type)) + { + var instance = (OutputBindingBaseAttribute)Activator.CreateInstance(type); + if (!string.IsNullOrEmpty(instance.BindingType)) + { + RegisteredBindings[shortName] = new BindingRegistration( + instance.BindingType, BindingDirection.Out, + arrayProperties: instance.ArrayProperties, requiredProperties: instance.RequiredProperties); + } + } + } + catch + { + // Skip types that can't be instantiated (e.g., generic attributes with null BindingType) + } + } + } + + #region Public API + + internal static bool IsAzFunctionAttribute(AttributeAst attributeAst) + { + return GetAttributeTypeName(attributeAst).Equals(AzFunctionTypeName, StringComparison.OrdinalIgnoreCase); + } + + internal static string GetFunctionNameOverride(AttributeAst attributeAst) + { + return GetNamedArgumentValue(attributeAst, "Name"); + } + + internal static bool IsTriggerAttribute(AttributeAst attributeAst) + { + var reg = ResolveRegistration(attributeAst); + if (reg != null) + { + return reg.Direction == BindingDirection.Trigger; + } + // GenericTrigger is not registered but is still a trigger + return GetAttributeTypeName(attributeAst).Equals("GenericTrigger", StringComparison.OrdinalIgnoreCase); + } + + internal static bool IsBindingAttribute(AttributeAst attributeAst) + { + return ResolveRegistration(attributeAst) != null || IsGenericAttribute(attributeAst); + } + + internal static BindingInfo ConvertToBindingInfo(AttributeAst attributeAst) + { + ResolveBindingTypeAndDirection(attributeAst, out string bindingType, out BindingDirection direction); + return new BindingInfo + { + Type = bindingType, + Direction = ToGrpcDirection(direction) + }; + } + + internal static string ConvertToRawBinding(AttributeAst attributeAst, string parameterName) + { + ResolveBindingTypeAndDirection(attributeAst, out string bindingType, out BindingDirection direction); + + // Resolve array property names from the binding registration + var reg = ResolveRegistration(attributeAst); + var arrayPropertyNames = reg?.ArrayPropertyNames; + + var properties = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "name", parameterName }, + { "type", bindingType }, + { "direction", DirectionToString(direction) } + }; + + // Generically extract ALL named arguments from the AST + AddAllNamedArguments(attributeAst, properties, arrayPropertyNames); + + // For generic bindings, flatten the Properties string ("key=value; key=value" pairs) + if (IsGenericAttribute(attributeAst)) + { + FlattenGenericProperties(attributeAst, properties); + } + + return SerializeToJson(properties); + } + + internal static IEnumerable<(string name, BindingInfo info, string rawBinding)> GetImplicitOutputBindings(string triggerTypeName) + { + var shortName = StripAttributeSuffix(triggerTypeName); + if (RegisteredBindings.TryGetValue(shortName, out var reg) && reg.ImplicitOutputType != null) + { + var bindingName = "Response"; + var bindingInfo = new BindingInfo + { + Type = reg.ImplicitOutputType, + Direction = BindingInfo.Types.Direction.Out + }; + + var properties = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "name", bindingName }, + { "type", reg.ImplicitOutputType }, + { "direction", "out" } + }; + + yield return (bindingName, bindingInfo, SerializeToJson(properties)); + } + } + + /// + /// Returns the names of required properties that are missing from the given attribute AST. + /// + internal static IEnumerable GetMissingRequiredProperties(AttributeAst attributeAst) + { + var reg = ResolveRegistration(attributeAst); + if (reg == null || reg.RequiredPropertyNames.Count == 0) + { + yield break; + } + + var providedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + if (attributeAst.NamedArguments != null) + { + foreach (var namedArg in attributeAst.NamedArguments) + { + providedNames.Add(namedArg.ArgumentName); + } + } + + foreach (var required in reg.RequiredPropertyNames) + { + if (!providedNames.Contains(required)) + { + yield return required; + } + } + } + + /// + /// Gets the binding type string for the given attribute AST. + /// + internal static string GetBindingType(AttributeAst attributeAst) + { + var reg = ResolveRegistration(attributeAst); + return reg?.BindingType; + } + + #endregion + + #region Resolution helpers + + private static BindingRegistration ResolveRegistration(AttributeAst attributeAst) + { + var shortName = GetAttributeTypeName(attributeAst); + RegisteredBindings.TryGetValue(shortName, out var reg); + return reg; + } + + private static bool IsGenericAttribute(AttributeAst attributeAst) + { + return GenericAttributeNames.Contains(GetAttributeTypeName(attributeAst)); + } + + private static void ResolveBindingTypeAndDirection( + AttributeAst attributeAst, + out string bindingType, + out BindingDirection direction) + { + // Try registered (built-in) bindings first + var reg = ResolveRegistration(attributeAst); + if (reg != null) + { + bindingType = reg.BindingType; + direction = reg.Direction; + return; + } + + // Handle generic bindings — read Type from AST + var shortName = GetAttributeTypeName(attributeAst); + if (GenericAttributeNames.Contains(shortName)) + { + bindingType = GetNamedArgumentValue(attributeAst, "Type") + ?? throw new InvalidOperationException( + string.Format(PowerShellWorkerStrings.UnknownBindingAttribute, shortName + " (missing Type)")); + + if (shortName.Equals("GenericTrigger", StringComparison.OrdinalIgnoreCase)) + direction = BindingDirection.Trigger; + else if (shortName.Equals("GenericInputBinding", StringComparison.OrdinalIgnoreCase)) + direction = BindingDirection.In; + else + direction = BindingDirection.Out; + return; + } + + throw new InvalidOperationException( + string.Format(PowerShellWorkerStrings.UnknownBindingAttribute, shortName)); + } + + #endregion + + #region Generic property extraction + + /// + /// Extracts ALL named arguments from the attribute AST and adds them to the properties dict + /// using camelCase keys. Skips keys already present (name, type, direction) and excluded keys. + /// Properties listed in arrayPropertyNames are always serialized as arrays. + /// + private static void AddAllNamedArguments(AttributeAst attributeAst, Dictionary properties, HashSet arrayPropertyNames = null) + { + if (attributeAst.NamedArguments == null) return; + + foreach (var namedArg in attributeAst.NamedArguments) + { + var argName = namedArg.ArgumentName; + + // Skip framework-internal properties and already-set keys + if (ExcludedPropertyNames.Contains(argName)) continue; + + var camelKey = ToCamelCase(argName); + if (properties.ContainsKey(camelKey)) continue; + + bool forceArray = arrayPropertyNames != null && arrayPropertyNames.Contains(argName); + + // Try to extract the value as the appropriate type + var arrayVal = ExtractArrayValue(namedArg.Argument); + if (arrayVal != null) + { + properties[camelKey] = arrayVal; + continue; + } + + var boolVal = ExtractBooleanValue(namedArg.Argument); + if (!forceArray && boolVal.HasValue) + { + properties[camelKey] = boolVal.Value; + continue; + } + + var intVal = ExtractIntegerValue(namedArg.Argument); + if (!forceArray && intVal.HasValue) + { + properties[camelKey] = intVal.Value; + continue; + } + + var strVal = ExtractConstantStringValue(namedArg.Argument); + if (strVal != null) + { + // Split on commas if the string contains them, or if this property + // is declared as an array property (always emit as JSON array). + if (strVal.Contains(',') || forceArray) + { + properties[camelKey] = strVal.Split(',') + .Select(v => v.Trim()) + .Where(v => v.Length > 0) + .ToArray(); + } + else + { + properties[camelKey] = strVal; + } + } + } + } + + /// + /// For generic bindings, flattens Properties = "key1=val1; key2=val2" + /// into individual raw binding properties. + /// + private static void FlattenGenericProperties(AttributeAst attributeAst, Dictionary properties) + { + var propsString = GetNamedArgumentValue(attributeAst, "Properties"); + if (string.IsNullOrWhiteSpace(propsString)) return; + + foreach (var entry in propsString.Split(';')) + { + var trimmed = entry.Trim(); + if (string.IsNullOrEmpty(trimmed)) continue; + + var eqIndex = trimmed.IndexOf('='); + if (eqIndex > 0) + { + var key = trimmed.Substring(0, eqIndex).Trim(); + var value = trimmed.Substring(eqIndex + 1).Trim(); + if (!properties.ContainsKey(key)) + { + properties[key] = value; + } + } + } + } + + #endregion + + #region AST value extraction + + internal static string GetNamedArgumentValue(AttributeAst attributeAst, string argumentName) + { + if (attributeAst.NamedArguments == null) return null; + + foreach (var namedArg in attributeAst.NamedArguments) + { + if (namedArg.ArgumentName.Equals(argumentName, StringComparison.OrdinalIgnoreCase)) + { + return ExtractConstantStringValue(namedArg.Argument); + } + } + return null; + } + + internal static string[] GetNamedArgumentArrayValue(AttributeAst attributeAst, string argumentName) + { + if (attributeAst.NamedArguments == null) return null; + + foreach (var namedArg in attributeAst.NamedArguments) + { + if (namedArg.ArgumentName.Equals(argumentName, StringComparison.OrdinalIgnoreCase)) + { + return ExtractArrayValue(namedArg.Argument); + } + } + return null; + } + + private static string ExtractConstantStringValue(ExpressionAst expressionAst) + { + switch (expressionAst) + { + case StringConstantExpressionAst stringAst: + return stringAst.Value; + case ConstantExpressionAst constantAst when constantAst.Value is string s: + return s; + case ConstantExpressionAst constantAst: + return constantAst.Value?.ToString(); + default: + return null; + } + } + + private static bool? ExtractBooleanValue(ExpressionAst expressionAst) + { + // PowerShell $true/$false parse as VariableExpressionAst or ConstantExpressionAst + if (expressionAst is VariableExpressionAst varAst) + { + if (varAst.VariablePath.UserPath.Equals("true", StringComparison.OrdinalIgnoreCase)) return true; + if (varAst.VariablePath.UserPath.Equals("false", StringComparison.OrdinalIgnoreCase)) return false; + } + if (expressionAst is ConstantExpressionAst constAst && constAst.Value is bool b) + { + return b; + } + // Named argument with ExpressionOmitted means [Attr(Flag)] which is $true + return expressionAst == null ? true : (bool?)null; + } + + private static int? ExtractIntegerValue(ExpressionAst expressionAst) + { + if (expressionAst is ConstantExpressionAst constAst && constAst.Value is int i) + return i; + // Handle long constants that fit in int + if (expressionAst is ConstantExpressionAst longConst && longConst.Value is long l && l >= int.MinValue && l <= int.MaxValue) + return (int)l; + return null; + } + + private static string[] ExtractArrayValue(ExpressionAst expressionAst) + { + switch (expressionAst) + { + case ArrayLiteralAst arrayAst: + return arrayAst.Elements + .Select(ExtractConstantStringValue) + .Where(v => v != null) + .ToArray(); + + case ArrayExpressionAst arrayExprAst: + var elements = new List(); + foreach (var statement in arrayExprAst.SubExpression.Statements) + { + if (statement is PipelineAst pipeline) + { + foreach (var command in pipeline.PipelineElements) + { + if (command is CommandExpressionAst cmdExpr) + { + if (cmdExpr.Expression is ArrayLiteralAst innerArray) + { + elements.AddRange(innerArray.Elements + .Select(ExtractConstantStringValue) + .Where(v => v != null)); + } + else + { + var val = ExtractConstantStringValue(cmdExpr.Expression); + if (val != null) elements.Add(val); + } + } + } + } + } + return elements.Count > 0 ? elements.ToArray() : null; + + // Only match actual array-typed AST nodes, not plain strings. + // Plain strings are handled by ExtractConstantStringValue. + default: + return null; + } + } + + #endregion + + #region Serialization / Utility + + private static string GetAttributeTypeName(AttributeAst attributeAst) + { + return StripAttributeSuffix(attributeAst.TypeName.Name); + } + + private static string StripAttributeSuffix(string name) + { + if (name.EndsWith("Attribute", StringComparison.OrdinalIgnoreCase)) + return name.Substring(0, name.Length - "Attribute".Length); + return name; + } + + private static string ToCamelCase(string pascalCase) + { + if (string.IsNullOrEmpty(pascalCase)) return pascalCase; + if (char.IsLower(pascalCase[0])) return pascalCase; + return char.ToLowerInvariant(pascalCase[0]) + pascalCase.Substring(1); + } + + private static BindingInfo.Types.Direction ToGrpcDirection(BindingDirection direction) + { + switch (direction) + { + case BindingDirection.Trigger: return BindingInfo.Types.Direction.In; + case BindingDirection.In: return BindingInfo.Types.Direction.In; + case BindingDirection.Out: return BindingInfo.Types.Direction.Out; + default: return BindingInfo.Types.Direction.In; + } + } + + private static string DirectionToString(BindingDirection direction) + { + switch (direction) + { + case BindingDirection.Trigger: return "in"; + case BindingDirection.In: return "in"; + case BindingDirection.Out: return "out"; + default: return "in"; + } + } + + private static string SerializeToJson(Dictionary properties) + { + var parts = new List(); + foreach (var kvp in properties) + { + if (kvp.Value is string[] arrayVal) + { + var items = string.Join(",", arrayVal.Select(v => $"\"{EscapeJsonString(v)}\"")); + parts.Add($"\"{EscapeJsonString(kvp.Key)}\":[{items}]"); + } + else if (kvp.Value is bool boolVal) + { + parts.Add($"\"{EscapeJsonString(kvp.Key)}\":{(boolVal ? "true" : "false")}"); + } + else if (kvp.Value is int intVal) + { + parts.Add($"\"{EscapeJsonString(kvp.Key)}\":{intVal}"); + } + else if (kvp.Value is string strVal) + { + parts.Add($"\"{EscapeJsonString(kvp.Key)}\":\"{EscapeJsonString(strVal)}\""); + } + else if (kvp.Value != null) + { + parts.Add($"\"{EscapeJsonString(kvp.Key)}\":\"{EscapeJsonString(kvp.Value.ToString())}\""); + } + } + return "{" + string.Join(",", parts) + "}"; + } + + private static string EscapeJsonString(string value) + { + return value?.Replace("\\", "\\\\").Replace("\"", "\\\""); + } + + #endregion + } +} diff --git a/src/WorkerIndexing/FunctionIndexer.cs b/src/WorkerIndexing/FunctionIndexer.cs new file mode 100644 index 00000000..ef3ac4d1 --- /dev/null +++ b/src/WorkerIndexing/FunctionIndexer.cs @@ -0,0 +1,425 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management.Automation.Language; +using System.Text.RegularExpressions; + +using Microsoft.Azure.Functions.PowerShellWorker.Utility; +using Microsoft.Azure.WebJobs.Script.Grpc.Messages; + +namespace Microsoft.Azure.Functions.PowerShellWorker.WorkerIndexing +{ + /// + /// Discovers Azure Functions defined using the V2 attribute-based programming model + /// by scanning PowerShell files and parsing the AST (no code execution needed). + /// + internal static class FunctionIndexer + { + private const string FunctionLanguagePowerShell = "powershell"; + + // Directories to skip when scanning for function files + private static readonly HashSet ExcludedDirectories = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".git", + ".vscode", + ".venv", + "node_modules", + "__pycache__", + "bin", + "obj", + }; + + /// + /// Scans the function app directory for PowerShell files containing V2 function definitions. + /// Returns RpcFunctionMetadata for each discovered function. + /// + internal static IEnumerable IndexFunctions(string functionAppDirectory, ILogger logger) + { + var results = new List(); + var scriptFiles = DiscoverScriptFiles(functionAppDirectory); + + foreach (var scriptFile in scriptFiles) + { + try + { + var functions = IndexFunctionsInFile(scriptFile, functionAppDirectory); + results.AddRange(functions); + } + catch (Exception ex) + { + logger?.Log( + isUserOnlyLog: false, + Microsoft.Azure.WebJobs.Script.Grpc.Messages.RpcLog.Types.Level.Warning, + string.Format(PowerShellWorkerStrings.FailedToIndexFile, scriptFile, ex.Message)); + } + } + + return results; + } + + /// + /// Indexes all V2 functions defined in a single PowerShell file. + /// + internal static IEnumerable IndexFunctionsInFile(string scriptFile, string functionAppDirectory) + { + var scriptAst = Parser.ParseFile(scriptFile, out _, out ParseError[] errors); + if (errors != null && errors.Length > 0) + { + var errorMessages = string.Join(Environment.NewLine, errors.Select(e => e.Message)); + throw new InvalidOperationException( + string.Format(PowerShellWorkerStrings.FailToParseScript, scriptFile, errorMessages)); + } + + var results = new List(); + + // Find all function definitions with [AzFunction()] attribute + var functionDefs = scriptAst.FindAll( + ast => ast is FunctionDefinitionAst, + searchNestedScriptBlocks: false) + .Cast(); + + foreach (var funcDef in functionDefs) + { + var azFuncAttr = GetAzFunctionAttribute(funcDef); + if (azFuncAttr == null) + { + continue; + } + + var metadata = BuildFunctionMetadata(funcDef, azFuncAttr, scriptFile, functionAppDirectory); + results.Add(metadata); + } + + return results; + } + + #region Private helpers + + /// + /// Recursively discovers all .ps1/.psm1 files in the function app directory, + /// excluding well-known directories and paths matching .funcignore patterns. + /// + private static IEnumerable DiscoverScriptFiles(string rootDirectory) + { + var files = new List(); + var ignorePatterns = LoadFuncIgnorePatterns(rootDirectory); + DiscoverScriptFilesRecursive(rootDirectory, rootDirectory, ignorePatterns, files); + return files; + } + + private static void DiscoverScriptFilesRecursive( + string directory, + string rootDirectory, + List ignorePatterns, + List files) + { + try + { + foreach (var file in Directory.EnumerateFiles(directory)) + { + var ext = Path.GetExtension(file); + if (ext.Equals(".ps1", StringComparison.OrdinalIgnoreCase) || + ext.Equals(".psm1", StringComparison.OrdinalIgnoreCase)) + { + if (!IsIgnored(file, rootDirectory, ignorePatterns)) + { + files.Add(file); + } + } + } + + foreach (var subDir in Directory.EnumerateDirectories(directory)) + { + var dirName = Path.GetFileName(subDir); + if (!ExcludedDirectories.Contains(dirName) && + !IsIgnored(subDir, rootDirectory, ignorePatterns)) + { + DiscoverScriptFilesRecursive(subDir, rootDirectory, ignorePatterns, files); + } + } + } + catch (UnauthorizedAccessException) + { + // Skip directories we don't have permission to access + } + catch (DirectoryNotFoundException) + { + // Directory may have been removed during scan + } + } + + /// + /// Loads patterns from a .funcignore file in the given directory. + /// Returns an empty list if no .funcignore file exists. + /// Each non-empty, non-comment line is converted to a Regex pattern. + /// + internal static List LoadFuncIgnorePatterns(string rootDirectory) + { + var patterns = new List(); + var funcIgnorePath = Path.Combine(rootDirectory, ".funcignore"); + + if (!File.Exists(funcIgnorePath)) + { + return patterns; + } + + foreach (var line in File.ReadAllLines(funcIgnorePath)) + { + var trimmed = line.Trim(); + if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith("#")) + { + continue; + } + + var regexPattern = GlobToRegex(trimmed); + patterns.Add(new Regex(regexPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled)); + } + + return patterns; + } + + /// + /// Checks whether a file or directory path matches any of the .funcignore patterns. + /// Patterns are tested against both the file/directory name and its relative path. + /// + private static bool IsIgnored(string fullPath, string rootDirectory, List patterns) + { + if (patterns.Count == 0) + { + return false; + } + + var name = Path.GetFileName(fullPath); + var relativePath = fullPath.Substring(rootDirectory.Length) + .TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Replace('\\', '/'); + + foreach (var pattern in patterns) + { + if (pattern.IsMatch(name) || pattern.IsMatch(relativePath)) + { + return true; + } + } + + return false; + } + + /// + /// Converts a simple glob pattern to a regex pattern. + /// Supports * (any chars except /), ** (any chars including /), and ? (single char). + /// + internal static string GlobToRegex(string glob) + { + var regexParts = new System.Text.StringBuilder("^"); + int i = 0; + + while (i < glob.Length) + { + char c = glob[i]; + + if (c == '*') + { + if (i + 1 < glob.Length && glob[i + 1] == '*') + { + // ** matches everything including directory separators + regexParts.Append(".*"); + i += 2; + // Skip trailing / after ** + if (i < glob.Length && (glob[i] == '/' || glob[i] == '\\')) + { + i++; + } + } + else + { + // * matches everything except directory separators + regexParts.Append("[^/\\\\]*"); + i++; + } + } + else if (c == '?') + { + regexParts.Append("[^/\\\\]"); + i++; + } + else + { + regexParts.Append(Regex.Escape(c.ToString())); + i++; + } + } + + regexParts.Append("$"); + return regexParts.ToString(); + } + + /// + /// Finds the [AzFunction()] attribute on a function definition, if present. + /// Checks both the function body's param block attributes and the function's attributes. + /// + private static AttributeAst GetAzFunctionAttribute(FunctionDefinitionAst funcDef) + { + // In PowerShell, function-level attributes are on the param block: + // function Foo { [AzFunction()] param(...) } + var paramBlock = funcDef.Body?.ParamBlock; + if (paramBlock?.Attributes != null) + { + foreach (var attr in paramBlock.Attributes) + { + if (AttributeToBindingConverter.IsAzFunctionAttribute(attr)) + { + return attr; + } + } + } + + return null; + } + + /// + /// Builds RpcFunctionMetadata from a function definition AST. + /// + private static RpcFunctionMetadata BuildFunctionMetadata( + FunctionDefinitionAst funcDef, + AttributeAst azFuncAttr, + string scriptFile, + string functionAppDirectory) + { + var functionName = AttributeToBindingConverter.GetFunctionNameOverride(azFuncAttr) + ?? funcDef.Name; + var relativeFile = Path.GetFileName(scriptFile); + + var metadata = new RpcFunctionMetadata + { + Name = functionName, + FunctionId = Guid.NewGuid().ToString(), + EntryPoint = funcDef.Name, + ScriptFile = scriptFile, + Directory = functionAppDirectory, + Language = FunctionLanguagePowerShell, + IsProxy = false, + }; + + // Mark as worker-indexed so AzFunctionInfo can distinguish V2 from V1 + metadata.Properties.Add("WorkerIndexed", "true"); + + var triggerNames = new List(); + var bindingNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Extract bindings from parameter attributes + var paramBlock = funcDef.Body?.ParamBlock; + if (paramBlock?.Parameters != null) + { + foreach (var param in paramBlock.Parameters) + { + var paramName = param.Name.VariablePath.UserPath; + + foreach (var attr in param.Attributes) + { + if (!(attr is AttributeAst attrAst)) + { + continue; + } + + if (!AttributeToBindingConverter.IsBindingAttribute(attrAst)) + { + continue; + } + + // Validate unique binding names + if (!bindingNames.Add(paramName)) + { + throw new InvalidOperationException( + string.Format(PowerShellWorkerStrings.DuplicateBindingName, functionName, relativeFile, paramName)); + } + + // Validate required properties + foreach (var missing in AttributeToBindingConverter.GetMissingRequiredProperties(attrAst)) + { + var bindingType = AttributeToBindingConverter.GetBindingType(attrAst) ?? attrAst.TypeName.Name; + throw new InvalidOperationException( + string.Format(PowerShellWorkerStrings.MissingRequiredBindingProperty, + functionName, relativeFile, paramName, bindingType, missing)); + } + + var bindingInfo = AttributeToBindingConverter.ConvertToBindingInfo(attrAst); + var rawBinding = AttributeToBindingConverter.ConvertToRawBinding(attrAst, paramName); + + metadata.Bindings.Add(paramName, bindingInfo); + metadata.RawBindings.Add(rawBinding); + + if (AttributeToBindingConverter.IsTriggerAttribute(attrAst)) + { + triggerNames.Add(paramName); + } + } + } + } + + // Validate exactly one trigger + if (triggerNames.Count == 0) + { + throw new InvalidOperationException( + string.Format(PowerShellWorkerStrings.NoTriggerBinding, functionName, relativeFile)); + } + if (triggerNames.Count > 1) + { + throw new InvalidOperationException( + string.Format(PowerShellWorkerStrings.MultipleTriggerBindings, + functionName, relativeFile, string.Join(", ", triggerNames))); + } + + // Add implicit output bindings based on trigger type + var triggerParamAttr = GetTriggerAttribute(funcDef); + if (triggerParamAttr != null) + { + var triggerTypeName = triggerParamAttr.TypeName.Name; + if (triggerTypeName.EndsWith("Attribute", StringComparison.OrdinalIgnoreCase)) + { + triggerTypeName = triggerTypeName.Substring(0, triggerTypeName.Length - "Attribute".Length); + } + + foreach (var (name, info, rawBinding) in AttributeToBindingConverter.GetImplicitOutputBindings(triggerTypeName)) + { + if (bindingNames.Add(name)) + { + metadata.Bindings.Add(name, info); + metadata.RawBindings.Add(rawBinding); + } + } + } + + return metadata; + } + + /// + /// Finds the trigger attribute on a function's parameters. + /// + private static AttributeAst GetTriggerAttribute(FunctionDefinitionAst funcDef) + { + var paramBlock = funcDef.Body?.ParamBlock; + if (paramBlock?.Parameters == null) return null; + + foreach (var param in paramBlock.Parameters) + { + foreach (var attr in param.Attributes) + { + if (attr is AttributeAst attrAst && AttributeToBindingConverter.IsTriggerAttribute(attrAst)) + { + return attrAst; + } + } + } + return null; + } + + #endregion + } +} diff --git a/src/resources/PowerShellWorkerStrings.resx b/src/resources/PowerShellWorkerStrings.resx index 4ed6c405..1f9d726c 100644 --- a/src/resources/PowerShellWorkerStrings.resx +++ b/src/resources/PowerShellWorkerStrings.resx @@ -391,4 +391,31 @@ Automatic upgrades are disabled in PowerShell 7.6 function apps. This warning should not be emitted until PowerShell 7.6's End of Life date, at which time, more guidance will be available regarding how to upgrade your function app to the latest version. + + No V2 (worker-indexed) functions found. Falling back to host-based function indexing. + + + Successfully indexed {0} V2 function(s) in {1}ms. + + + Failed to index PowerShell file '{0}': {1} + + + Unknown binding attribute type: '{0}'. Ensure the attribute is a recognized trigger, input, or output binding. + + + Function '{0}' produced {1} pipeline output objects but the return binding expects a single value. Extra objects may be unintentional pipeline output. Use '$null = expression' or '[void]' to suppress unwanted output. + + + Function '{0}' in '{1}' has multiple trigger bindings ({2}). A function must have exactly one trigger. + + + Function '{0}' in '{1}' has duplicate binding name '{2}'. Each binding name must be unique within a function. + + + Function '{0}' in '{1}': binding '{2}' of type '{3}' is missing required property '{4}'. + + + Function '{0}' in '{1}' has no trigger binding. A function must have exactly one trigger. + \ No newline at end of file diff --git a/src/worker.config.json b/src/worker.config.json index 6d84c564..c038a67d 100644 --- a/src/worker.config.json +++ b/src/worker.config.json @@ -6,6 +6,7 @@ "defaultWorkerPath":"%FUNCTIONS_WORKER_RUNTIME_VERSION%/Microsoft.Azure.Functions.PowerShellWorker.dll", "supportedRuntimeVersions":["7", "7.2", "7.4", "7.6"], "defaultRuntimeVersion": "7.4", - "sanitizeRuntimeVersionRegex":"\\d+\\.?\\d*" + "sanitizeRuntimeVersionRegex":"\\d+\\.?\\d*", + "workerIndexing": "true" } } diff --git a/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E.csproj b/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E.csproj index 4263f4cb..4199a6c1 100644 --- a/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E.csproj +++ b/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E.csproj @@ -7,6 +7,7 @@ + diff --git a/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Constants.cs b/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Constants.cs index aaaa84d2..1364a818 100644 --- a/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Constants.cs +++ b/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Constants.cs @@ -21,7 +21,7 @@ public static class Queue { // CosmosDB tests public static class CosmosDB { - public static string CosmosDBConnectionStringSetting = Environment.GetEnvironmentVariable("AzureWebJobsCosmosDBConnectionString"); + public static string CosmosDBAccountEndpoint = Environment.GetEnvironmentVariable("CosmosDBConnection__accountEndpoint"); public static string DbName = "ItemDb"; public static string InputCollectionName = "PartitionedItemCollectionIn"; public static string OutputCollectionName = "PartitionedItemCollectionOut"; @@ -30,7 +30,7 @@ public static class CosmosDB { // EventHubs public static class EventHubs { - public static string EventHubsConnectionStringSetting = Environment.GetEnvironmentVariable("AzureWebJobsEventHubSender"); + public static string EventHubsNamespace = Environment.GetEnvironmentVariable("EventHubConnection__fullyQualifiedNamespace"); public static class Json_Test { public static string OutputName = "test-output-object-ps"; diff --git a/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Fixtures/FixtureHelpers.cs b/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Fixtures/FixtureHelpers.cs index 68c91a8c..21faef29 100644 --- a/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Fixtures/FixtureHelpers.cs +++ b/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Fixtures/FixtureHelpers.cs @@ -17,7 +17,7 @@ public static Process GetFuncHostProcess(bool enableAuth = false) funcHostProcess.StartInfo.RedirectStandardError = true; funcHostProcess.StartInfo.RedirectStandardOutput = true; funcHostProcess.StartInfo.CreateNoWindow = true; - funcHostProcess.StartInfo.WorkingDirectory = Path.Combine(rootDir, String.Format(@"test{0}E2E{0}TestFunctionApp", Path.DirectorySeparatorChar)); + funcHostProcess.StartInfo.WorkingDirectory = Path.Combine(rootDir, String.Format(@"test{0}E2E{0}TestFunctionAppV2", Path.DirectorySeparatorChar)); funcHostProcess.StartInfo.FileName = Path.Combine(rootDir, "test", "E2E", "Azure.Functions.Cli", funcName); funcHostProcess.StartInfo.ArgumentList.Add("start"); if (enableAuth) diff --git a/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Helpers/CosmosDBHelpers.cs b/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Helpers/CosmosDBHelpers.cs index ea217a8d..bff6e185 100644 --- a/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Helpers/CosmosDBHelpers.cs +++ b/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Helpers/CosmosDBHelpers.cs @@ -3,6 +3,7 @@ using System; using System.Threading.Tasks; +using Azure.Identity; using Microsoft.Azure.Cosmos; namespace Azure.Functions.PowerShell.Tests.E2E @@ -20,10 +21,7 @@ private class Document static CosmosDBHelpers() { - var builder = new System.Data.Common.DbConnectionStringBuilder(); - builder.ConnectionString = Constants.CosmosDB.CosmosDBConnectionStringSetting; - var serviceUri = builder["AccountEndpoint"].ToString(); - _cosmosDbClient = new CosmosClient(serviceUri, builder["AccountKey"].ToString()); + _cosmosDbClient = new CosmosClient(Constants.CosmosDB.CosmosDBAccountEndpoint, new DefaultAzureCredential()); } // keep diff --git a/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Helpers/EventHubsHelpers.cs b/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Helpers/EventHubsHelpers.cs index ba70e63d..9cdedfb6 100644 --- a/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Helpers/EventHubsHelpers.cs +++ b/test/E2E/Azure.Functions.PowerShellWorker.E2E/Azure.Functions.PowerShellWorker.E2E/Helpers/EventHubsHelpers.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. +using Azure.Identity; using Azure.Messaging.EventHubs; using Azure.Messaging.EventHubs.Producer; using Newtonsoft.Json; @@ -30,7 +31,7 @@ public static async Task SendJSONMessagesAsync(string eventId, string eventHubNa events.Add(evt); } - EventHubProducerClient eventHubClient = new EventHubProducerClient(Constants.EventHubs.EventHubsConnectionStringSetting, eventHubName); + EventHubProducerClient eventHubClient = new EventHubProducerClient(Constants.EventHubs.EventHubsNamespace, eventHubName, new DefaultAzureCredential()); await eventHubClient.SendAsync(events); } @@ -47,7 +48,7 @@ public static async Task SendMessagesAsync(string eventId, string eventHubName) events.Add(evt); } - EventHubProducerClient eventHubClient = new EventHubProducerClient(Constants.EventHubs.EventHubsConnectionStringSetting, eventHubName); + EventHubProducerClient eventHubClient = new EventHubProducerClient(Constants.EventHubs.EventHubsNamespace, eventHubName, new DefaultAzureCredential()); await eventHubClient.SendAsync(events); } } diff --git a/test/E2E/Start-E2ETest.ps1 b/test/E2E/Start-E2ETest.ps1 index 0fc33fd5..fa20311a 100644 --- a/test/E2E/Start-E2ETest.ps1 +++ b/test/E2E/Start-E2ETest.ps1 @@ -125,7 +125,7 @@ $Env:Path = "$Env:Path$([System.IO.Path]::PathSeparator)$FUNC_CLI_DIRECTORY" $funcExePath = Join-Path $FUNC_CLI_DIRECTORY $FUNC_EXE_NAME Write-Host "Installing extensions..." -Push-Location "$PSScriptRoot\TestFunctionApp" +Push-Location "$PSScriptRoot\TestFunctionAppV2" if ($IsMacOS -or $IsLinux) { chmod +x $funcExePath diff --git a/test/E2E/TestFunctionAppV2/CosmosDBFunctions.psm1 b/test/E2E/TestFunctionAppV2/CosmosDBFunctions.psm1 new file mode 100644 index 00000000..e045b775 --- /dev/null +++ b/test/E2E/TestFunctionAppV2/CosmosDBFunctions.psm1 @@ -0,0 +1,30 @@ +# CosmosDB Functions +# Groups: CosmosDBTriggerAndOutput + +function CosmosDBTriggerAndOutput { + [AzFunction()] + param( + [CosmosDBTrigger( + DatabaseName = "ItemDb", + ContainerName = "PartitionedItemCollectionIn", + Connection = "CosmosDBConnection", + LeaseContainerName = "leases", + CreateLeaseContainerIfNotExists = $true)] + $itemIn, + + [CosmosDBOutput( + DatabaseName = "ItemDb", + ContainerName = "PartitionedItemCollectionOut", + Connection = "CosmosDBConnection")] + $itemOut + ) + + Write-Host "PowerShell Cosmos DB trigger function executed. Received document: $itemIn" + + if ($itemIn -and $itemIn.Count -gt 0) { + $doc = $itemIn[0] + Write-Host "Document Id: $($doc.id)" + $doc.Description = "testdescription" + Push-OutputBinding -Name itemOut -Value $doc + } +} diff --git a/test/E2E/TestFunctionAppV2/DurableBasicOrchestration.psm1 b/test/E2E/TestFunctionAppV2/DurableBasicOrchestration.psm1 new file mode 100644 index 00000000..20eab031 --- /dev/null +++ b/test/E2E/TestFunctionAppV2/DurableBasicOrchestration.psm1 @@ -0,0 +1,128 @@ +# Durable Basic Orchestration Chain +# DurableClient (generic HTTP starter) → DurableOrchestrator → DurableActivity, DurableActivityFlaky +# DurableClientTerminating also uses DurableOrchestrator + +using namespace System.Net + +function DurableClient { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = "Anonymous", Methods = "GET, POST")] + $Request, + + $TriggerMetadata, + + [DurableClient()] + $starter + ) + + Write-Host "DurableClient started" + $ErrorActionPreference = 'Stop' + + $FunctionName = $Request.Query.FunctionName ?? 'DurableOrchestrator' + + $InstanceId = Start-DurableOrchestration -FunctionName $FunctionName -InputObject 'Hello' + Write-Host "Started orchestration with ID = '$InstanceId'" + + $Response = New-DurableOrchestrationCheckStatusResponse -Request $Request -InstanceId $InstanceId + Push-OutputBinding -Name Response -Value $Response + + $Status = Get-DurableStatus -InstanceId $InstanceId + Write-Host "Orchestration $InstanceId status: $($Status | ConvertTo-Json)" + if ($Status.runtimeStatus -notin 'Pending', 'Running', 'Failed', 'Completed') { + throw "Unexpected orchestration $InstanceId runtime status: $($Status.runtimeStatus)" + } + + Write-Host "DurableClient completed" +} + +function DurableClientTerminating { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = "Anonymous", Methods = "GET, POST")] + $Request, + + $TriggerMetadata, + + [DurableClient()] + $starter + ) + + Write-Host "DurableClient started" + $ErrorActionPreference = 'Stop' + + $FunctionName = $Request.Query.FunctionName ?? 'DurableOrchestrator' + + $InstanceId = Start-DurableOrchestration -FunctionName $FunctionName -InputObject 'Hello' + Write-Host "Started orchestration with ID = '$InstanceId'" + + Stop-DurableOrchestration -InstanceId $InstanceId -Reason 'Terminated intentionally' + + $Response = New-DurableOrchestrationCheckStatusResponse -Request $Request -InstanceId $InstanceId + Push-OutputBinding -Name Response -Value $Response + + Write-Host "DurableClient completed" +} + +function DurableOrchestrator { + [AzFunction()] + param( + [OrchestrationTrigger()] + $Context + ) + + $ErrorActionPreference = 'Stop' + Write-Host "DurableOrchestrator: started. Input: $($Context.Input)" + + Set-DurableCustomStatus -CustomStatus 'Custom status: started' + + # Function chaining + $output = @() + $output += Invoke-DurableActivity -FunctionName "DurableActivity" -Input "Tokyo" + + # Fan-out/Fan-in + $tasks = @() + $tasks += Invoke-DurableActivity -FunctionName "DurableActivity" -Input "Seattle" -NoWait + $tasks += Invoke-DurableActivity -FunctionName "DurableActivity" -Input "London" -NoWait + $output += Wait-DurableTask -Task $tasks + + # Retries + $retryOptions = New-DurableRetryOptions -FirstRetryInterval (New-Timespan -Seconds 2) -MaxNumberOfAttempts 5 + $inputData = @{ Name = 'Toronto'; StartTime = $Context.CurrentUtcDateTime } + $output += Invoke-DurableActivity -FunctionName "DurableActivityFlaky" -Input $inputData -RetryOptions $retryOptions + + Set-DurableCustomStatus -CustomStatus 'Custom status: finished' + Write-Host "DurableOrchestrator: finished." + + return $output +} + +function DurableActivity { + [AzFunction()] + param( + [ActivityTrigger()] + $name + ) + + Write-Host "DurableActivity($name) started" + Start-Sleep -Seconds 1 + Write-Host "DurableActivity($name) finished" + + "Hello $name" +} + +function DurableActivityFlaky { + [AzFunction()] + param( + [ActivityTrigger()] + $InputData + ) + + # Intentional intermittent error, eventually "self-healing" + $elapsedTime = (Get-Date).ToUniversalTime() - $InputData.StartTime + if ($elapsedTime.TotalSeconds -lt 3) { + throw 'Nope, no luck this time...' + } + + "Hello $($InputData.Name)" +} diff --git a/test/E2E/TestFunctionAppV2/DurableContextProperties.psm1 b/test/E2E/TestFunctionAppV2/DurableContextProperties.psm1 new file mode 100644 index 00000000..964137b1 --- /dev/null +++ b/test/E2E/TestFunctionAppV2/DurableContextProperties.psm1 @@ -0,0 +1,37 @@ +# Durable Context Properties Chain +# DurableClientOrchContextProperties → DurableOrchestratorAccessContextProps → (reuses DurableActivity) + +using namespace System.Net + +function DurableClientOrchContextProperties { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = "Function", Methods = "POST, GET")] + $Request, + + $TriggerMetadata, + + [DurableClient()] + $starter + ) + + $InstanceId = Start-DurableOrchestration -FunctionName "DurableOrchestratorAccessContextProps" -InstanceId "myInstanceId" + Write-Host "Started orchestration with ID = '$InstanceId'" + + $Response = New-DurableOrchestrationCheckStatusResponse -Request $Request -InstanceId $InstanceId + Push-OutputBinding -Name Response -Value $Response +} + +function DurableOrchestratorAccessContextProps { + [AzFunction()] + param( + [OrchestrationTrigger()] + $Context + ) + + $output = @() + $output += $Context.IsReplaying + $output += Invoke-DurableActivity -FunctionName 'DurableActivity' -Input $Context.InstanceId + $output += $Context.IsReplaying + $output +} diff --git a/test/E2E/TestFunctionAppV2/DurableEvents.psm1 b/test/E2E/TestFunctionAppV2/DurableEvents.psm1 new file mode 100644 index 00000000..af09283c --- /dev/null +++ b/test/E2E/TestFunctionAppV2/DurableEvents.psm1 @@ -0,0 +1,44 @@ +# Durable Event Handling Orchestrations +# DurableOrchestratorRaiseEvent, DurableOrchestratorComplexRaiseEvent, DurableOrchestratorGetTaskResult +# (All started via generic DurableClient with ?FunctionName=) + +function DurableOrchestratorRaiseEvent { + [AzFunction()] + param( + [OrchestrationTrigger()] + $Context + ) + + $output = @() + $output += Start-DurableExternalEventListener -EventName "TESTEVENTNAME" + $output +} + +function DurableOrchestratorComplexRaiseEvent { + [AzFunction()] + param( + [OrchestrationTrigger()] + $Context + ) + + $output = @() + Invoke-DurableActivity -FunctionName "DurableActivity" -Input "Tokyo" + Invoke-DurableActivity -FunctionName "DurableActivity" -Input "Seattle" + $output += Start-DurableExternalEventListener -EventName "TESTEVENTNAME" + Invoke-DurableActivity -FunctionName "DurableActivity" -Input "London" + $output +} + +function DurableOrchestratorGetTaskResult { + [AzFunction()] + param( + [OrchestrationTrigger()] + $Context + ) + + $output = @() + $task = Invoke-DurableActivity -FunctionName 'DurableActivity' -Input "world" -NoWait + $firstTask = Wait-DurableTask -Task $task -Any + $output += Get-DurableTaskResult -Task $firstTask + $output +} diff --git a/test/E2E/TestFunctionAppV2/DurableExceptions.psm1 b/test/E2E/TestFunctionAppV2/DurableExceptions.psm1 new file mode 100644 index 00000000..6a163af7 --- /dev/null +++ b/test/E2E/TestFunctionAppV2/DurableExceptions.psm1 @@ -0,0 +1,27 @@ +# Durable Exception Handling Chain +# DurableOrchestratorWithException → DurableActivityWithException +# (Started via generic DurableClient with ?FunctionName=DurableOrchestratorWithException) + +using namespace System.Net + +function DurableOrchestratorWithException { + [AzFunction()] + param( + [OrchestrationTrigger()] + $Context + ) + + $ErrorActionPreference = 'Stop' + Invoke-DurableActivity -FunctionName 'DurableActivityWithException' -Input 'Name' -ErrorAction Stop + 'This should not be returned' +} + +function DurableActivityWithException { + [AzFunction()] + param( + [ActivityTrigger()] + $name + ) + + throw "Intentional exception ($name)" +} diff --git a/test/E2E/TestFunctionAppV2/DurableExternalEvents.psm1 b/test/E2E/TestFunctionAppV2/DurableExternalEvents.psm1 new file mode 100644 index 00000000..db324584 --- /dev/null +++ b/test/E2E/TestFunctionAppV2/DurableExternalEvents.psm1 @@ -0,0 +1,75 @@ +# Durable External Event Chain +# ExternalEventClient → ExternalEventOrchestrator + +using namespace System.Net + +function ExternalEventClient { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = "Anonymous", Methods = "GET, POST")] + $Request, + + $TriggerMetadata, + + [DurableClient()] + $starter + ) + + Write-Host "ExternalEventClient started" + $ErrorActionPreference = 'Stop' + + $OrchestratorInputs = @{ FirstDuration = 5; SecondDuration = 60 } + + $InstanceId = Start-DurableOrchestration -FunctionName 'ExternalEventOrchestrator' -InputObject $OrchestratorInputs + Write-Host "Started orchestration with ID = '$InstanceId'" + + $Response = New-DurableOrchestrationCheckStatusResponse -Request $Request -InstanceId $InstanceId + Push-OutputBinding -Name Response -Value $Response + + Start-Sleep -Seconds 3 + Send-DurableExternalEvent -InstanceId $InstanceId -EventName "SecondExternalEvent" + + Write-Host "ExternalEventClient completed" +} + +function ExternalEventOrchestrator { + [AzFunction()] + param( + [OrchestrationTrigger()] + $Context + ) + + $ErrorActionPreference = 'Stop' + Write-Host "ExternalEventOrchestrator started." + + $output = @() + + $firstDuration = New-TimeSpan -Seconds $Context.Input.FirstDuration + $secondDuration = New-TimeSpan -Seconds $Context.Input.SecondDuration + + $firstTimeout = Start-DurableTimer -Duration $firstDuration -NoWait + $firstExternalEvent = Start-DurableExternalEventListener -EventName "FirstExternalEvent" -NoWait + $firstCompleted = Wait-DurableTask -Task $firstTimeout, $firstExternalEvent -Any + + if ($firstCompleted -eq $firstTimeout) { + $output += "FirstTimeout" + } + else { + $output += "FirstExternalEvent" + Stop-DurableTimerTask -Task $firstTimeout + } + + $secondTimeout = Start-DurableTimer -Duration $secondDuration -NoWait + $secondExternalEvent = Start-DurableExternalEventListener -EventName "SecondExternalEvent" -NoWait + $secondCompleted = Wait-DurableTask -Task $secondTimeout, $secondExternalEvent -Any + + if ($secondCompleted -eq $secondTimeout) { + $output += "SecondTimeout" + } + else { + $output += "SecondExternalEvent" + Stop-DurableTimerTask -Task $secondTimeout + } + + return $output +} diff --git a/test/E2E/TestFunctionAppV2/DurableLegacyNames.psm1 b/test/E2E/TestFunctionAppV2/DurableLegacyNames.psm1 new file mode 100644 index 00000000..eed81ed8 --- /dev/null +++ b/test/E2E/TestFunctionAppV2/DurableLegacyNames.psm1 @@ -0,0 +1,45 @@ +# Durable Legacy Names Chain +# DurableClientLegacyNames → DurableOrchestratorLegacyNames → (reuses DurableActivity) + +using namespace System.Net + +function DurableClientLegacyNames { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = "Anonymous", Methods = "GET, POST")] + $Request, + + $TriggerMetadata, + + [DurableClient()] + $starter + ) + + Write-Host "DurableClientLegacyNames started" + $ErrorActionPreference = 'Stop' + + $InstanceId = Start-NewOrchestration -FunctionName 'DurableOrchestratorLegacyNames' -InputObject 'Hello' + Write-Host "Started orchestration with ID = '$InstanceId'" + + $Response = New-OrchestrationCheckStatusResponse -Request $Request -InstanceId $InstanceId + Push-OutputBinding -Name Response -Value $Response + + Write-Host "DurableClientLegacyNames completed" +} + +function DurableOrchestratorLegacyNames { + [AzFunction()] + param( + [OrchestrationTrigger()] + $Context + ) + + $ErrorActionPreference = 'Stop' + Write-Host "DurableOrchestratorLegacyNames: started. Input: $($Context.Input)" + + Invoke-ActivityFunction -FunctionName "DurableActivity" -Input "Tokyo" + + Write-Host "DurableOrchestratorLegacyNames: finished." + + return $output +} diff --git a/test/E2E/TestFunctionAppV2/DurableQueueOutput.psm1 b/test/E2E/TestFunctionAppV2/DurableQueueOutput.psm1 new file mode 100644 index 00000000..2f3e9c32 --- /dev/null +++ b/test/E2E/TestFunctionAppV2/DurableQueueOutput.psm1 @@ -0,0 +1,32 @@ +# Durable Queue Output Chain +# DurableOrchestratorWriteToQueue → DurableActivityWritesToQueue +# (Started via generic DurableClient with ?FunctionName=DurableOrchestratorWriteToQueue) + +using namespace System.Net + +function DurableOrchestratorWriteToQueue { + [AzFunction()] + param( + [OrchestrationTrigger()] + $Context + ) + + Invoke-DurableActivity -FunctionName 'DurableActivityWritesToQueue' -Input 'QueueData' +} + +function DurableActivityWritesToQueue { + [AzFunction()] + param( + [ActivityTrigger()] + $name, + + [QueueOutput(QueueName = "outqueue", Connection = "AzureWebJobsStorage")] + $outputQueueItem + ) + + Write-Information "Pushing to outputQueueItem output binding" + Push-OutputBinding -Name outputQueueItem -Value $name + Write-Information "Done" + + "Hello $name!" +} diff --git a/test/E2E/TestFunctionAppV2/DurableTimers.psm1 b/test/E2E/TestFunctionAppV2/DurableTimers.psm1 new file mode 100644 index 00000000..a86789f7 --- /dev/null +++ b/test/E2E/TestFunctionAppV2/DurableTimers.psm1 @@ -0,0 +1,123 @@ +# Durable Timer Chains +# DurableTimerClient → DurableTimerOrchestrator +# CurrentUtcDateTimeClient → CurrentUtcDateTimeOrchestrator + +using namespace System.Net + +function DurableTimerClient { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = "Anonymous", Methods = "GET, POST")] + $Request, + + $TriggerMetadata, + + [DurableClient()] + $starter + ) + + Write-Host "DurableTimerClient started" + $ErrorActionPreference = 'Stop' + + $InstanceId = Start-DurableOrchestration -FunctionName 'DurableTimerOrchestrator' + Write-Host "Started orchestration with ID = '$InstanceId'" + + $Response = New-DurableOrchestrationCheckStatusResponse -Request $Request -InstanceId $InstanceId + Push-OutputBinding -Name Response -Value $Response + + Write-Host "DurableTimerClient completed" +} + +function DurableTimerOrchestrator { + [AzFunction()] + param( + [OrchestrationTrigger()] + $Context + ) + + $ErrorActionPreference = 'Stop' + Write-Host "DurableTimerOrchestrator: started." + + $tempFile = New-TemporaryFile + $tempDir = $tempFile.Directory.FullName + Remove-Item $tempFile + $fileName = "$("{0:MM_dd_yyyy_hh_mm_ss}" -f $Context.CurrentUtcDateTime)_timer_test.txt" + $path = Join-Path -Path $tempDir -ChildPath $fileName + + Add-Content -Value "---" -Path $path + Add-Content -Value $Context.CurrentUtcDateTime -Path $path + + Start-DurableTimer -Duration (New-TimeSpan -Seconds 5) + + Add-Content -Value $Context.CurrentUtcDateTime -Path $path + + Write-Host "DurableTimerOrchestrator: finished." + return (Get-Content $path) -join "`n" +} + +function CurrentUtcDateTimeClient { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = "Anonymous", Methods = "GET, POST")] + $Request, + + $TriggerMetadata, + + [DurableClient()] + $starter + ) + + Write-Host "CurrentUtcDateTimeClient started" + $ErrorActionPreference = 'Stop' + + $InstanceId = Start-DurableOrchestration -FunctionName 'CurrentUtcDateTimeOrchestrator' -InputObject 'Hello' + Write-Host "Started orchestration with ID = '$InstanceId'" + + $Response = New-DurableOrchestrationCheckStatusResponse -Request $Request -InstanceId $InstanceId + Push-OutputBinding -Name Response -Value $Response + + Write-Host "CurrentUtcDateTimeClient completed" +} + +function CurrentUtcDateTimeOrchestrator { + [AzFunction()] + param( + [OrchestrationTrigger()] + $Context + ) + + $ErrorActionPreference = 'Stop' + Write-Host "CurrentUtcDateTimeOrchestrator started. Input: $($Context.Input)" + + $activityResults = @() + + $tempFile = New-TemporaryFile + $tempDir = $tempFile.Directory.FullName + Remove-Item $tempFile + $fileName = "$("{0:MM_dd_yyyy_hh_mm_ss}" -f $Context.CurrentUtcDateTime)_datetime_test.txt" + $path = Join-Path -Path $tempDir -ChildPath $fileName + + Add-Content -Value '---' -Path $path + Add-Content -Value $Context.CurrentUtcDateTime -Path $path + Add-Content -Value $Context.CurrentUtcDateTime -Path $path + + $activityResults += Invoke-DurableActivity -FunctionName "DurableActivity" -Input "Tokyo" + Add-Content -Value $Context.CurrentUtcDateTime -Path $path + + Write-Host "About to start asynchronous calls." + + $tasks = @() + $tasks += Invoke-DurableActivity -FunctionName "DurableActivity" -Input "Seattle" -NoWait + Add-Content -Value $Context.CurrentUtcDateTime -Path $path + + $tasks += Invoke-DurableActivity -FunctionName "DurableActivity" -Input "London" -NoWait + Add-Content -Value $Context.CurrentUtcDateTime -Path $path + + Write-Host "Finished the asynchronous calls." + + $activityResults += Wait-DurableTask -Task $tasks + Add-Content -Value $Context.CurrentUtcDateTime -Path $path + + Write-Host "CurrentUtcDateTimeOrchestrator: finished." + return (Get-Content $path) -join "`n" +} diff --git a/test/E2E/TestFunctionAppV2/EventHubFunctions.psm1 b/test/E2E/TestFunctionAppV2/EventHubFunctions.psm1 new file mode 100644 index 00000000..f6358049 --- /dev/null +++ b/test/E2E/TestFunctionAppV2/EventHubFunctions.psm1 @@ -0,0 +1,61 @@ +# EventHub Functions +# Groups: EventHubTriggerAndOutputObject, EventHubTriggerAndOutputString, +# EventHubVerifyOutputObject, EventHubVerifyOutputString + +function EventHubTriggerAndOutputObject { + [AzFunction()] + param( + [EventHubTrigger(EventHubName = "test-input-object-ps", Connection = "EventHubConnection", Cardinality = "many")] + $eventHubMessages, + + [EventHubOutput(EventHubName = "test-output-object-ps", Connection = "EventHubConnection")] + $outEventHubMessage + ) + + Write-Host "PowerShell eventhub trigger function called for object message array $eventHubMessages" + $eventHubMessages | ForEach-Object { "Processed message $_, value: $($_.value)" } + Push-OutputBinding -Name outEventHubMessage -Value $eventHubMessages[0] +} + +function EventHubTriggerAndOutputString { + [AzFunction()] + param( + [EventHubTrigger(EventHubName = "test-input-string-ps", Connection = "EventHubConnection", Cardinality = "many")] + $eventHubMessages, + + [EventHubOutput(EventHubName = "test-output-string-ps", Connection = "EventHubConnection")] + $outEventHubMessage + ) + + Write-Host "PowerShell eventhub trigger function called for string message array $eventHubMessages" + $eventHubMessages | ForEach-Object { "Processed message $_, value: $($_.value)" } + Push-OutputBinding -Name outEventHubMessage -Value $eventHubMessages[0] +} + +function EventHubVerifyOutputObject { + [AzFunction()] + param( + [EventHubTrigger(EventHubName = "test-output-object-ps", Connection = "EventHubConnection", Cardinality = "one")] + $eventHubMessages, + + [QueueOutput(QueueName = "test-output-object-ps", Connection = "AzureWebJobsStorage")] + $outEventHubMessage + ) + + Write-Host "PowerShell EventHubVerifyOutputObject function called for message $eventHubMessages" + Push-OutputBinding -Name outEventHubMessage -Value $eventHubMessages +} + +function EventHubVerifyOutputString { + [AzFunction()] + param( + [EventHubTrigger(EventHubName = "test-output-string-ps", Connection = "EventHubConnection", Cardinality = "one")] + $eventHubMessages, + + [QueueOutput(QueueName = "test-output-string-ps", Connection = "AzureWebJobsStorage")] + $outEventHubMessage + ) + + Write-Host "PowerShell EventHubVerifyOutputString function called for message $eventHubMessages" + Push-OutputBinding -Name outEventHubMessage -Value $eventHubMessages +} diff --git a/test/E2E/TestFunctionAppV2/HttpFunctions.psm1 b/test/E2E/TestFunctionAppV2/HttpFunctions.psm1 new file mode 100644 index 00000000..71d12698 --- /dev/null +++ b/test/E2E/TestFunctionAppV2/HttpFunctions.psm1 @@ -0,0 +1,93 @@ +# HTTP Trigger Functions +# Groups: HttpTrigger, HttpTriggerThrows, HttpTriggerWithMetadata, UsingManagedDependencies + +function HttpTrigger { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = "Anonymous", Methods = "GET, POST")] + $req + ) + + Write-Verbose "PowerShell HTTP trigger function processed a request." -Verbose + + $name = $req.Query.Name + if (-not $name) { $name = $req.Body.Name } + + if ($name) { + $status = 200 + $body = "Hello " + $name + } + else { + $status = 400 + $body = "Please pass a name on the query string or in the request body." + } + + Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{ + StatusCode = $status + Body = $body + }) +} + +function HttpTriggerThrows { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = "Anonymous", Methods = "GET, POST")] + $req + ) + + Write-Host "PowerShell HTTP trigger function processed a request." + throw "Test Exception" +} + +function HttpTriggerWithMetadata { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = "Anonymous", Methods = "GET, POST")] + $req, + + $TriggerMetadata + ) + + Write-Verbose "PowerShell HTTP trigger function processed a request." -Verbose + + $name = $req.Query.Name + if (-not $name) { $name = $req.Body.Name } + + if ($name) { + $status = 200 + + $invocationId = $TriggerMetadata.InvocationId + $funcDirectory = $TriggerMetadata.FunctionDirectory + $funcName = $TriggerMetadata.FunctionName + + $FuncDirSameAsScriptRoot = $funcDirectory -eq $PSScriptRoot + $InvocationIdNullOrEmpty = [string]::IsNullOrEmpty($invocationId) + + $body = "{0} {1} {2}" -f $funcName, $FuncDirSameAsScriptRoot, $InvocationIdNullOrEmpty + } + else { + $status = 400 + $body = "Please pass a name on the query string or in the request body." + } + + Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{ + StatusCode = $status + Body = $body + }) +} + +function UsingManagedDependencies { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = "Anonymous", Methods = "GET, POST")] + $req + ) + + Write-Verbose "PowerShell HTTP trigger function processed a request." -Verbose + + Import-Module Az.Accounts + + Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{ + Body = Get-Module Az.Accounts | ForEach-Object Path + }) +} diff --git a/test/E2E/TestFunctionAppV2/StorageFunctions.psm1 b/test/E2E/TestFunctionAppV2/StorageFunctions.psm1 new file mode 100644 index 00000000..2dfba75a --- /dev/null +++ b/test/E2E/TestFunctionAppV2/StorageFunctions.psm1 @@ -0,0 +1,16 @@ +# Storage Functions +# Groups: QueueTriggerAndOutput + +function QueueTriggerAndOutput { + [AzFunction()] + param( + [QueueTrigger(QueueName = "test-input-ps", Connection = "AzureWebJobsStorage")] + $myQueueItem, + + [QueueOutput(QueueName = "test-output-ps", Connection = "AzureWebJobsStorage")] + $outQueueItem + ) + + Write-Host "PowerShell queue trigger function processed work item $myQueueItem" + Push-OutputBinding -Name outQueueItem -Value $myQueueItem +} diff --git a/test/E2E/TestFunctionAppV2/host.json b/test/E2E/TestFunctionAppV2/host.json new file mode 100644 index 00000000..5d33f749 --- /dev/null +++ b/test/E2E/TestFunctionAppV2/host.json @@ -0,0 +1,18 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + }, + "managedDependency": { + "enabled": true + } +} \ No newline at end of file diff --git a/test/E2E/TestFunctionAppV2/local.settings.json b/test/E2E/TestFunctionAppV2/local.settings.json new file mode 100644 index 00000000..c826aa40 --- /dev/null +++ b/test/E2E/TestFunctionAppV2/local.settings.json @@ -0,0 +1,11 @@ +{ + "IsEncrypted": false, + "Values": { + "EventHubConnection__fullyQualifiedNamespace": "", + "CosmosDBConnection__accountEndpoint": "", + "FUNCTIONS_WORKER_RUNTIME": "powershell", + "FUNCTIONS_WORKER_RUNTIME_VERSION": "7.6", + "AzureWebJobsStorage": "", + "ExternalDurablePowerShellSDK": "true" + } +} \ No newline at end of file diff --git a/test/E2E/TestFunctionAppV2/profile.ps1 b/test/E2E/TestFunctionAppV2/profile.ps1 new file mode 100644 index 00000000..3954cf78 --- /dev/null +++ b/test/E2E/TestFunctionAppV2/profile.ps1 @@ -0,0 +1,4 @@ +# V2 Programming Model - Test Function App +# Profile runs once per PowerShell instance. + +Import-Module -Name AzureFunctions.PowerShell.Durable.SDK -ErrorAction Stop diff --git a/test/E2E/TestFunctionAppV2/requirements.psd1 b/test/E2E/TestFunctionAppV2/requirements.psd1 new file mode 100644 index 00000000..eb8b5ef7 --- /dev/null +++ b/test/E2E/TestFunctionAppV2/requirements.psd1 @@ -0,0 +1,4 @@ +@{ + 'Az.Accounts' = '5.*' + 'AzureFunctions.PowerShell.Durable.SDK' = '2.*' +} diff --git a/test/Unit/PowerShell/PowerShellManagerTests.cs b/test/Unit/PowerShell/PowerShellManagerTests.cs index 83cb483f..1608f221 100644 --- a/test/Unit/PowerShell/PowerShellManagerTests.cs +++ b/test/Unit/PowerShell/PowerShellManagerTests.cs @@ -490,6 +490,30 @@ private static Hashtable InvokeFunction( return powerShellManager.InvokeFunction(functionInfo, triggerMetadata, null, retryContext, s_testInputData, new FunctionInvocationPerformanceStopwatch(), null); } + [Fact] + public void PipelineLeakWarningLoggedForMultiplePipelineOutputs() + { + string path = Path.Join(s_funcDirectory, "testPipelineLeak.ps1"); + var (functionInfo, testManager) = PrepareFunction(path, string.Empty); + + try + { + FunctionMetadata.RegisterFunctionMetadata(testManager.InstanceId, functionInfo.OutputBindings); + s_testLogger.FullLog.Clear(); + + Hashtable result = InvokeFunction(testManager, functionInfo); + + // Should have a warning about pipeline output leak + Assert.Contains(s_testLogger.FullLog, + log => log.Contains("Warning", StringComparison.OrdinalIgnoreCase) + && log.Contains("pipeline output", StringComparison.OrdinalIgnoreCase)); + } + finally + { + FunctionMetadata.UnregisterFunctionMetadata(testManager.InstanceId); + } + } + private class ContextValidatingLogger : ILogger { private bool _isContextSet = false; diff --git a/test/Unit/PowerShell/TestScripts/testPipelineLeak.ps1 b/test/Unit/PowerShell/TestScripts/testPipelineLeak.ps1 new file mode 100644 index 00000000..6b862b8b --- /dev/null +++ b/test/Unit/PowerShell/TestScripts/testPipelineLeak.ps1 @@ -0,0 +1,8 @@ +param ($Req) + +# Produce multiple pipeline objects — this is a pipeline leak +"first item" +"second item" +"third item" + +Push-OutputBinding -Name res -Value "done" diff --git a/test/Unit/WorkerIndexing/FunctionIndexerTests.cs b/test/Unit/WorkerIndexing/FunctionIndexerTests.cs new file mode 100644 index 00000000..845325af --- /dev/null +++ b/test/Unit/WorkerIndexing/FunctionIndexerTests.cs @@ -0,0 +1,594 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.IO; +using System.Linq; +using Xunit; + +using Microsoft.Azure.Functions.PowerShellWorker.WorkerIndexing; + +namespace Microsoft.Azure.Functions.PowerShellWorker.Test.WorkerIndexing +{ + public class FunctionIndexerTests + { + private static readonly string TestScriptsDir = Path.GetFullPath( + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "WorkerIndexing", "TestScripts")); + + #region HttpTrigger + + [Fact] + public void IndexesHttpTriggerFunction() + { + var results = FunctionIndexer.IndexFunctionsInFile( + Path.Combine(TestScriptsDir, "HttpTriggerFunction.psm1"), TestScriptsDir).ToList(); + + Assert.Single(results); + var func = results[0]; + Assert.Equal("HttpExample", func.Name); + Assert.Equal("HttpExample", func.EntryPoint); + Assert.Equal("powershell", func.Language); + Assert.True(func.Bindings.ContainsKey("Request")); + + var triggerBinding = func.Bindings["Request"]; + Assert.Equal("httpTrigger", triggerBinding.Type); + Assert.Equal(Microsoft.Azure.WebJobs.Script.Grpc.Messages.BindingInfo.Types.Direction.In, triggerBinding.Direction); + + // Should have implicit Response output binding for HTTP + Assert.True(func.Bindings.ContainsKey("Response")); + Assert.Equal("http", func.Bindings["Response"].Type); + Assert.Equal(Microsoft.Azure.WebJobs.Script.Grpc.Messages.BindingInfo.Types.Direction.Out, func.Bindings["Response"].Direction); + + // Verify raw bindings contain authLevel and methods + Assert.True(func.RawBindings.Any(r => r.Contains("authLevel", StringComparison.OrdinalIgnoreCase))); + Assert.True(func.RawBindings.Any(r => r.Contains("hello", StringComparison.OrdinalIgnoreCase))); + Assert.True(func.RawBindings.Any(r => r.Contains("methods", StringComparison.OrdinalIgnoreCase))); + } + + [Fact] + public void HttpTriggerRawBindingHasCorrectJsonTypes() + { + // Validates that authLevel is a JSON string and methods is always a JSON array, + // which is what the Functions host expects when parsing the raw binding. + var results = FunctionIndexer.IndexFunctionsInFile( + Path.Combine(TestScriptsDir, "HttpTriggerFunction.psm1"), TestScriptsDir).ToList(); + + Assert.Single(results); + var func = results[0]; + + // Find the httpTrigger raw binding (the one with "httpTrigger") + var triggerRaw = func.RawBindings.First(r => r.Contains("httpTrigger")); + + // authLevel must be a JSON string value: "authLevel":"anonymous" (not an array) + Assert.Contains("\"authLevel\":\"", triggerRaw); + Assert.DoesNotContain("\"authLevel\":[", triggerRaw); + + // methods must be a JSON array: "methods":["GET","POST"] (even for single values) + Assert.Contains("\"methods\":[", triggerRaw); + Assert.DoesNotContain("\"methods\":\"", triggerRaw); + + // route must be a JSON string value + Assert.Contains("\"route\":\"", triggerRaw); + Assert.DoesNotContain("\"route\":[", triggerRaw); + } + + #endregion + + #region TimerTrigger + + [Fact] + public void IndexesTimerTriggerWithNameOverride() + { + var results = FunctionIndexer.IndexFunctionsInFile( + Path.Combine(TestScriptsDir, "TimerTriggerFunction.psm1"), TestScriptsDir).ToList(); + + Assert.Single(results); + var func = results[0]; + Assert.Equal("MyTimerFunc", func.Name); + Assert.Equal("TimerJob", func.EntryPoint); + Assert.True(func.Bindings.ContainsKey("Timer")); + Assert.Equal("timerTrigger", func.Bindings["Timer"].Type); + + Assert.True(func.RawBindings.Any(r => r.Contains("schedule", StringComparison.OrdinalIgnoreCase) && r.Contains("0 */5 * * * *"))); + } + + #endregion + + #region QueueTrigger + Output + + [Fact] + public void IndexesQueueTriggerWithOutputBinding() + { + var results = FunctionIndexer.IndexFunctionsInFile( + Path.Combine(TestScriptsDir, "QueueTriggerWithOutput.psm1"), TestScriptsDir).ToList(); + + Assert.Single(results); + var func = results[0]; + Assert.Equal("ProcessQueue", func.Name); + + Assert.True(func.Bindings.ContainsKey("QueueItem")); + Assert.Equal("queueTrigger", func.Bindings["QueueItem"].Type); + + Assert.True(func.Bindings.ContainsKey("OutputQueue")); + Assert.Equal("queue", func.Bindings["OutputQueue"].Type); + Assert.Equal(Microsoft.Azure.WebJobs.Script.Grpc.Messages.BindingInfo.Types.Direction.Out, + func.Bindings["OutputQueue"].Direction); + + // Verify connection string is in raw bindings + Assert.True(func.RawBindings.Any(r => r.Contains("StorageConn", StringComparison.OrdinalIgnoreCase))); + } + + #endregion + + #region Multiple Functions In One File + + [Fact] + public void IndexesMultipleFunctionsFromOneFile() + { + var results = FunctionIndexer.IndexFunctionsInFile( + Path.Combine(TestScriptsDir, "MultipleFunctions.psm1"), TestScriptsDir).ToList(); + + Assert.Equal(2, results.Count); + Assert.Contains(results, f => f.Name == "MultipleFunctions_First"); + Assert.Contains(results, f => f.Name == "MultipleFunctions_Second"); + } + + #endregion + + #region No AzFunction Attribute + + [Fact] + public void SkipsFunctionsWithoutAzFunctionAttribute() + { + var results = FunctionIndexer.IndexFunctionsInFile( + Path.Combine(TestScriptsDir, "NoAzFunctionAttribute.ps1"), TestScriptsDir).ToList(); + + Assert.Empty(results); + } + + #endregion + + #region Generic Trigger + + [Fact] + public void IndexesGenericTriggerWithProperties() + { + var results = FunctionIndexer.IndexFunctionsInFile( + Path.Combine(TestScriptsDir, "GenericTriggerFunction.psm1"), TestScriptsDir).ToList(); + + Assert.Single(results); + var func = results[0]; + Assert.Equal("GenericExample", func.Name); + Assert.Equal("kafkaTrigger", func.Bindings["Message"].Type); + + // Generic Properties should be flattened into raw binding + Assert.True(func.RawBindings.Any(r => r.Contains("brokerList", StringComparison.OrdinalIgnoreCase))); + Assert.True(func.RawBindings.Any(r => r.Contains("myTopic", StringComparison.OrdinalIgnoreCase))); + Assert.True(func.RawBindings.Any(r => r.Contains("KafkaConn", StringComparison.OrdinalIgnoreCase))); + } + + #endregion + + #region Durable Functions + + [Fact] + public void IndexesDurableFunctions() + { + var results = FunctionIndexer.IndexFunctionsInFile( + Path.Combine(TestScriptsDir, "DurableFunctions.psm1"), TestScriptsDir).ToList(); + + Assert.Equal(3, results.Count); + + var orch = results.First(f => f.Name == "DurableOrchExample"); + Assert.Equal("orchestrationTrigger", orch.Bindings["Context"].Type); + + var activity = results.First(f => f.Name == "DurableActivityExample"); + Assert.Equal("activityTrigger", activity.Bindings["name"].Type); + + var client = results.First(f => f.Name == "DurableClientExample"); + Assert.True(client.Bindings.ContainsKey("Request")); + Assert.Equal("httpTrigger", client.Bindings["Request"].Type); + Assert.True(client.Bindings.ContainsKey("starter")); + Assert.Equal("durableClient", client.Bindings["starter"].Type); + Assert.Equal(Microsoft.Azure.WebJobs.Script.Grpc.Messages.BindingInfo.Types.Direction.In, + client.Bindings["starter"].Direction); + } + + #endregion + + #region Validation: Multiple Triggers + + [Fact] + public void ThrowsForMultipleTriggerBindings() + { + var ex = Assert.Throws(() => + FunctionIndexer.IndexFunctionsInFile( + Path.Combine(TestScriptsDir, "MultipleTriggers.psm1"), TestScriptsDir).ToList()); + + Assert.Contains("multiple trigger", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("BadFunction", ex.Message); + } + + #endregion + + #region Validation: No Trigger + + [Fact] + public void ThrowsForNoTriggerBinding() + { + var ex = Assert.Throws(() => + FunctionIndexer.IndexFunctionsInFile( + Path.Combine(TestScriptsDir, "NoTrigger.psm1"), TestScriptsDir).ToList()); + + Assert.Contains("no trigger", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("NoTriggerFunc", ex.Message); + } + + #endregion + + #region Validation: Missing Required Property + + [Fact] + public void ThrowsForMissingRequiredProperty() + { + var ex = Assert.Throws(() => + FunctionIndexer.IndexFunctionsInFile( + Path.Combine(TestScriptsDir, "MissingRequiredProperty.psm1"), TestScriptsDir).ToList()); + + Assert.Contains("Schedule", ex.Message); + Assert.Contains("MissingSchedule", ex.Message); + Assert.Contains("timerTrigger", ex.Message); + } + + [Fact] + public void ThrowsForMissingMultipleRequiredProperties() + { + var ex = Assert.Throws(() => + FunctionIndexer.IndexFunctionsInFile( + Path.Combine(TestScriptsDir, "MissingMultipleRequiredProperties.psm1"), TestScriptsDir).ToList()); + + // Should mention at least one of the missing required properties + Assert.Contains("MissingCosmosProps", ex.Message); + Assert.True( + ex.Message.Contains("DatabaseName") || ex.Message.Contains("ContainerName"), + $"Expected mention of DatabaseName or ContainerName in: {ex.Message}"); + } + + #endregion + + #region Validation: Duplicate Binding Name + + [Fact] + public void ThrowsForDuplicateBindingName() + { + var ex = Assert.Throws(() => + FunctionIndexer.IndexFunctionsInFile( + Path.Combine(TestScriptsDir, "DuplicateBindingName.psm1"), TestScriptsDir).ToList()); + + Assert.Contains("duplicate", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Request", ex.Message); + } + + #endregion + + #region Validation: Errors Handled Gracefully by IndexFunctions + + [Fact] + public void IndexFunctionsSkipsInvalidFunctionsAndContinues() + { + // When IndexFunctions (with logger) encounters a validation error, + // it should skip that file and continue indexing valid files. + var results = FunctionIndexer.IndexFunctions(TestScriptsDir, logger: null).ToList(); + + // Valid functions should still be found (HttpExample, MyTimerFunc, etc.) + Assert.True(results.Count >= 5, $"Expected at least 5 valid functions, found {results.Count}"); + + // None of the invalid functions should be in the results + Assert.DoesNotContain(results, f => f.Name == "BadFunction"); + Assert.DoesNotContain(results, f => f.Name == "NoTriggerFunc"); + Assert.DoesNotContain(results, f => f.Name == "MissingSchedule"); + } + + #endregion + + #region Explicit Output Overrides Implicit + + [Fact] + public void ExplicitHttpOutputOverridesImplicitResponse() + { + // An [HttpTrigger] normally adds an implicit "Response" output. + // If the user explicitly adds [HttpOutput()] $Response, the explicit one should win. + var scriptContent = @" +function ExplicitResponseFunc { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = 'Anonymous')] + $Request, + + [HttpOutput()] + $Response + ) + Push-OutputBinding -Name Response -Value 'test' +} +"; + var tempFile = Path.Combine(Path.GetTempPath(), "ExplicitHttpOutput_" + Guid.NewGuid() + ".psm1"); + try + { + File.WriteAllText(tempFile, scriptContent); + var results = FunctionIndexer.IndexFunctionsInFile(tempFile, Path.GetTempPath()).ToList(); + + Assert.Single(results); + var func = results[0]; + + // Should have both Request (trigger) and Response (explicit output) — no duplicate error + Assert.True(func.Bindings.ContainsKey("Request")); + Assert.True(func.Bindings.ContainsKey("Response")); + Assert.Equal("http", func.Bindings["Response"].Type); + Assert.Equal(Microsoft.Azure.WebJobs.Script.Grpc.Messages.BindingInfo.Types.Direction.Out, + func.Bindings["Response"].Direction); + } + finally + { + File.Delete(tempFile); + } + } + + #endregion + + #region Directory Scanning + + [Fact] + public void IndexFunctionsScansDirectoryRecursively() + { + // IndexFunctions on the TestScripts directory should find all functions across all files + var results = FunctionIndexer.IndexFunctions(TestScriptsDir, logger: null).ToList(); + + // At minimum: HttpExample, MyTimerFunc, ProcessQueue, MultipleFunctions_First, + // MultipleFunctions_Second, GenericExample, DurableOrchExample, DurableActivityExample, DurableClientExample + // (invalid test scripts like MultipleTriggers, NoTrigger, MissingRequiredProperty, etc. are skipped) + Assert.True(results.Count >= 9, $"Expected at least 9 functions, found {results.Count}"); + + // Verify no invalid functions leaked through + Assert.DoesNotContain(results, f => f.Name == "BadFunction"); + Assert.DoesNotContain(results, f => f.Name == "NoTriggerFunc"); + Assert.DoesNotContain(results, f => f.Name == "MissingSchedule"); + Assert.DoesNotContain(results, f => f.Name == "MissingCosmosProps"); + Assert.DoesNotContain(results, f => f.Name == "DuplicateBindingFunc"); + + // Verify no duplicates + var names = results.Select(f => f.Name).ToList(); + Assert.Equal(names.Count, names.Distinct().Count()); + } + + [Fact] + public void ParseErrorsAreHandledGracefully() + { + // IndexFunctions should skip files with parse errors and continue + var results = FunctionIndexer.IndexFunctions(TestScriptsDir, logger: null).ToList(); + + // Should still have results from valid files + Assert.True(results.Count > 0); + // ParseError.ps1 should not contribute any functions + Assert.DoesNotContain(results, f => f.Name == "Broken"); + } + + #endregion + + #region FuncIgnore + + [Fact] + public void GlobToRegexMatchesWildcardPatterns() + { + // * matches any chars except path separator + var pattern = FunctionIndexer.GlobToRegex("*.test.ps1"); + var regex = new System.Text.RegularExpressions.Regex(pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + Assert.True(regex.IsMatch("MyFunc.test.ps1")); + Assert.True(regex.IsMatch("x.test.ps1")); + Assert.False(regex.IsMatch("MyFunc.ps1")); + Assert.False(regex.IsMatch("sub/MyFunc.test.ps1")); // * doesn't match / + } + + [Fact] + public void GlobToRegexMatchesDoubleStarPatterns() + { + var pattern = FunctionIndexer.GlobToRegex("**/test"); + var regex = new System.Text.RegularExpressions.Regex(pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + Assert.True(regex.IsMatch("sub/test")); + Assert.True(regex.IsMatch("a/b/test")); + Assert.True(regex.IsMatch("test")); + } + + [Fact] + public void GlobToRegexMatchesDotGitStar() + { + var pattern = FunctionIndexer.GlobToRegex(".git*"); + var regex = new System.Text.RegularExpressions.Regex(pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + Assert.True(regex.IsMatch(".git")); + Assert.True(regex.IsMatch(".gitignore")); + Assert.True(regex.IsMatch(".github")); + Assert.False(regex.IsMatch("notgit")); + } + + [Fact] + public void LoadFuncIgnoreParsesFile() + { + var tempDir = Path.Combine(Path.GetTempPath(), "funcignore_test_" + Guid.NewGuid()); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, ".funcignore"), + "# Comment line\n\n*.test.ps1\nlocal.settings.json\n.git*\n"); + + var patterns = FunctionIndexer.LoadFuncIgnorePatterns(tempDir); + + Assert.Equal(3, patterns.Count); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void LoadFuncIgnoreReturnsEmptyWhenNoFile() + { + var tempDir = Path.Combine(Path.GetTempPath(), "funcignore_empty_" + Guid.NewGuid()); + Directory.CreateDirectory(tempDir); + try + { + var patterns = FunctionIndexer.LoadFuncIgnorePatterns(tempDir); + Assert.Empty(patterns); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void IndexFunctionsRespectsExcludedDirectoriesAndFuncIgnore() + { + var tempDir = Path.Combine(Path.GetTempPath(), "funcignore_scan_" + Guid.NewGuid()); + Directory.CreateDirectory(tempDir); + try + { + var funcContent = @" +function IncludedFunc { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = 'Anonymous')] + $Request + ) + Push-OutputBinding -Name Response -Value 'ok' +} +"; + var excludedContent = @" +function ExcludedFunc { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = 'Anonymous')] + $Request + ) + Push-OutputBinding -Name Response -Value 'excluded' +} +"; + + // Write a .funcignore that excludes the 'ignored' directory + File.WriteAllText(Path.Combine(tempDir, ".funcignore"), "ignored\n"); + + // Create included file at root + File.WriteAllText(Path.Combine(tempDir, "Functions.psm1"), funcContent); + + // Create excluded file in 'ignored' directory + var ignoredDir = Path.Combine(tempDir, "ignored"); + Directory.CreateDirectory(ignoredDir); + File.WriteAllText(Path.Combine(ignoredDir, "Excluded.psm1"), excludedContent); + + var results = FunctionIndexer.IndexFunctions(tempDir, logger: null).ToList(); + + Assert.Single(results); + Assert.Equal("IncludedFunc", results[0].Name); + Assert.DoesNotContain(results, f => f.Name == "ExcludedFunc"); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void IndexFunctionsExcludesFilesByFuncIgnorePattern() + { + var tempDir = Path.Combine(Path.GetTempPath(), "funcignore_files_" + Guid.NewGuid()); + Directory.CreateDirectory(tempDir); + try + { + var funcContent = @" +function IncludedFunc { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = 'Anonymous')] + $Request + ) + Push-OutputBinding -Name Response -Value 'ok' +} +"; + var testContent = @" +function TestFunc { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = 'Anonymous')] + $Request + ) + Push-OutputBinding -Name Response -Value 'test' +} +"; + + // .funcignore excludes test files + File.WriteAllText(Path.Combine(tempDir, ".funcignore"), "*.test.psm1\n"); + + File.WriteAllText(Path.Combine(tempDir, "Functions.psm1"), funcContent); + File.WriteAllText(Path.Combine(tempDir, "Functions.test.psm1"), testContent); + + var results = FunctionIndexer.IndexFunctions(tempDir, logger: null).ToList(); + + Assert.Single(results); + Assert.Equal("IncludedFunc", results[0].Name); + Assert.DoesNotContain(results, f => f.Name == "TestFunc"); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + #endregion + + #region E2E App Dump + + [Fact] + public void DumpV2E2EAppIndexedMetadata() + { + // BaseDirectory is test/Unit/bin/Debug/net10.0/ + // Go up 3 to test/Unit/, then up 1 to test/, then into E2E/TestFunctionAppV2 + var e2eAppDir = Path.GetFullPath( + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "E2E", "TestFunctionAppV2")); + + Assert.True(Directory.Exists(e2eAppDir), $"E2E app dir not found: {e2eAppDir}"); + + var results = FunctionIndexer.IndexFunctions(e2eAppDir, logger: null).ToList(); + + // Emit as a JSON array for easy inspection + var sb = new System.Text.StringBuilder(); + sb.AppendLine("["); + for (int i = 0; i < results.Count; i++) + { + var func = results.OrderBy(f => f.Name).ElementAt(i); + sb.AppendLine(" {"); + sb.AppendLine($" \"name\": \"{func.Name}\","); + sb.AppendLine($" \"entryPoint\": \"{func.EntryPoint}\","); + sb.AppendLine($" \"scriptFile\": \"{Path.GetFileName(func.ScriptFile)}\","); + sb.AppendLine($" \"bindings\": {{"); + var bindingPairs = func.Bindings.Select(b => $" \"{b.Key}\": {{ \"type\": \"{b.Value.Type}\", \"direction\": \"{b.Value.Direction}\" }}"); + sb.AppendLine(string.Join(",\n", bindingPairs)); + sb.AppendLine(" },"); + sb.AppendLine($" \"rawBindings\": ["); + var rawItems = func.RawBindings.Select(rb => $" {rb}"); + sb.AppendLine(string.Join(",\n", rawItems)); + sb.AppendLine(" ]"); + sb.Append(i < results.Count - 1 ? " },\n" : " }\n"); + } + sb.AppendLine("]"); + Console.WriteLine(sb.ToString()); + + Assert.True(results.Count > 0, "No functions indexed"); + } + + #endregion + } +} diff --git a/test/Unit/WorkerIndexing/TestScripts/DuplicateBindingName.psm1 b/test/Unit/WorkerIndexing/TestScripts/DuplicateBindingName.psm1 new file mode 100644 index 00000000..db8a63c2 --- /dev/null +++ b/test/Unit/WorkerIndexing/TestScripts/DuplicateBindingName.psm1 @@ -0,0 +1,9 @@ +function DuplicateBindingFunc { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = "Anonymous")] + [QueueOutput(QueueName = "myqueue")] + $Request + ) + Write-Host "Two binding attributes on one parameter = duplicate binding name" +} diff --git a/test/Unit/WorkerIndexing/TestScripts/DurableFunctions.psm1 b/test/Unit/WorkerIndexing/TestScripts/DurableFunctions.psm1 new file mode 100644 index 00000000..080b372b --- /dev/null +++ b/test/Unit/WorkerIndexing/TestScripts/DurableFunctions.psm1 @@ -0,0 +1,31 @@ +function DurableOrchExample { + [AzFunction()] + param( + [OrchestrationTrigger()] + $Context + ) + $output = Invoke-DurableActivity -FunctionName "MyActivity" -Input "test" + return $output +} + +function DurableActivityExample { + [AzFunction()] + param( + [ActivityTrigger()] + $name + ) + "Hello $name" +} + +function DurableClientExample { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = "Anonymous", Methods = "POST", Route = "start")] + $Request, + + [DurableClient()] + $starter + ) + $InstanceId = Start-DurableOrchestration -FunctionName "DurableOrchExample" + Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{ Body = $InstanceId }) +} diff --git a/test/Unit/WorkerIndexing/TestScripts/GenericTriggerFunction.psm1 b/test/Unit/WorkerIndexing/TestScripts/GenericTriggerFunction.psm1 new file mode 100644 index 00000000..e4f5676d --- /dev/null +++ b/test/Unit/WorkerIndexing/TestScripts/GenericTriggerFunction.psm1 @@ -0,0 +1,8 @@ +function GenericExample { + [AzFunction()] + param( + [GenericTrigger(Type = "kafkaTrigger", Connection = "KafkaConn", Properties = "brokerList=myBroker; topic=myTopic")] + $Message + ) + Write-Host "Kafka message: $Message" +} diff --git a/test/Unit/WorkerIndexing/TestScripts/HttpTriggerFunction.psm1 b/test/Unit/WorkerIndexing/TestScripts/HttpTriggerFunction.psm1 new file mode 100644 index 00000000..551efe9f --- /dev/null +++ b/test/Unit/WorkerIndexing/TestScripts/HttpTriggerFunction.psm1 @@ -0,0 +1,11 @@ +function HttpExample { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = "Anonymous", Methods = "GET, POST", Route = "hello")] + $Request + ) + Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{ + StatusCode = 200 + Body = "Hello" + }) +} diff --git a/test/Unit/WorkerIndexing/TestScripts/MissingMultipleRequiredProperties.psm1 b/test/Unit/WorkerIndexing/TestScripts/MissingMultipleRequiredProperties.psm1 new file mode 100644 index 00000000..0bbc54e1 --- /dev/null +++ b/test/Unit/WorkerIndexing/TestScripts/MissingMultipleRequiredProperties.psm1 @@ -0,0 +1,8 @@ +function MissingCosmosProps { + [AzFunction()] + param( + [CosmosDBTrigger(Connection = "CosmosConn")] + $Changes + ) + Write-Host "This should fail - CosmosDBTrigger requires DatabaseName and ContainerName" +} diff --git a/test/Unit/WorkerIndexing/TestScripts/MissingRequiredProperty.psm1 b/test/Unit/WorkerIndexing/TestScripts/MissingRequiredProperty.psm1 new file mode 100644 index 00000000..0b66998d --- /dev/null +++ b/test/Unit/WorkerIndexing/TestScripts/MissingRequiredProperty.psm1 @@ -0,0 +1,8 @@ +function MissingSchedule { + [AzFunction()] + param( + [TimerTrigger()] + $Timer + ) + Write-Host "This should fail - TimerTrigger requires Schedule" +} diff --git a/test/Unit/WorkerIndexing/TestScripts/MultipleFunctions.psm1 b/test/Unit/WorkerIndexing/TestScripts/MultipleFunctions.psm1 new file mode 100644 index 00000000..091eea45 --- /dev/null +++ b/test/Unit/WorkerIndexing/TestScripts/MultipleFunctions.psm1 @@ -0,0 +1,17 @@ +function MultipleFunctions_First { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = "Function", Methods = "GET", Route = "first")] + $Request + ) + "Hello from First" +} + +function MultipleFunctions_Second { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = "Anonymous", Methods = "POST", Route = "second")] + $Request + ) + "Hello from Second" +} diff --git a/test/Unit/WorkerIndexing/TestScripts/MultipleTriggers.psm1 b/test/Unit/WorkerIndexing/TestScripts/MultipleTriggers.psm1 new file mode 100644 index 00000000..31b2ebad --- /dev/null +++ b/test/Unit/WorkerIndexing/TestScripts/MultipleTriggers.psm1 @@ -0,0 +1,11 @@ +function BadFunction { + [AzFunction()] + param( + [HttpTrigger(AuthLevel = "Anonymous")] + $Request, + + [TimerTrigger(Schedule = "0 */5 * * * *")] + $Timer + ) + Write-Host "This should fail validation" +} diff --git a/test/Unit/WorkerIndexing/TestScripts/NoAzFunctionAttribute.ps1 b/test/Unit/WorkerIndexing/TestScripts/NoAzFunctionAttribute.ps1 new file mode 100644 index 00000000..27700f79 --- /dev/null +++ b/test/Unit/WorkerIndexing/TestScripts/NoAzFunctionAttribute.ps1 @@ -0,0 +1,5 @@ +# This function does NOT have [AzFunction()] and should be skipped by the indexer +function HelperFunction { + param($value) + return $value * 2 +} diff --git a/test/Unit/WorkerIndexing/TestScripts/NoTrigger.psm1 b/test/Unit/WorkerIndexing/TestScripts/NoTrigger.psm1 new file mode 100644 index 00000000..20348f28 --- /dev/null +++ b/test/Unit/WorkerIndexing/TestScripts/NoTrigger.psm1 @@ -0,0 +1,8 @@ +function NoTriggerFunc { + [AzFunction()] + param( + [QueueOutput(QueueName = "myqueue")] + $OutputQueue + ) + Write-Host "This should fail validation - no trigger" +} diff --git a/test/Unit/WorkerIndexing/TestScripts/ParseError.ps1 b/test/Unit/WorkerIndexing/TestScripts/ParseError.ps1 new file mode 100644 index 00000000..396996ee --- /dev/null +++ b/test/Unit/WorkerIndexing/TestScripts/ParseError.ps1 @@ -0,0 +1,9 @@ +# This file has a parse error - unterminated string +function Broken { + [AzFunction()] + param( + [HttpTrigger()] + $Request + ) + "Hello +} diff --git a/test/Unit/WorkerIndexing/TestScripts/QueueTriggerWithOutput.psm1 b/test/Unit/WorkerIndexing/TestScripts/QueueTriggerWithOutput.psm1 new file mode 100644 index 00000000..c3adba31 --- /dev/null +++ b/test/Unit/WorkerIndexing/TestScripts/QueueTriggerWithOutput.psm1 @@ -0,0 +1,11 @@ +function ProcessQueue { + [AzFunction()] + param( + [QueueTrigger(QueueName = "myqueue", Connection = "StorageConn")] + $QueueItem, + + [QueueOutput(QueueName = "outqueue", Connection = "StorageConn")] + $OutputQueue + ) + Push-OutputBinding -Name OutputQueue -Value $QueueItem +} diff --git a/test/Unit/WorkerIndexing/TestScripts/TimerTriggerFunction.psm1 b/test/Unit/WorkerIndexing/TestScripts/TimerTriggerFunction.psm1 new file mode 100644 index 00000000..e8a79674 --- /dev/null +++ b/test/Unit/WorkerIndexing/TestScripts/TimerTriggerFunction.psm1 @@ -0,0 +1,8 @@ +function TimerJob { + [AzFunction(Name = "MyTimerFunc")] + param( + [TimerTrigger(Schedule = "0 */5 * * * *")] + $Timer + ) + Write-Host "Timer triggered" +}