diff --git a/preview-features/on-demand-sandboxes/README.md b/preview-features/on-demand-sandboxes/README.md new file mode 100644 index 00000000..1fe1630a --- /dev/null +++ b/preview-features/on-demand-sandboxes/README.md @@ -0,0 +1,245 @@ +# On-demand Sandboxes for Azure Durable Task Scheduler + +> **Status:** Private preview + +## Get private preview access + +To gain access to the private preview, email [dts-team@microsoft.com](mailto:dts-team@microsoft.com). + +## Overview + +A *sandbox* is an isolated, microVM-backed container that runs a single piece of your +workflow with its own runtime, dependencies, and security boundary—separate from your +orchestrator's process. + +On-demand Sandboxes let you move individual workflow steps (activities) out of your +orchestrator process and into managed, isolated compute, while your orchestrator stays +exactly where it is. You tell Durable Task Scheduler (DTS) which activities should run +in isolation and provide a container image with that activity code; DTS handles +provisioning, scaling, and teardown. + +Most activities belong in-process: they're fast, simple, and co-located with your +orchestrator. But some steps don't fit that model—they need a native binary, a +different language runtime, per-invocation isolation, or bursty compute you don't want +to keep warm. On-demand Sandboxes handle those exceptions without dedicated +infrastructure or custom scaling policies. + +## Why it's valuable + +- **Activity-level granularity.** Move individual steps to managed compute, not your + whole app. +- **Per-activity or per-invocation isolation.** Each execution runs in a clean, + microVM-backed sandbox—ideal for untrusted code, customer plugins, or LLM-generated + logic. +- **Cross-runtime flexibility.** Run a Python inference step from a .NET orchestrator, + with no compromise on either side. +- **Scale-to-zero.** Pay for CPU and memory per second of execution, not for + infrastructure that sits idle. +- **No orchestrator changes.** Your orchestration code and hosting model don't change + at all. + +## Prerequisites + +Before you begin, make sure you have: + +- **Private preview access.** On-demand Sandboxes is in private preview. + [Sign up here](https://techcommunity.microsoft.com/blog/AppsonAzureBlog/introducing-on-demand-sandboxes-for-azure-durable-task-scheduler-private-preview/4522333) + to have the feature enabled on your scheduler. +- **An app using a supported standalone Durable Task SDK.** On-demand Sandboxes target + the standalone Durable Task SDKs used *outside* the Azure Functions host—apps running + on Azure Container Apps, Azure Kubernetes Service, App Service, or anywhere else you + self-host. The private preview supports the **.NET** and **Python** SDKs; additional + language SDKs and Azure Functions support are coming soon. +- **A provisioned Durable Task Scheduler** configured as the durable backend for your app, + in one of the supported preview regions. +- **A container registry** (for example, Azure Container Registry) where you can push + the worker image that contains your sandboxed activity code. +- **User-assigned managed identities** that DTS uses to pull your worker image from your + registry and start the sandbox. The scheduler must have the identity attached, and the + image-pull identity needs the **AcrPull** role on your registry. You provide the client + IDs on the worker profile (the image-pull identity via `Image.ManagedIdentityClientId` / + `image.managed_identity_client_id`, and the worker/scheduler identity via + `SchedulerManagedIdentityClientId` / `scheduler_managed_identity_client_id`). See + [Configure the scheduler identity for image pull](#configure-the-scheduler-identity-for-image-pull). + +## How it works + +On-demand Sandboxes use a two-part model: + +1. A **sandbox worker profile** in your orchestrator app that tells DTS which activities + to offload. +2. A **worker image** that contains those activity implementations. + +Your orchestrator still calls activities the same way it always has. The decision to run +an activity in a sandbox lives entirely in the profile configuration. + +### A simple example + +Imagine an orchestrator that does two things: format some text in-process, then run a +piece of customer-supplied Python in isolation. Only the second activity is declared in a +sandbox worker profile, so DTS runs it in a managed sandbox started from your worker +image—while the first activity stays in-process. The result flows back to the +orchestrator as if nothing special happened. + +```mermaid +flowchart LR + subgraph YourApp["Your orchestrator app"] + Orch["Orchestrator"] + InProc["FormatText activity
(runs in-process)"] + end + + DTS["Durable Task Scheduler
(durable backend)"] + + subgraph Sandbox["Managed sandbox (started by DTS)"] + Worker["RunPython activity
(your worker image)"] + end + + Orch -->|"in-process call"| InProc + Orch -->|"call RunPython"| DTS + DTS -->|"start sandbox &
dispatch activity"| Worker + Worker -->|"result"| DTS + DTS -->|"result"| Orch +``` + +1. The orchestrator runs `FormatText` in-process, like any normal activity. +2. When it calls `RunPython`—an activity declared in a sandbox worker profile—DTS starts a + sandbox from your worker image and dispatches the activity to it. +3. The activity runs in the isolated sandbox, and its result flows back through DTS to the + orchestrator. When the work is done, DTS tears the sandbox down. + +## Configure the scheduler identity for image pull + +To start a sandbox, DTS pulls your worker image from your container registry on your +behalf. It does this using a **user-assigned managed identity** attached to the scheduler. +That identity must be granted the **AcrPull** role on the Azure Container Registry that +hosts your worker image, and the scheduler must have the identity attached. + +> [!IMPORTANT] +> Only **user-assigned** managed identities are supported. System-assigned managed +> identities are not supported at this time. + +The worker profile distinguishes two identities, and you can use the same identity for +both or split them: + +- **Image-pull identity** (`Image.ManagedIdentityClientId` / + `image.managed_identity_client_id`) — the identity DTS uses to **pull the worker image** + from your registry. This identity needs the **AcrPull** role on the registry. +- **Worker/scheduler identity** (`SchedulerManagedIdentityClientId` / + `scheduler_managed_identity_client_id`) — the identity the **sandbox worker uses to + connect back to Durable Task Scheduler**, and the identity your activity code runs as + when it calls other services (for example, Storage, Key Vault, or a database). Grant + this identity whatever roles your activity code needs on those downstream services. + +Both identities must be attached to the scheduler. Using two separate identities lets you +scope image-pull permissions narrowly while granting your activity code only the +downstream permissions it needs. + +### 1. Grant the identity the AcrPull role on your registry + +Assign the **AcrPull** role to the **image-pull** user-assigned managed identity, scoped +to your registry: + +```bash +az role assignment create \ + --assignee "" \ + --role "AcrPull" \ + --scope "/subscriptions//resourceGroups//providers/Microsoft.ContainerRegistry/registries/" +``` + +Without this role assignment, DTS cannot pull the worker image and the sandbox will fail +to start. If your activity code calls other Azure services, grant the **worker/scheduler** +identity the roles it needs on those services as well. + +### 2. Attach the identity to the scheduler + +The scheduler must have the user-assigned identity attached. You can attach it when you +create the scheduler, or update an existing scheduler. + +> [!IMPORTANT] +> Managing scheduler identities requires API version **2026-05-01-preview** or later. See +> the [Schedulers - Create Or Update](https://learn.microsoft.com/rest/api/durabletask/schedulers/create-or-update?view=rest-durabletask-2026-05-01-preview&tabs=HTTP#managedserviceidentity) +> REST API reference. + +**For an existing scheduler**, send a PATCH to the scheduler resource URI. You can attach +multiple identities: + +```bash +az rest --method patch \ + --uri "https://management.azure.com/subscriptions//resourceGroups//providers/Microsoft.DurableTask/schedulers/?api-version=2026-05-01-preview" \ + --body '{ + "identity": { + "type": "UserAssigned", + "userAssignedIdentities": { + "/subscriptions//resourceGroups//providers/Microsoft.ManagedIdentity/userAssignedIdentities/": {} + } + } + }' +``` + +You can also include the same `identity` block directly in the body when **creating** a +scheduler. + +Once the identities are attached to the scheduler—the image-pull identity with the +**AcrPull** role on your registry—reference their client IDs on the worker profile +(`Image.ManagedIdentityClientId` / `image.managed_identity_client_id` and +`SchedulerManagedIdentityClientId` / `scheduler_managed_identity_client_id`) so DTS uses +the image-pull identity to pull the image and the worker/scheduler identity for the +sandbox worker to connect back to DTS and call downstream services. + +## Choose your language + +Follow the step-by-step guide for your SDK: + +- **[.NET guide](./docs/dotnet.md)** — declare a sandbox worker profile and build the worker + image with the .NET Durable Task SDK. +- **[Python guide](./docs/python.md)** — declare a sandbox worker profile and build the worker + image with the Python Durable Task SDK. + +Both guides follow the same shape: declare a sandbox worker profile in your orchestrator +app, build and push a worker image, then view execution logs in the DTS dashboard. + +## Worker profile configuration reference + +Both languages configure the same worker profile settings. The table below lists each +setting, what it controls, its accepted values, and its default. The setting names differ +slightly between .NET (`PascalCase`) and Python (`snake_case`) but map one to one. + +| Setting (.NET / Python) | What it controls | Accepted values | Default | +| --- | --- | --- | --- | +| `Image.ImageRef` / `image.image_ref` | The container image that holds your activity implementations. | A full OCI image reference, by tag (`myregistry.azurecr.io/workers/hello:1.0`) or digest (`myregistry.azurecr.io/workers/hello@sha256:...`). | *Required* | +| `Image.ManagedIdentityClientId` / `image.managed_identity_client_id` | The client ID of the user-assigned managed identity DTS uses to **pull the worker image** from your registry. This identity needs the **AcrPull** role on the registry. | A user-assigned managed identity client ID (GUID). Must be attached to the scheduler. | *Required* | +| `SchedulerManagedIdentityClientId` / `scheduler_managed_identity_client_id` | The client ID of the user-assigned managed identity the **sandbox worker uses to connect back to DTS**, and that the activity code runs as when calling other services. | A user-assigned managed identity client ID (GUID). Must be attached to the scheduler. Can be the same identity as the image-pull identity or a different one. | *Required* | +| `Cpu` / `cpu` | CPU quantity declared for each sandbox. | A positive CPU quantity, expressed in millicores (`500m`, `1000m`) or whole/fractional cores (`2`, `0.5`). | `1000m` (1 vCPU) | +| `Memory` / `memory` | Memory quantity declared for each sandbox. | A positive memory quantity, such as `256Mi`, `1Gi`, or a bare number interpreted as MiB (`2048`). | `2048Mi` | +| `MaxConcurrentActivities` / `max_concurrent_activities` | How many activities a single sandbox worker instance processes concurrently. | An integer greater than `0`. There is no enforced upper bound; size it to what your activity and resource shape can handle. | `100` | +| `EnvironmentVariables` / `environment_variables` | Customer environment variables injected into the sandbox at runtime. | A map of string keys to string values. | Empty | +| *(profile id)* | Friendly profile id that groups the image, resources, and activities for monitoring and reuse. | A non-empty string, unique across your declared profiles. | `default` | +| `AddActivity` / `add_activity` | The activity names this profile offloads to the sandbox. | One or more activity names. At least one is required; an activity can belong to only one profile. | *Required* | + +> [!NOTE] +> CPU and memory must be positive resource quantities. The platform may apply additional +> per-preview ceilings on the total CPU and memory a sandbox can request—check your +> private preview onboarding details for the current limits. + +## View logs in the DTS dashboard + +Once your sandbox activities are running, you can view their execution logs directly in +the Durable Task Scheduler dashboard. The dashboard shows real-time output from your +managed workers, including stdout, stderr, and activity lifecycle events—giving you full +visibility into what's happening inside the sandbox without configuring external log +sinks or building your own observability pipeline. + +## Get started + +On-demand Sandboxes is in private preview. To get access, +[sign up here](https://techcommunity.microsoft.com/blog/AppsonAzureBlog/introducing-on-demand-sandboxes-for-azure-durable-task-scheduler-private-preview/4522333). +Once you're in, the workflow is straightforward: declare a sandbox worker profile in +your orchestrator app, build and push a worker image, and DTS takes care of the rest. + +## Related resources + +- **Documentation:** [Durable Task Scheduler overview](https://learn.microsoft.com/azure/durable-task/) +- **Samples:** [.NET sample](./samples/dotnet) · [Python sample](./samples/python) +- **Pricing:** [Azure Durable Task Scheduler pricing](https://azure.microsoft.com/pricing/) +- **Feedback:** Open an issue in the + [Durable-Task-Scheduler GitHub repo](https://github.com/Azure-Samples/Durable-Task-Scheduler). diff --git a/preview-features/on-demand-sandboxes/docs/dotnet.md b/preview-features/on-demand-sandboxes/docs/dotnet.md new file mode 100644 index 00000000..76e3229c --- /dev/null +++ b/preview-features/on-demand-sandboxes/docs/dotnet.md @@ -0,0 +1,182 @@ +# On-demand Sandboxes — .NET guide + +> **Status:** Private preview · [Back to overview](./README.md) + +This guide walks through using On-demand Sandboxes with the **.NET** Durable Task SDK. +Make sure you've reviewed the [prerequisites](./README.md#prerequisites) first. + +On-demand Sandboxes use a two-part model: a **sandbox worker profile** in your +orchestrator app that tells DTS which activities to offload, and a **worker image** that +contains those activity implementations. Your orchestrator still calls activities the +same way it always has—the decision to run one in a sandbox lives entirely in the profile +configuration. + +## Install the preview packages + +The on-demand sandbox APIs ship in two opt-in preview packages that layer on top of the +Azure-managed client and worker packages: + +- `Microsoft.DurableTask.Client.AzureManaged.Sandboxes` — declarer-app side + (`[SandboxWorkerProfile]`, `SandboxWorkerProfileOptions`, `SandboxActivitiesClient`). +- `Microsoft.DurableTask.Worker.AzureManaged.Sandboxes` — sandbox-worker side + (`UseSandboxWorker()`). + +Add the client and worker packages to your orchestrator app, and the worker package to +the sandbox worker image project: + +```bash +# Orchestrator / declarer app +dotnet add package Microsoft.DurableTask.Client.AzureManaged.Sandboxes --version 1.25.0-preview.2 +dotnet add package Microsoft.DurableTask.Worker.AzureManaged.Sandboxes --version 1.25.0-preview.2 + +# Sandbox worker image project +dotnet add package Microsoft.DurableTask.Worker.AzureManaged.Sandboxes --version 1.25.0-preview.2 +``` + +## Step 1 — Declare a sandbox worker profile + +In the app that hosts your orchestrator, define a sandbox worker profile. The profile +gives DTS the container image of your activity code, the managed identities DTS uses to +pull the image and start the sandbox, the resource shape, concurrency setting, and the +activity names that should run in a sandbox. + +```csharp +using Microsoft.DurableTask.Client.AzureManaged; + +[SandboxWorkerProfile("")] +internal sealed class CodeSandboxWorkerProfile : ISandboxWorkerProfile +{ + public void Configure(SandboxWorkerProfileOptions options) + { + options.Image.ImageRef = Environment.GetEnvironmentVariable("DTS_SANDBOX_CONTAINER_IMAGE") + ?? throw new InvalidOperationException("DTS_SANDBOX_CONTAINER_IMAGE is required."); + options.Image.ManagedIdentityClientId = Environment.GetEnvironmentVariable("DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID") + ?? throw new InvalidOperationException("DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID is required."); + options.SchedulerManagedIdentityClientId = Environment.GetEnvironmentVariable("DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID") + ?? throw new InvalidOperationException("DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID is required."); + options.Cpu = "1000m"; + options.Memory = "2048Mi"; + options.MaxConcurrentActivities = 1; + options.AddActivity(TaskNames.ExecuteCode, version: ""); + } +} +``` + +> [!IMPORTANT] +> The managed identities referenced by `options.Image.ManagedIdentityClientId` and +> `options.SchedulerManagedIdentityClientId` must both be attached to the scheduler. The +> image-pull identity must have the **AcrPull** role on your container registry, and the +> worker/scheduler identity must have whatever roles your activity code needs on the +> downstream services it calls. You can use the same identity for both or split them. See +> [Configure the scheduler identity for image pull](./README.md#configure-the-scheduler-identity-for-image-pull). + +Then, in the main app, enable work-item filters, register the sandbox activities client, +and declare the profiles with DTS: + +```csharp +builder.Services.AddDurableTaskWorker(workerBuilder => +{ + workerBuilder.AddTasks(tasks => tasks.AddAllGeneratedTasks()); + workerBuilder.UseWorkItemFilters(); + workerBuilder.UseDurableTaskScheduler(options => + { + options.EndpointAddress = endpoint; + options.TaskHubName = taskHub; + options.Credential = credential; + }); +}); + +// Profiles are declared via [SandboxWorkerProfile]. This registers the client that +// publishes them to DTS. +builder.Services.AddDurableTaskSchedulerSandboxActivitiesClient(); +``` + +`UseWorkItemFilters()` is required: without it, DTS can dispatch a sandbox activity to +your in-process worker—which doesn't implement it—and the orchestration gets stuck +retrying the wrong worker. + +Once the host is running, publish the declared profiles to DTS so it can route their +activities to a sandbox: + +```csharp +SandboxActivitiesClient sandboxActivitiesClient = + host.Services.GetRequiredService(); +await sandboxActivitiesClient.EnableSandboxActivitiesAsync(); +``` + +`EnableSandboxActivitiesAsync()` is the key call—it registers your sandbox worker profiles +with DTS so it picks them up and routes their declared activities to managed compute. +Without it, those activities won't be offloaded. + +For the meaning, accepted values, and defaults of each `SandboxWorkerProfileOptions` +setting, see the +[worker profile configuration reference](./README.md#worker-profile-configuration-reference). +In short: `Image.ImageRef` is the image with your activity implementations; +`Image.ManagedIdentityClientId` is the managed identity DTS uses to **pull the worker +image** from your registry (needs **AcrPull**), while `SchedulerManagedIdentityClientId` +is the managed identity the **sandbox worker uses to connect back to DTS** and that the +activity code runs as when it calls other services; `Cpu` / `Memory` set the per-sandbox +resource shape; `MaxConcurrentActivities` sets concurrency; and `AddActivity` selects the +activities to offload (only added activities run in DTS-managed isolated compute; +everything else stays in-process). + +The orchestrator call site doesn't change: + +```csharp +ExecuteCodeOutput execution = await context.CallActivityAsync( + TaskNames.ExecuteCode, + new ExecuteCodeInput(pythonCode, input.CsvData)); +``` + +Because `ExecuteCode` is not registered in the main app's in-process activity list, DTS +uses the profile to route the work to the sandbox image when the orchestrator calls it. + +## Step 2 — Build the worker image + +The worker image is a container you own. In most apps, this worker lives in a separate +project from the orchestrator host so it can have its own entry point, dependencies, and +container image. It registers the activity implementations it can run and opts in to +managed execution with `UseSandboxWorker()`: + +```csharp +builder.Services.AddDurableTaskWorker(workerBuilder => +{ + workerBuilder.AddTasks(tasks => + { + tasks.AddActivity(); + }); + + workerBuilder.UseSandboxWorker(); +}); +``` + +`UseSandboxWorker()` is the key line—it signals that this worker runs in DTS-managed +compute. The sandbox worker does **not** need to configure the DTS endpoint, task hub, +profile id, or credentials; DTS injects the runtime settings when it starts the +container. + +The activity implementations themselves are standard Durable Task activities. There's +nothing special about the activity code—it can call a runtime with different +dependencies (for example, Python and pandas) while running in an isolated container +instead of in your main app's process. + +Package the image like any containerized service, including whatever runtimes and native +tools the activity needs. Push it to your container registry (for example, Azure +Container Registry) and reference the image in the worker profile's `Image.ImageRef` +option. The image-pull identity you set in `Image.ManagedIdentityClientId` must have the +**AcrPull** role on that registry. + +## Step 3 — View logs in the DTS dashboard + +Once your sandbox activities are running, you can view their execution logs directly in +the Durable Task Scheduler dashboard. See +[View logs in the DTS dashboard](./README.md#view-logs-in-the-dts-dashboard) in the +overview for details. + +## Next steps + +- [Worker profile configuration reference](./README.md#worker-profile-configuration-reference) +- [Configure the scheduler identity for image pull](./README.md#configure-the-scheduler-identity-for-image-pull) +- [End-to-end .NET sample](../samples/dotnet) +- [Python guide](./python.md) +- [Back to overview](./README.md) diff --git a/preview-features/on-demand-sandboxes/docs/python.md b/preview-features/on-demand-sandboxes/docs/python.md new file mode 100644 index 00000000..ae168d84 --- /dev/null +++ b/preview-features/on-demand-sandboxes/docs/python.md @@ -0,0 +1,218 @@ +# On-demand Sandboxes — Python guide + +> **Status:** Private preview · [Back to overview](./README.md) + +This guide walks through using On-demand Sandboxes with the **Python** Durable Task SDK. +Make sure you've reviewed the [prerequisites](./README.md#prerequisites) first. + +On-demand Sandboxes use a two-part model: a **sandbox worker profile** (the *declarer +app*) that tells DTS which activities to offload, and a **worker image** that contains +those activity implementations. Your orchestrator still calls activities the same way it +always has—the decision to run one in a sandbox lives entirely in the profile +configuration. + +## Install the SDK + +The on-demand sandbox APIs ship under the `durabletask.azuremanaged.preview.sandboxes` +namespace. Install the Durable Task packages: + +```bash +pip install durabletask==1.6.0 durabletask-azuremanaged==1.6.0 +``` + +## Step 1 — Declare a sandbox worker profile + +The declarer app uses a decorated profile class to declare the remote worker image and +activity ownership, then enables sandbox activities on the DTS client. The profile sets +the image, the managed identities DTS needs to pull the image and start the sandbox, the +resource shape, concurrency, any customer environment variables, and the activity names +to offload with `options.add_activity(...)`. + +```python +import os + +from azure.identity import DefaultAzureCredential + +from durabletask import client, task +from durabletask.azuremanaged.client import DurableTaskSchedulerClient +from durabletask.azuremanaged.preview.sandboxes import ( + SandboxActivitiesClient, + SandboxWorkerProfile, + sandbox_worker_profile, +) +from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker + +REMOTE_HELLO = "remote_hello" + +endpoint = os.environ["DTS_ENDPOINT"] +taskhub_name = os.environ["DTS_TASK_HUB"] +worker_profile_id = os.getenv("DTS_WORKER_PROFILE_ID", "default") +container_image = os.environ["DTS_SANDBOX_CONTAINER_IMAGE"] + + +def hello_orchestrator(ctx: task.OrchestrationContext, name: str): + """Orchestrator that calls an activity executed by the remote sandbox worker.""" + return (yield ctx.call_activity(REMOTE_HELLO, input=name)) + + +@sandbox_worker_profile(worker_profile_id) +class RemoteWorkerProfile(SandboxWorkerProfile): + def configure(self, options) -> None: + options.image.image_ref = container_image + options.image.managed_identity_client_id = os.environ[ + "DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID"] + options.scheduler_managed_identity_client_id = os.environ[ + "DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID"] + options.cpu = "1000m" + options.memory = "2048Mi" + options.max_concurrent_activities = 1 + options.environment_variables["SAMPLE_MARKER"] = "python-sample-marker" + options.add_activity(REMOTE_HELLO) + + +credential = DefaultAzureCredential() + +# Declare the sandbox worker profile with DTS so it can route the activity to a sandbox. +sandbox_client = SandboxActivitiesClient( + host_address=endpoint, + secure_channel=True, + taskhub=taskhub_name, + token_credential=credential) +sandbox_client.enable_sandbox_activities() + +with DurableTaskSchedulerWorker( + host_address=endpoint, + secure_channel=True, + taskhub=taskhub_name, + token_credential=credential) as worker: + worker.add_orchestrator(hello_orchestrator) + worker.use_work_item_filters() + worker.start() + + durable_client = DurableTaskSchedulerClient( + host_address=endpoint, + secure_channel=True, + taskhub=taskhub_name, + token_credential=credential) + instance_id = durable_client.schedule_new_orchestration( + hello_orchestrator, input="on-demand sandbox Python") + state = durable_client.wait_for_orchestration_completion(instance_id, timeout=300) + print(state.serialized_output if state else "no result") +``` + +`enable_sandbox_activities()` is the key call—it registers the declared profiles with DTS +so it can route those activities to the sandbox image. `use_work_item_filters()` keeps +sandbox activities from being dispatched to this in-process worker. + +> [!IMPORTANT] +> The managed identities referenced by `options.image.managed_identity_client_id` and +> `options.scheduler_managed_identity_client_id` must both be attached to the scheduler. +> The image-pull identity must have the **AcrPull** role on your container registry, and +> the worker/scheduler identity must have whatever roles your activity code needs on the +> downstream services it calls (you can use the same identity for both or split them). See +> [Configure the scheduler identity for image pull](./README.md#configure-the-scheduler-identity-for-image-pull). + +For the meaning, accepted values, and defaults of each profile option, see the +[worker profile configuration reference](./README.md#worker-profile-configuration-reference). +In short: `image.image_ref` is the image with your activity implementations; +`image.managed_identity_client_id` is the managed identity DTS uses to **pull the worker +image** from your registry (needs **AcrPull**), while +`scheduler_managed_identity_client_id` is the managed identity the **sandbox worker uses +to connect back to DTS** and that the activity code runs as when it calls other services; +`cpu` / `memory` set the per-sandbox resource shape; `max_concurrent_activities` sets +concurrency; `environment_variables` injects customer environment variables; and +`add_activity(...)` selects the activities to offload (only added activities run in +DTS-managed isolated compute; everything else stays in-process). + +The orchestrator call site doesn't change—it calls `REMOTE_HELLO` the same way it would +call any activity, and DTS routes it to the sandbox. + +## Step 2 — Build the worker image + +The worker image runs `SandboxWorker()`, registers the activity implementations it owns, +and starts. The sandbox worker does **not** configure the DTS endpoint, task hub, profile +id, or credentials—`SandboxWorker()` reads the runtime settings (such as `DTS_ENDPOINT`, +`DTS_TASK_HUB`, `DTS_WORKER_PROFILE_ID`, and `DTS_SANDBOX_ID`) from environment variables +that DTS injects when it starts the container. + +```python +import os +import threading + +from durabletask import task +from durabletask.azuremanaged.preview.sandboxes import SandboxWorker + +REMOTE_HELLO = "remote_hello" + + +def _remote_hello(ctx: task.ActivityContext, name: str) -> str: + """Activity function that runs inside the on-demand sandbox worker container.""" + sandbox_id = os.getenv("DTS_SANDBOX_ID", "unknown-sandbox") + return f"Hello {name} from Python on-demand sandbox worker {sandbox_id}!" + + +# The registered activity name must match the name declared in the worker profile. +_remote_hello.__name__ = REMOTE_HELLO + +with SandboxWorker() as worker: + worker.add_activity(_remote_hello) + worker.start() + print("Python on-demand sandbox remote worker is running.") + try: + threading.Event().wait() + except KeyboardInterrupt: + pass +``` + +Keep the activity name constant (here, `REMOTE_HELLO`) in a small shared module so the +declarer app and the remote worker stay in sync. When the worker connects, it reports its +registered activity names, and DTS validates they match the declaration before +advertising worker capacity. + +Build and push the image with a `Containerfile`/`Dockerfile` that installs the SDK and +your activity's dependencies, then copies in the worker entry point: + +```dockerfile +# syntax=docker/dockerfile:1.7 +FROM python:3.12-slim AS runtime +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +ENV GRPC_DEFAULT_SSL_ROOTS_FILE_PATH=/etc/ssl/certs/ca-certificates.crt + +# Install the Durable Task SDK (with the sandboxes extension), plus your +# activity dependencies. +RUN pip install --no-cache-dir durabletask==1.6.0 durabletask-azuremanaged==1.6.0 + +COPY remote_worker.py /app/remote_worker.py +COPY activities.py /app/activities.py + +ENTRYPOINT ["python", "/app/remote_worker.py"] +``` + +```bash +docker build -f Containerfile -t . +docker push +``` + +Then set the image reference on the declarer profile (for example, via the +`DTS_SANDBOX_CONTAINER_IMAGE` environment variable). DTS pulls the image using the +image-pull managed identity you configured on the profile, which must have the +**AcrPull** role on your registry. + +## Step 3 — View logs in the DTS dashboard + +Once your sandbox activities are running, you can view their execution logs directly in +the Durable Task Scheduler dashboard. See +[View logs in the DTS dashboard](./README.md#view-logs-in-the-dts-dashboard) in the +overview for details. + +## Next steps + +- [Worker profile configuration reference](./README.md#worker-profile-configuration-reference) +- [Configure the scheduler identity for image pull](./README.md#configure-the-scheduler-identity-for-image-pull) +- [End-to-end Python sample](../samples/python) +- [.NET guide](./dotnet.md) +- [Back to overview](./README.md) diff --git a/preview-features/on-demand-sandboxes/samples/README.md b/preview-features/on-demand-sandboxes/samples/README.md new file mode 100644 index 00000000..089651ef --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/README.md @@ -0,0 +1,19 @@ +# On-demand Sandboxes: LLM-generated code interpreter demo + +A Durable Task Scheduler (DTS) demo of the **On-demand Sandboxes** preview, built +in two languages. A three-step workflow asks a natural-language question over a +CSV: an LLM generates a pandas script, the **untrusted** script runs in a +DTS-managed on-demand sandbox (fanned out per region), and an in-process step +aggregates the answer. + +| Directory | Implementation | SDK | +| --- | --- | --- | +| [`dotnet/`](dotnet/README.md) | .NET 10 | `Microsoft.DurableTask.*.AzureManaged.Sandboxes` 1.25.0-preview.2 | +| [`python/`](python/README.md) | Python 3.12 | `durabletask-azuremanaged` 1.6.0 | + +Both implementations follow the same shape: a main/declarer app hosts the +orchestrator and in-process activities and declares the sandbox worker profile, +while a separate sandbox worker image runs the offloaded `ExecuteCode` / +`execute_code` activity in DTS-provisioned compute. + +See each directory's README for prerequisites, build, and run instructions. diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/Directory.Build.props b/preview-features/on-demand-sandboxes/samples/dotnet/Directory.Build.props new file mode 100644 index 00000000..68c0dd96 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/Directory.Build.props @@ -0,0 +1,13 @@ + + + + + 1.25.0-preview.2 + + 2.1.0-preview.2 + + + diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/README.md b/preview-features/on-demand-sandboxes/samples/dotnet/README.md new file mode 100644 index 00000000..58dfaf0f --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/README.md @@ -0,0 +1,234 @@ +# On-demand Sandboxes demo: LLM-generated code interpreter + +A three-step Durable Task workflow that demonstrates the **On-demand Sandboxes** preview +of Azure Durable Task Scheduler (DTS). + +``` + ┌─────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────────┐ + │ GenerateCode │ │ ExecuteCode │ │ FormatAnswer │ + │ (in-process .NET) │ -> │ (on-demand sandbox) │ -> │ (in-process .NET) │ + │ Azure OpenAI -> Python │ │ python3 + pandas │ │ Pretty-print answer │ + └─────────────────────────┘ └─────────────────────────┘ └─────────────────────────┘ +``` + +The orchestrator asks a natural-language question over `data/sales_q1.csv`. The LLM +returns a self-contained pandas script. That script is **untrusted** code, so it runs in +a DTS-managed on-demand sandbox - not in the orchestrator's process. The first and last +activities stay in-process; only `ExecuteCode` is offloaded. + +## Why this is a fit for On-demand Sandboxes + +- The generated Python is arbitrary code. It should not run in the orchestrator host. +- The sandbox needs a different runtime (Python + pandas) than the orchestrator (.NET). +- Each invocation gets a fresh container. No cross-request state to worry about. +- Bursty by nature - a question every few minutes, but each one is short-lived. + +## Architecture + +```mermaid +flowchart LR + subgraph MainAppHost["main-app process (.NET, always running)"] + direction TB + Orch["AnalyzeSales
Orchestrator"] + GenCode["GenerateCode
(in-process activity)"] + FormatAns["FormatAnswer
(in-process activity)"] + end + + subgraph DTS["Azure Durable Task Scheduler"] + direction TB + TaskHub[("Task Hub")] + Sandboxes["On-demand sandbox runtime"] + end + + subgraph Sandbox["DTS-managed sandbox container
(code-executor profile, on-demand)"] + ExecCode["ExecuteCode
(on-demand sandbox activity)"] + end + + Orch <--> TaskHub + GenCode <--> TaskHub + FormatAns <--> TaskHub + + TaskHub -.->|"ExecuteCode declared on-demand"| Sandboxes + Sandboxes ==>|"provision on demand /
scale to zero"| Sandbox + ExecCode <--> TaskHub +``` + +**How it works:** + +- The orchestrator and its in-process activities (`GenerateCode`, `FormatAnswer`) run in the always-on `main-app` process and exchange work items with the DTS task hub. +- `ExecuteCode` is declared as an on-demand sandbox activity by the `code-executor` worker profile (see `main-app/WorkerProfiles.cs`). The activity is never registered in the main app. +- When the orchestrator calls `ExecuteCode`, the DTS on-demand sandbox runtime provisions a sandbox container from the profile's image. The sandbox picks up the work item, runs it, returns the result, and is scaled back to zero when idle. +- The orchestrator's call site (`CallActivityAsync(TaskNames.ExecuteCode, ...)`) is identical to any other activity call — the "this runs in a sandbox" decision lives entirely in the worker profile declaration. + +## Layout + +``` +dts-ondemand-sandbox-codegen-demo/ +├── Directory.Build.props # Pins the DTS preview package version +├── data/sales_q1.csv # Sample dataset (~35 rows) +├── azure.yaml # azd service + hooks (Deploy to Azure) +├── infra/ # Bicep: AKS, ACR, identity, Azure OpenAI, scheduler wiring +├── scripts/ # acr-build.sh + attach-scheduler-identity.sh (azd hooks) +├── main-app/ # Orchestrator host (.NET 10), deployed to AKS +│ ├── Program.cs +│ ├── AnalyzeSalesOrchestrator.cs +│ ├── Activities.cs # GenerateCode + FormatAnswer (in-process) +│ ├── Contracts.cs +│ ├── TaskNames.cs +│ ├── Containerfile # main-app image +│ └── manifests/ # K8s deployment template +└── sandbox-worker/ # Built into the sandbox container image + ├── Program.cs # UseSandboxWorker() + ├── ExecuteCodeActivity.cs # Shells out to python3 + ├── Contracts.cs + └── Containerfile +``` + +## Prerequisites + +- .NET 10 SDK +- Docker (for building the sandbox image) +- A DTS scheduler + task hub you can hit +- An Azure Container Registry with anonymous pull enabled (so DTS can fetch the sandbox image) +- An Azure OpenAI deployment of a chat model (GPT-4o, GPT-4.1, etc.) +- The Durable Task on-demand sandbox preview packages (`1.25.0-preview.2`) available on + a NuGet feed you can restore from + +## Build the sandbox image + +From the demo root: + +```bash +ACR= +IMAGE=$ACR.azurecr.io/dts-codegen-sandbox:v1 + +docker build \ + --platform linux/amd64 \ + -f sandbox-worker/Containerfile \ + -t $IMAGE \ + . + +# Enable anonymous pull so DTS can fetch the sandbox image without credentials +az acr update --name $ACR --anonymous-pull-enabled true + +az acr login --name $ACR +docker push $IMAGE +``` + +> **Note on `--platform linux/amd64`:** Required on Apple Silicon. The `Grpc.Tools` +> 2.78.0 linux_arm64 `protoc` binary segfaults under Docker's arm64 emulation. +> amd64 builds work fine under Rosetta and match what DTS sandboxes run anyway. + +## Run the orchestrator + +```bash +export DTS_ENDPOINT="https://" +export DTS_TASK_HUB="" +export DTS_SANDBOX_CONTAINER_IMAGE=".azurecr.io/dts-codegen-sandbox:v1" +export DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID="" +export DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID="" + +export AOAI_ENDPOINT="https://.openai.azure.com" +export AOAI_DEPLOYMENT="" + +# Sign in so DefaultAzureCredential can reach DTS and Azure OpenAI +az login + +dotnet run --project main-app/main-app.csproj -- \ + "Which region had the highest total revenue in March 2025?" +``` + +The orchestrator prints the question, the orchestration id, and the final answer. +The main-app console shows the AOAI-generated Python (prefixed `[generate]`) before +it's handed off to the sandbox. The sandbox container logs (prefixed `[sandbox]`) +stream through the DTS dashboard's **On-demand Sandboxes** tab while `ExecuteCode` +runs — that's where you see the code, dataset load, execution timing, and script output. + +## Deploy to Azure (AKS) with `azd` + +The `infra/` folder and `azure.yaml` deploy the **main-app** orchestrator to **Azure +Kubernetes Service** with [`azd`](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd). +The sandbox worker image is built and pushed to ACR; DTS starts it on demand, so it is +never deployed to the cluster. + +> The Durable Task Scheduler is **not created** by this template — you pass in an +> existing one. On-demand Sandboxes is a private-preview feature that must be enabled on +> the scheduler out of band, so the scheduler is patched separately and supplied here by +> name. + +### What gets provisioned + +| Resource | Purpose | +|----------|---------| +| **AKS cluster** | Hosts the `main-app` orchestrator pod (workload identity enabled) | +| **Azure Container Registry** | Stores the main-app and sandbox-worker images (built server-side via ACR Tasks) | +| **User-assigned managed identity** + federated credential | Pod auth to DTS/Azure OpenAI, ACR pull for the sandbox, and the sandbox's connection back to DTS | +| **Azure OpenAI** + `gpt-4o` deployment | Backs the in-process `GenerateCode` activity | +| **VNet** | Network isolation for AKS | + +The deployment also **ensures the task hub** exists, grants the identity the roles it +needs (AcrPull, Durable Task data access, Cognitive Services OpenAI User), and a +`postprovision` hook **attaches the identity to your scheduler** (a merge-safe PATCH). + +### Prerequisites + +- An existing **DTS scheduler** with the On-demand Sandboxes preview enabled, and its + resource group name. +- [Azure Developer CLI (`azd`)](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd), [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli), and [kubectl](https://kubernetes.io/docs/tasks/tools/). +- Azure OpenAI quota for `gpt-4o` in your target region. + +### Deploy + +```bash +azd auth login && az login + +# Point the template at your existing (preview-enabled) scheduler. +azd env set DTS_SCHEDULER_NAME "" +azd env set DTS_SCHEDULER_RESOURCE_GROUP "" +# Optional overrides: DTS_TASK_HUB (default: default), AZURE_OPENAI_LOCATION + +azd up +``` + +`azd` provisions the resources, builds both images via ACR Tasks, attaches the identity +to your scheduler, and deploys the `main-app` pod. If you don't set `DTS_SCHEDULER_NAME` +/ `DTS_SCHEDULER_RESOURCE_GROUP` first, `azd` prompts for them. + +### Verify + +```bash +az aks get-credentials --resource-group --name # from `azd env get-values` +kubectl get pods +kubectl logs -l app=mainapp --tail=50 +``` + +The `main-app` pod runs the orchestration; `[sandbox]` logs from `ExecuteCode` stream in +the DTS dashboard's **On-demand Sandboxes** tab. + +### Clean up + +```bash +azd down +``` + +This removes the resources the template created. Your scheduler is left untouched (it was +not created here); detach the identity manually if you no longer need it. + +## Sample questions to try + +- `Which region had the highest total revenue in March 2025?` +- `What was the best-selling product in Q1?` +- `Average revenue per transaction in February?` +- `Total units sold in the East region across the quarter?` + +## What's in-process vs on-demand sandbox + +| Activity | Runs where | Why | +| --------------- | ------------------ | ------------------------------------------------------ | +| GenerateCode | In-process | Plain Azure OpenAI HTTP call. No reason to split out. | +| ExecuteCode | **Sandbox** | Untrusted LLM-generated code + different runtime. | +| FormatAnswer | In-process | Trivial string formatting. | + +Only `ExecuteCode` is declared on the `code-executor` sandbox worker profile via +`options.AddActivity(...)` (see `main-app/WorkerProfiles.cs`). Everything +else runs wherever the orchestrator runs. diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/azure.yaml b/preview-features/on-demand-sandboxes/samples/dotnet/azure.yaml new file mode 100644 index 00000000..c2200786 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/azure.yaml @@ -0,0 +1,24 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +# On-demand Sandboxes (.NET) on Azure Kubernetes Service. +# +# Only the orchestrator (main-app) is deployed to AKS. The sandbox worker image is +# built and pushed to ACR by the predeploy hook; DTS starts it on demand, so it is +# not an azd service. Images are built server-side with ACR Tasks (az acr build) to +# avoid local Docker builds (the Grpc.Tools arm64 protoc segfaults on Apple Silicon). + +metadata: + template: dts-ondemand-sandboxes-dotnet-aks +name: dts-ondemand-sandboxes-dotnet +hooks: + predeploy: + shell: bash + run: ./scripts/acr-build.sh + postprovision: + shell: bash + run: ./scripts/attach-scheduler-identity.sh +services: + mainapp: + project: ./main-app + language: csharp + host: aks diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/data/sales_q1.csv b/preview-features/on-demand-sandboxes/samples/dotnet/data/sales_q1.csv new file mode 100644 index 00000000..aa6d0ee8 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/data/sales_q1.csv @@ -0,0 +1,301 @@ +date,region,product,units,revenue +2025-01-01,West,Gadget,18,9000 +2025-01-01,East,Widget,25,5000 +2025-01-01,Central,Gizmo,32,9600 +2025-01-01,South,Gadget,39,19500 +2025-01-02,West,Widget,46,9200 +2025-01-02,East,Gizmo,53,15900 +2025-01-02,Central,Gadget,60,30000 +2025-01-03,South,Widget,24,4800 +2025-01-03,West,Gizmo,31,9300 +2025-01-03,East,Gadget,38,19000 +2025-01-03,Central,Widget,45,9000 +2025-01-04,South,Gizmo,52,15600 +2025-01-04,West,Gadget,59,29500 +2025-01-04,East,Widget,23,4600 +2025-01-05,Central,Gizmo,30,9000 +2025-01-05,South,Gadget,37,18500 +2025-01-05,West,Widget,44,8800 +2025-01-06,East,Gizmo,51,15300 +2025-01-06,Central,Gadget,58,29000 +2025-01-06,South,Widget,22,4400 +2025-01-06,West,Gizmo,29,8700 +2025-01-07,East,Gadget,36,18000 +2025-01-07,Central,Widget,43,8600 +2025-01-07,South,Gizmo,50,15000 +2025-01-08,West,Gadget,57,28500 +2025-01-08,East,Widget,21,4200 +2025-01-08,Central,Gizmo,28,8400 +2025-01-09,South,Gadget,35,17500 +2025-01-09,West,Widget,42,8400 +2025-01-09,East,Gizmo,49,14700 +2025-01-09,Central,Gadget,56,28000 +2025-01-10,South,Widget,20,4000 +2025-01-10,West,Gizmo,27,8100 +2025-01-10,East,Gadget,34,17000 +2025-01-11,Central,Widget,41,8200 +2025-01-11,South,Gizmo,48,14400 +2025-01-11,West,Gadget,55,27500 +2025-01-12,East,Widget,19,3800 +2025-01-12,Central,Gizmo,26,7800 +2025-01-12,South,Gadget,33,16500 +2025-01-12,West,Widget,40,8000 +2025-01-13,East,Gizmo,47,14100 +2025-01-13,Central,Gadget,54,27000 +2025-01-13,South,Widget,18,3600 +2025-01-14,West,Gizmo,25,7500 +2025-01-14,East,Gadget,32,16000 +2025-01-14,Central,Widget,39,7800 +2025-01-14,South,Gizmo,46,13800 +2025-01-15,West,Gadget,53,26500 +2025-01-15,East,Widget,60,12000 +2025-01-15,Central,Gizmo,24,7200 +2025-01-16,South,Gadget,31,15500 +2025-01-16,West,Widget,38,7600 +2025-01-16,East,Gizmo,45,13500 +2025-01-17,Central,Gadget,52,26000 +2025-01-17,South,Widget,59,11800 +2025-01-17,West,Gizmo,23,6900 +2025-01-17,East,Gadget,30,15000 +2025-01-18,Central,Widget,37,7400 +2025-01-18,South,Gizmo,44,13200 +2025-01-18,West,Gadget,51,25500 +2025-01-19,East,Widget,58,11600 +2025-01-19,Central,Gizmo,22,6600 +2025-01-19,South,Gadget,29,14500 +2025-01-20,West,Widget,36,7200 +2025-01-20,East,Gizmo,43,12900 +2025-01-20,Central,Gadget,50,25000 +2025-01-20,South,Widget,57,11400 +2025-01-21,West,Gizmo,21,6300 +2025-01-21,East,Gadget,28,14000 +2025-01-21,Central,Widget,35,7000 +2025-01-22,South,Gizmo,42,12600 +2025-01-22,West,Gadget,49,24500 +2025-01-22,East,Widget,56,11200 +2025-01-23,Central,Gizmo,20,6000 +2025-01-23,South,Gadget,27,13500 +2025-01-23,West,Widget,34,6800 +2025-01-23,East,Gizmo,41,12300 +2025-01-24,Central,Gadget,48,24000 +2025-01-24,South,Widget,55,11000 +2025-01-24,West,Gizmo,19,5700 +2025-01-25,East,Gadget,26,13000 +2025-01-25,Central,Widget,33,6600 +2025-01-25,South,Gizmo,40,12000 +2025-01-26,West,Gadget,47,23500 +2025-01-26,East,Widget,54,10800 +2025-01-26,Central,Gizmo,18,5400 +2025-01-26,South,Gadget,25,12500 +2025-01-27,West,Widget,32,6400 +2025-01-27,East,Gizmo,39,11700 +2025-01-27,Central,Gadget,46,23000 +2025-01-28,South,Widget,53,10600 +2025-01-28,West,Gizmo,60,18000 +2025-01-28,East,Gadget,24,12000 +2025-01-28,Central,Widget,31,6200 +2025-01-29,South,Gizmo,38,11400 +2025-01-29,West,Gadget,45,22500 +2025-01-29,East,Widget,52,10400 +2025-01-30,Central,Gizmo,59,17700 +2025-01-30,South,Gadget,23,11500 +2025-01-30,West,Widget,30,6000 +2025-01-31,East,Gizmo,37,11100 +2025-01-31,Central,Gadget,44,22000 +2025-01-31,South,Widget,51,10200 +2025-01-31,West,Gizmo,58,17400 +2025-02-01,East,Gadget,22,11000 +2025-02-01,Central,Widget,29,5800 +2025-02-01,South,Gizmo,36,10800 +2025-02-02,West,Gadget,43,21500 +2025-02-02,East,Widget,50,10000 +2025-02-02,Central,Gizmo,57,17100 +2025-02-03,South,Gadget,21,10500 +2025-02-03,West,Widget,28,5600 +2025-02-03,East,Gizmo,35,10500 +2025-02-03,Central,Gadget,42,21000 +2025-02-04,South,Widget,49,9800 +2025-02-04,West,Gizmo,56,16800 +2025-02-04,East,Gadget,20,10000 +2025-02-05,Central,Widget,27,5400 +2025-02-05,South,Gizmo,34,10200 +2025-02-05,West,Gadget,41,20500 +2025-02-06,East,Widget,48,9600 +2025-02-06,Central,Gizmo,55,16500 +2025-02-06,South,Gadget,19,9500 +2025-02-06,West,Widget,26,5200 +2025-02-07,East,Gizmo,33,9900 +2025-02-07,Central,Gadget,40,20000 +2025-02-07,South,Widget,47,9400 +2025-02-08,West,Gizmo,54,16200 +2025-02-08,East,Gadget,18,9000 +2025-02-08,Central,Widget,25,5000 +2025-02-08,South,Gizmo,32,9600 +2025-02-09,West,Gadget,39,19500 +2025-02-09,East,Widget,46,9200 +2025-02-09,Central,Gizmo,53,15900 +2025-02-10,South,Gadget,60,30000 +2025-02-10,West,Widget,24,4800 +2025-02-10,East,Gizmo,31,9300 +2025-02-11,Central,Gadget,38,19000 +2025-02-11,South,Widget,45,9000 +2025-02-11,West,Gizmo,52,15600 +2025-02-11,East,Gadget,59,29500 +2025-02-12,Central,Widget,23,4600 +2025-02-12,South,Gizmo,30,9000 +2025-02-12,West,Gadget,37,18500 +2025-02-13,East,Widget,44,8800 +2025-02-13,Central,Gizmo,51,15300 +2025-02-13,South,Gadget,58,29000 +2025-02-14,West,Widget,22,4400 +2025-02-14,East,Gizmo,29,8700 +2025-02-14,Central,Gadget,36,18000 +2025-02-14,South,Widget,43,8600 +2025-02-15,West,Gizmo,50,15000 +2025-02-15,East,Gadget,57,28500 +2025-02-15,Central,Widget,21,4200 +2025-02-16,South,Gizmo,28,8400 +2025-02-16,West,Gadget,35,17500 +2025-02-16,East,Widget,42,8400 +2025-02-17,Central,Gizmo,49,14700 +2025-02-17,South,Gadget,56,28000 +2025-02-17,West,Widget,20,4000 +2025-02-17,East,Gizmo,27,8100 +2025-02-18,Central,Gadget,34,17000 +2025-02-18,South,Widget,41,8200 +2025-02-18,West,Gizmo,48,14400 +2025-02-19,East,Gadget,55,27500 +2025-02-19,Central,Widget,19,3800 +2025-02-19,South,Gizmo,26,7800 +2025-02-20,West,Gadget,33,16500 +2025-02-20,East,Widget,40,8000 +2025-02-20,Central,Gizmo,47,14100 +2025-02-20,South,Gadget,54,27000 +2025-02-21,West,Widget,18,3600 +2025-02-21,East,Gizmo,25,7500 +2025-02-21,Central,Gadget,32,16000 +2025-02-22,South,Widget,39,7800 +2025-02-22,West,Gizmo,46,13800 +2025-02-22,East,Gadget,53,26500 +2025-02-22,Central,Widget,60,12000 +2025-02-23,South,Gizmo,24,7200 +2025-02-23,West,Gadget,31,15500 +2025-02-23,East,Widget,38,7600 +2025-02-24,Central,Gizmo,45,13500 +2025-02-24,South,Gadget,52,26000 +2025-02-24,West,Widget,59,11800 +2025-02-25,East,Gizmo,23,6900 +2025-02-25,Central,Gadget,30,15000 +2025-02-25,South,Widget,37,7400 +2025-02-25,West,Gizmo,44,13200 +2025-02-26,East,Gadget,51,25500 +2025-02-26,Central,Widget,58,11600 +2025-02-26,South,Gizmo,22,6600 +2025-02-27,West,Gadget,29,14500 +2025-02-27,East,Widget,36,7200 +2025-02-27,Central,Gizmo,43,12900 +2025-02-28,South,Gadget,50,25000 +2025-02-28,West,Widget,57,11400 +2025-02-28,East,Gizmo,21,6300 +2025-02-28,Central,Gadget,28,14000 +2025-03-01,South,Widget,35,7000 +2025-03-01,West,Gizmo,60,18000 +2025-03-01,East,Gadget,53,26500 +2025-03-02,Central,Widget,58,11600 +2025-03-02,South,Gizmo,20,6000 +2025-03-02,West,Gadget,45,22500 +2025-03-03,East,Widget,38,7600 +2025-03-03,Central,Gizmo,43,12900 +2025-03-03,South,Gadget,48,24000 +2025-03-03,West,Widget,73,14600 +2025-03-04,East,Gizmo,23,6900 +2025-03-04,Central,Gadget,28,14000 +2025-03-04,South,Widget,33,6600 +2025-03-05,West,Gizmo,58,17400 +2025-03-05,East,Gadget,51,25500 +2025-03-05,Central,Widget,56,11200 +2025-03-05,South,Gizmo,18,5400 +2025-03-06,West,Gadget,43,21500 +2025-03-06,East,Widget,36,7200 +2025-03-06,Central,Gizmo,41,12300 +2025-03-07,South,Gadget,46,23000 +2025-03-07,West,Widget,71,14200 +2025-03-07,East,Gizmo,64,19200 +2025-03-08,Central,Gadget,26,13000 +2025-03-08,South,Widget,31,6200 +2025-03-08,West,Gizmo,56,16800 +2025-03-08,East,Gadget,49,24500 +2025-03-09,Central,Widget,54,10800 +2025-03-09,South,Gizmo,59,17700 +2025-03-09,West,Gadget,41,20500 +2025-03-10,East,Widget,34,6800 +2025-03-10,Central,Gizmo,39,11700 +2025-03-10,South,Gadget,44,22000 +2025-03-11,West,Widget,69,13800 +2025-03-11,East,Gizmo,62,18600 +2025-03-11,Central,Gadget,24,12000 +2025-03-11,South,Widget,29,5800 +2025-03-12,West,Gizmo,54,16200 +2025-03-12,East,Gadget,47,23500 +2025-03-12,Central,Widget,52,10400 +2025-03-13,South,Gizmo,57,17100 +2025-03-13,West,Gadget,39,19500 +2025-03-13,East,Widget,32,6400 +2025-03-14,Central,Gizmo,37,11100 +2025-03-14,South,Gadget,42,21000 +2025-03-14,West,Widget,67,13400 +2025-03-14,East,Gizmo,60,18000 +2025-03-15,Central,Gadget,22,11000 +2025-03-15,South,Widget,27,5400 +2025-03-15,West,Gizmo,52,15600 +2025-03-16,East,Gadget,45,22500 +2025-03-16,Central,Widget,50,10000 +2025-03-16,South,Gizmo,55,16500 +2025-03-17,West,Gadget,37,18500 +2025-03-17,East,Widget,30,6000 +2025-03-17,Central,Gizmo,35,10500 +2025-03-17,South,Gadget,40,20000 +2025-03-18,West,Widget,65,13000 +2025-03-18,East,Gizmo,58,17400 +2025-03-18,Central,Gadget,20,10000 +2025-03-19,South,Widget,25,5000 +2025-03-19,West,Gizmo,50,15000 +2025-03-19,East,Gadget,43,21500 +2025-03-19,Central,Widget,48,9600 +2025-03-20,South,Gizmo,53,15900 +2025-03-20,West,Gadget,78,39000 +2025-03-20,East,Widget,28,5600 +2025-03-21,Central,Gizmo,33,9900 +2025-03-21,South,Gadget,38,19000 +2025-03-21,West,Widget,63,12600 +2025-03-22,East,Gizmo,56,16800 +2025-03-22,Central,Gadget,61,30500 +2025-03-22,South,Widget,23,4600 +2025-03-22,West,Gizmo,48,14400 +2025-03-23,East,Gadget,41,20500 +2025-03-23,Central,Widget,46,9200 +2025-03-23,South,Gizmo,51,15300 +2025-03-24,West,Gadget,76,38000 +2025-03-24,East,Widget,26,5200 +2025-03-24,Central,Gizmo,31,9300 +2025-03-25,South,Gadget,36,18000 +2025-03-25,West,Widget,61,12200 +2025-03-25,East,Gizmo,54,16200 +2025-03-25,Central,Gadget,59,29500 +2025-03-26,South,Widget,21,4200 +2025-03-26,West,Gizmo,46,13800 +2025-03-26,East,Gadget,39,19500 +2025-03-27,Central,Widget,44,8800 +2025-03-27,South,Gizmo,49,14700 +2025-03-27,West,Gadget,74,37000 +2025-03-28,East,Widget,24,4800 +2025-03-28,Central,Gizmo,29,8700 +2025-03-28,South,Gadget,34,17000 +2025-03-28,West,Widget,59,11800 +2025-03-29,East,Gizmo,52,15600 +2025-03-29,Central,Gadget,57,28500 +2025-03-29,South,Widget,19,3800 +2025-03-30,West,Gizmo,44,13200 +2025-03-30,East,Gadget,37,18500 +2025-03-30,Central,Widget,42,8400 +2025-03-31,South,Gizmo,47,14100 diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/infra/abbreviations.json b/preview-features/on-demand-sandboxes/samples/dotnet/infra/abbreviations.json new file mode 100644 index 00000000..10424d3d --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/infra/abbreviations.json @@ -0,0 +1,17 @@ +{ + "analysisServicesServers": "as", + "apiManagementService": "apim-", + "appConfigurationStores": "appcs-", + "appManagedEnvironments": "cae-", + "appContainerApps": "ca-", + "authorizationPolicyDefinitions": "policy-", + "automationAutomationAccounts": "aa-", + "containerRegistryRegistries": "cr", + "containerServiceManagedClusters": "aks-", + "networkVirtualNetworks": "vnet-", + "networkNetworkSecurityGroups": "nsg-", + "managedIdentityUserAssignedIdentities": "id-", + "resourcesResourceGroups": "rg-", + "dts": "dts-", + "taskhub": "taskhub-" +} diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/infra/app/federated-identity.bicep b/preview-features/on-demand-sandboxes/samples/dotnet/infra/app/federated-identity.bicep new file mode 100644 index 00000000..18cadeaa --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/infra/app/federated-identity.bicep @@ -0,0 +1,34 @@ +metadata description = 'Creates a federated identity credential for AKS workload identity.' + +@description('The name of the user-assigned managed identity') +param identityName string + +@description('The name of the federated credential') +param federatedCredentialName string + +@description('The OIDC issuer URL from the AKS cluster') +param oidcIssuerUrl string + +@description('The Kubernetes namespace for the service account') +param serviceAccountNamespace string = 'default' + +@description('The Kubernetes service account name') +param serviceAccountName string + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = { + name: identityName +} + +// Federated identity credential binds a Kubernetes service account to the +// user-assigned managed identity, enabling pods to authenticate as that identity. +resource federatedCredential 'Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials@2024-11-30' = { + parent: identity + name: federatedCredentialName + properties: { + issuer: oidcIssuerUrl + subject: 'system:serviceaccount:${serviceAccountNamespace}:${serviceAccountName}' + audiences: [ + 'api://AzureADTokenExchange' + ] + } +} diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/infra/app/openai.bicep b/preview-features/on-demand-sandboxes/samples/dotnet/infra/app/openai.bicep new file mode 100644 index 00000000..bf724f67 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/infra/app/openai.bicep @@ -0,0 +1,92 @@ +metadata description = 'Creates an Azure OpenAI account with a chat model deployment for the GenerateCode activity.' + +@description('Name of the Azure OpenAI (Cognitive Services) account') +param name string + +@description('Azure region for the Azure OpenAI account') +param location string = resourceGroup().location + +@description('Tags to apply to the account') +param tags object = {} + +@description('Custom subdomain used to build the account endpoint') +param customSubDomainName string = name + +@description('Name of the chat model deployment the app calls') +param chatDeploymentName string = 'gpt-5.1' + +@description('Chat model name') +param chatModelName string = 'gpt-5.1' + +@description('Chat model version') +param chatModelVersion string = '2025-11-13' + +@description('Deployment SKU name for the chat model') +param chatDeploymentSkuName string = 'GlobalStandard' + +@description('Tokens-per-minute capacity (in thousands) for the chat deployment') +param chatDeploymentCapacity int = 30 + +@description('Principal id of the workload identity that calls Azure OpenAI') +param workloadPrincipalId string + +@description('Principal id of the deploying user (optional)') +param userPrincipalId string = '' + +resource account 'Microsoft.CognitiveServices/accounts@2024-10-01' = { + name: name + location: location + tags: tags + kind: 'OpenAI' + sku: { + name: 'S0' + } + properties: { + customSubDomainName: customSubDomainName + publicNetworkAccess: 'Enabled' + disableLocalAuth: true + } +} + +resource chatDeployment 'Microsoft.CognitiveServices/accounts/deployments@2024-10-01' = { + parent: account + name: chatDeploymentName + sku: { + name: chatDeploymentSkuName + capacity: chatDeploymentCapacity + } + properties: { + model: { + format: 'OpenAI' + name: chatModelName + version: chatModelVersion + } + } +} + +// Cognitive Services OpenAI User role (5e0bd9bd-7b93-4f28-af87-19fc36ad61bd) +var openAiUserRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd') + +resource workloadOpenAiAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: account + name: guid(account.id, workloadPrincipalId, openAiUserRole) + properties: { + roleDefinitionId: openAiUserRole + principalId: workloadPrincipalId + principalType: 'ServicePrincipal' + } +} + +resource userOpenAiAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(userPrincipalId)) { + scope: account + name: guid(account.id, userPrincipalId, openAiUserRole) + properties: { + roleDefinitionId: openAiUserRole + principalId: userPrincipalId + principalType: 'User' + } +} + +output endpoint string = account.properties.endpoint +output name string = account.name +output chatDeploymentName string = chatDeployment.name diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/infra/app/scheduler-access.bicep b/preview-features/on-demand-sandboxes/samples/dotnet/infra/app/scheduler-access.bicep new file mode 100644 index 00000000..aea4ebf5 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/infra/app/scheduler-access.bicep @@ -0,0 +1,53 @@ +metadata description = 'Wires up an existing Durable Task Scheduler: ensures the task hub, grants data-plane access, and surfaces the endpoint. Deployed into the scheduler\'s resource group.' + +@description('Name of the existing Durable Task Scheduler') +param schedulerName string + +@description('Name of the task hub to use (created if it does not already exist)') +param taskHubName string = 'default' + +@description('Principal id of the workload identity that connects to DTS') +param workloadPrincipalId string + +@description('Principal id of the deploying user, for dashboard access (optional)') +param userPrincipalId string = '' + +// The scheduler is created and patched out of band (preview feature enablement + +// managed-identity attach), so it is referenced as an existing resource here. +resource scheduler 'Microsoft.DurableTask/schedulers@2025-11-01' existing = { + name: schedulerName +} + +// Ensure the task hub the app uses exists on the scheduler. +resource taskHub 'Microsoft.DurableTask/schedulers/taskhubs@2025-11-01' = { + parent: scheduler + name: taskHubName +} + +// Durable Task Data Contributor (0ad04412-c4d5-4796-b79c-f76d14c8d402) — data-plane +// access used by the orchestrator app and the sandbox worker to connect to DTS. +var dtsDataRole = '0ad04412-c4d5-4796-b79c-f76d14c8d402' + +resource workloadDtsAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: scheduler + name: guid(scheduler.id, workloadPrincipalId, dtsDataRole) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', dtsDataRole) + principalId: workloadPrincipalId + principalType: 'ServicePrincipal' + } +} + +resource userDtsAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(userPrincipalId)) { + scope: scheduler + name: guid(scheduler.id, userPrincipalId, dtsDataRole) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', dtsDataRole) + principalId: userPrincipalId + principalType: 'User' + } +} + +output endpoint string = scheduler.properties.endpoint +output taskHubName string = taskHub.name +output schedulerName string = scheduler.name diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/infra/app/user-assigned-identity.bicep b/preview-features/on-demand-sandboxes/samples/dotnet/infra/app/user-assigned-identity.bicep new file mode 100644 index 00000000..0583ab8d --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/infra/app/user-assigned-identity.bicep @@ -0,0 +1,17 @@ +metadata description = 'Creates a Microsoft Entra user-assigned identity.' + +param name string +param location string = resourceGroup().location +param tags object = {} + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: name + location: location + tags: tags +} + +output name string = identity.name +output resourceId string = identity.id +output principalId string = identity.properties.principalId +output clientId string = identity.properties.clientId +output tenantId string = identity.properties.tenantId diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/infra/core/host/aks-cluster.bicep b/preview-features/on-demand-sandboxes/samples/dotnet/infra/core/host/aks-cluster.bicep new file mode 100644 index 00000000..d2a767e3 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/infra/core/host/aks-cluster.bicep @@ -0,0 +1,94 @@ +metadata description = 'Creates an Azure Kubernetes Service (AKS) cluster.' + +@description('The name of the AKS cluster') +param name string + +@description('The Azure region for the AKS cluster') +param location string = resourceGroup().location + +@description('Tags to apply to the AKS cluster') +param tags object = {} + +@description('The Kubernetes version for the AKS cluster') +param kubernetesVersion string = '1.32' + +@description('The VM size for the default node pool') +param agentVMSize string = 'standard_d4s_v5' + +@description('The number of nodes in the default node pool') +param agentCount int = 2 + +@description('The minimum number of nodes for autoscaling') +param agentMinCount int = 1 + +@description('The maximum number of nodes for autoscaling') +param agentMaxCount int = 5 + +@description('The subnet resource ID for the AKS nodes') +param subnetId string = '' + +@description('The name of the container registry to attach') +param containerRegistryName string = '' + +@description('Enable OIDC issuer for workload identity') +param enableOidcIssuer bool = true + +@description('Enable workload identity') +param enableWorkloadIdentity bool = true + +// AKS cluster with workload identity and OIDC issuer enabled +resource aksCluster 'Microsoft.ContainerService/managedClusters@2024-09-01' = { + name: name + location: location + tags: tags + identity: { + type: 'SystemAssigned' + } + properties: { + kubernetesVersion: kubernetesVersion + dnsPrefix: name + enableRBAC: true + agentPoolProfiles: [ + { + name: 'system' + count: agentCount + vmSize: agentVMSize + mode: 'System' + osType: 'Linux' + osSKU: 'AzureLinux' + enableAutoScaling: true + minCount: agentMinCount + maxCount: agentMaxCount + vnetSubnetID: !empty(subnetId) ? subnetId : null + } + ] + networkProfile: { + networkPlugin: 'azure' + networkPolicy: 'azure' + serviceCidr: '10.1.0.0/16' + dnsServiceIP: '10.1.0.10' + } + oidcIssuerProfile: { + enabled: enableOidcIssuer + } + securityProfile: { + workloadIdentity: { + enabled: enableWorkloadIdentity + } + } + } +} + +// Grant AKS kubelet identity AcrPull access to the container registry +module registryAccess '../security/registry-access.bicep' = if (!empty(containerRegistryName)) { + name: 'aks-registry-access' + params: { + containerRegistryName: containerRegistryName + principalId: aksCluster.properties.identityProfile.kubeletidentity.objectId + } +} + +output clusterName string = aksCluster.name +output clusterId string = aksCluster.id +output oidcIssuerUrl string = aksCluster.properties.oidcIssuerProfile.issuerURL +output kubeletIdentityObjectId string = aksCluster.properties.identityProfile.kubeletidentity.objectId diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/infra/core/host/container-registry.bicep b/preview-features/on-demand-sandboxes/samples/dotnet/infra/core/host/container-registry.bicep new file mode 100644 index 00000000..4cd24453 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/infra/core/host/container-registry.bicep @@ -0,0 +1,59 @@ +metadata description = 'Creates an Azure Container Registry.' +param name string +param location string = resourceGroup().location +param tags object = {} + +@description('Indicates whether admin user is enabled') +param adminUserEnabled bool = false + +@description('Indicates whether anonymous pull is enabled') +param anonymousPullEnabled bool = false + +@description('SKU settings') +param sku object = { + name: 'Standard' +} + +@description('The log analytics workspace ID used for logging and monitoring') +param workspaceId string = '' + +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = { + name: name + location: location + tags: tags + sku: sku + properties: { + adminUserEnabled: adminUserEnabled + anonymousPullEnabled: anonymousPullEnabled + publicNetworkAccess: 'Enabled' + } +} + +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { + name: 'registry-diagnostics' + scope: containerRegistry + properties: { + workspaceId: workspaceId + logs: [ + { + category: 'ContainerRegistryRepositoryEvents' + enabled: true + } + { + category: 'ContainerRegistryLoginEvents' + enabled: true + } + ] + metrics: [ + { + category: 'AllMetrics' + enabled: true + timeGrain: 'PT1M' + } + ] + } +} + +output id string = containerRegistry.id +output loginServer string = containerRegistry.properties.loginServer +output name string = containerRegistry.name diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/infra/core/networking/vnet.bicep b/preview-features/on-demand-sandboxes/samples/dotnet/infra/core/networking/vnet.bicep new file mode 100644 index 00000000..46764a8e --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/infra/core/networking/vnet.bicep @@ -0,0 +1,40 @@ +@description('The name of the Virtual Network') +param name string + +@description('The Azure region where the Virtual Network should exist') +param location string = resourceGroup().location + +@description('Optional tags for the resources') +param tags object = {} + +@description('The address prefixes of the Virtual Network') +param addressPrefixes array = ['10.0.0.0/16'] + +@description('The subnets to create in the Virtual Network') +param subnets array = [ + { + name: 'aks-subnet' + properties: { + addressPrefix: '10.0.0.0/21' + delegations: [] + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Enabled' + } + } +] + +resource vnet 'Microsoft.Network/virtualNetworks@2023-11-01' = { + name: name + location: location + tags: tags + properties: { + addressSpace: { + addressPrefixes: addressPrefixes + } + subnets: subnets + } +} + +output id string = vnet.id +output name string = vnet.name +output aksSubnetId string = resourceId('Microsoft.Network/virtualNetworks/subnets', name, 'aks-subnet') diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/infra/core/security/registry-access.bicep b/preview-features/on-demand-sandboxes/samples/dotnet/infra/core/security/registry-access.bicep new file mode 100644 index 00000000..f977b9f1 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/infra/core/security/registry-access.bicep @@ -0,0 +1,19 @@ +metadata description = 'Assigns ACR Pull permissions to access an Azure Container Registry.' +param containerRegistryName string +param principalId string + +var acrPullRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + +resource aksAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: containerRegistry // Use when specifying a scope that is different than the deployment scope + name: guid(subscription().id, resourceGroup().id, principalId, acrPullRole) + properties: { + roleDefinitionId: acrPullRole + principalType: 'ServicePrincipal' + principalId: principalId + } +} + +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' existing = { + name: containerRegistryName +} diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/infra/core/security/role.bicep b/preview-features/on-demand-sandboxes/samples/dotnet/infra/core/security/role.bicep new file mode 100644 index 00000000..0b30cfd3 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/infra/core/security/role.bicep @@ -0,0 +1,21 @@ +metadata description = 'Creates a role assignment for a service principal.' +param principalId string + +@allowed([ + 'Device' + 'ForeignGroup' + 'Group' + 'ServicePrincipal' + 'User' +]) +param principalType string = 'ServicePrincipal' +param roleDefinitionId string + +resource role 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().id, resourceGroup().id, principalId, roleDefinitionId) + properties: { + principalId: principalId + principalType: principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId) + } +} diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/infra/main.bicep b/preview-features/on-demand-sandboxes/samples/dotnet/infra/main.bicep new file mode 100644 index 00000000..66ee57f2 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/infra/main.bicep @@ -0,0 +1,222 @@ +targetScope = 'subscription' + +// Provisions the Azure resources to run the On-demand Sandboxes code-interpreter demo +// in the cloud: the orchestrator (main-app) runs on AKS, and DTS starts the sandbox +// worker image on demand. The Durable Task Scheduler is NOT created here — it is passed +// in as an existing resource (schedulerName + schedulerResourceGroupName) because it is +// patched out of band to enable the On-demand Sandboxes preview feature. + +@minLength(1) +@maxLength(64) +@description('Name of the environment which is used to generate a short unique hash used in all resources.') +param environmentName string + +@minLength(1) +@description('Primary location for all resources') +param location string + +@description('Id of the user or app to assign application roles') +param principalId string = '' + +// AKS parameters +param aksClusterName string = '' +param kubernetesVersion string = '1.32' +param aksVmSize string = 'standard_d4s_v5' +param aksNodeCount int = 2 + +// Container registry parameters +param containerRegistryName string = '' + +// Existing Durable Task Scheduler (created + patched out of band for the preview). +@description('Name of the existing Durable Task Scheduler to use as the durable backend.') +param schedulerName string + +@description('Resource group that contains the existing Durable Task Scheduler.') +param schedulerResourceGroupName string + +@description('Task hub to use on the scheduler (created if it does not exist).') +param taskHubName string = 'default' + +// Azure OpenAI parameters (used by the in-process GenerateCode activity). +param openAiServiceName string = '' +param openAiLocation string = 'eastus' +param chatDeploymentName string = 'gpt-5.1' +param chatModelName string = 'gpt-5.1' +param chatModelVersion string = '2025-11-13' +param chatDeploymentSkuName string = 'GlobalStandard' +param chatDeploymentCapacity int = 30 + +// Service name (must match the service name in azure.yaml). +param mainAppServiceName string = 'mainapp' + +// Optional resource group name override +param resourceGroupName string = '' + +var abbrs = loadJsonContent('./abbreviations.json') + +var tags = { + 'azd-env-name': environmentName +} + +var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) + +resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}' + location: location + tags: tags +} + +// ============================ +// Identity +// ============================ + +// A single user-assigned managed identity is used for everything in this sample: +// - AKS workload identity (the app authenticates to DTS and Azure OpenAI) +// - DTS image pull for the sandbox (AcrPull on the registry) +// - the sandbox worker connecting back to DTS +module identity './app/user-assigned-identity.bicep' = { + scope: rg + params: { + name: '${abbrs.managedIdentityUserAssignedIdentities}${resourceToken}' + location: location + tags: tags + } +} + +// ============================ +// Networking +// ============================ + +module vnet './core/networking/vnet.bicep' = { + scope: rg + params: { + name: '${abbrs.networkVirtualNetworks}${resourceToken}' + location: location + tags: tags + } +} + +// ============================ +// Container Registry +// ============================ + +module containerRegistry './core/host/container-registry.bicep' = { + name: 'container-registry' + scope: rg + params: { + name: !empty(containerRegistryName) ? containerRegistryName : '${abbrs.containerRegistryRegistries}${resourceToken}' + location: location + tags: tags + sku: { + name: 'Standard' + } + anonymousPullEnabled: false + } +} + +// Grant the managed identity AcrPull so DTS can pull the sandbox worker image. +module identityAcrPull './core/security/registry-access.bicep' = { + name: 'identity-acr-pull' + scope: rg + params: { + containerRegistryName: containerRegistry.outputs.name + principalId: identity.outputs.principalId + } +} + +// ============================ +// AKS Cluster +// ============================ + +module aksCluster './core/host/aks-cluster.bicep' = { + name: 'aks-cluster' + scope: rg + params: { + name: !empty(aksClusterName) ? aksClusterName : '${abbrs.containerServiceManagedClusters}${resourceToken}' + location: location + tags: tags + kubernetesVersion: kubernetesVersion + agentVMSize: aksVmSize + agentCount: aksNodeCount + subnetId: vnet.outputs.aksSubnetId + containerRegistryName: containerRegistry.outputs.name + } +} + +// ============================ +// Workload Identity Federation +// ============================ + +module federatedIdentityMainApp './app/federated-identity.bicep' = { + name: 'federated-identity-mainapp' + scope: rg + params: { + identityName: identity.outputs.name + federatedCredentialName: 'fed-${mainAppServiceName}' + oidcIssuerUrl: aksCluster.outputs.oidcIssuerUrl + serviceAccountNamespace: 'default' + serviceAccountName: mainAppServiceName + } +} + +// ============================ +// Azure OpenAI +// ============================ + +module openAi './app/openai.bicep' = { + name: 'openai' + scope: rg + params: { + name: !empty(openAiServiceName) ? openAiServiceName : 'aoai-${resourceToken}' + location: openAiLocation + tags: tags + chatDeploymentName: chatDeploymentName + chatModelName: chatModelName + chatModelVersion: chatModelVersion + chatDeploymentSkuName: chatDeploymentSkuName + chatDeploymentCapacity: chatDeploymentCapacity + workloadPrincipalId: identity.outputs.principalId + userPrincipalId: principalId + } +} + +// ============================ +// Existing Durable Task Scheduler +// ============================ + +module schedulerAccess './app/scheduler-access.bicep' = { + name: 'scheduler-access' + scope: resourceGroup(schedulerResourceGroupName) + params: { + schedulerName: schedulerName + taskHubName: taskHubName + workloadPrincipalId: identity.outputs.principalId + userPrincipalId: principalId + } +} + +// ============================ +// Outputs +// ============================ + +output AZURE_LOCATION string = location +output AZURE_TENANT_ID string = tenant().tenantId + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer +output AZURE_CONTAINER_REGISTRY_NAME string = containerRegistry.outputs.name + +output AZURE_AKS_CLUSTER_NAME string = aksCluster.outputs.clusterName + +output AZURE_USER_ASSIGNED_IDENTITY_NAME string = identity.outputs.name +output AZURE_USER_ASSIGNED_IDENTITY_CLIENT_ID string = identity.outputs.clientId +output AZURE_USER_ASSIGNED_IDENTITY_RESOURCE_ID string = identity.outputs.resourceId + +// Scheduler details (DTS_ENDPOINT/DTS_TASK_HUB feed the app; name/RG feed the +// postprovision identity-attach hook). +output DTS_ENDPOINT string = schedulerAccess.outputs.endpoint +output DTS_TASK_HUB string = schedulerAccess.outputs.taskHubName +output DTS_SCHEDULER_NAME string = schedulerName +output DTS_SCHEDULER_RESOURCE_GROUP string = schedulerResourceGroupName + +output AOAI_ENDPOINT string = openAi.outputs.endpoint +output AOAI_DEPLOYMENT string = openAi.outputs.chatDeploymentName diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/infra/main.parameters.json b/preview-features/on-demand-sandboxes/samples/dotnet/infra/main.parameters.json new file mode 100644 index 00000000..f98d2108 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/infra/main.parameters.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "principalId": { + "value": "${AZURE_PRINCIPAL_ID}" + }, + "schedulerName": { + "value": "${DTS_SCHEDULER_NAME}" + }, + "schedulerResourceGroupName": { + "value": "${DTS_SCHEDULER_RESOURCE_GROUP}" + }, + "taskHubName": { + "value": "${DTS_TASK_HUB=default}" + } + } +} diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/main-app/Activities.cs b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/Activities.cs new file mode 100644 index 00000000..dc83a0c9 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/Activities.cs @@ -0,0 +1,128 @@ +using System.ClientModel; +using System.Globalization; +using Azure.Identity; +using Microsoft.DurableTask; +using OpenAI.Chat; + +namespace Demo.Codegen.MainApp; + +/// +/// In-process activity. Calls Azure OpenAI to translate a natural-language question +/// into a self-contained pandas script that reads /tmp/data.csv and prints the answer. +/// +[DurableTask(TaskNames.GenerateCode)] +internal sealed class GenerateCodeActivity : TaskActivity +{ + const string SystemPrompt = """ + You are a Python code generator. Given a question about a sales dataset, + produce a single self-contained Python script that: + + 1. Reads /tmp/data.csv with pandas. The columns are: date, region, product, units, revenue. + 2. Assumes the CSV contains rows for exactly one region. + 3. Computes the total revenue for March 2025 in this subset. + 4. Prints ONLY the numeric revenue total to stdout. No code fences, no explanation, no commentary. + + Constraints: + - Use only the Python standard library and pandas. + - Do not access the network or filesystem outside /tmp. + - If there is no March 2025 data in this subset, print 0. + - Output must be plain text containing only the number. + + Respond with the Python script only. No markdown, no backticks. + """; + + public override async Task RunAsync(TaskActivityContext context, string question) + { + string endpoint = GetRequired("AOAI_ENDPOINT"); + string deployment = GetRequired("AOAI_DEPLOYMENT"); + + var client = new Azure.AI.OpenAI.AzureOpenAIClient( + new Uri(endpoint), + new DefaultAzureCredential()); + + ChatClient chat = client.GetChatClient(deployment); + + ChatCompletion completion = await chat.CompleteChatAsync( + new SystemChatMessage(SystemPrompt), + new UserChatMessage(question)); + + string code = StripCodeFences(completion.Content[0].Text ?? string.Empty); + int lineCount = code.Split('\n').Length; + Console.WriteLine($"[generate] AOAI returned {lineCount} lines of Python:"); + Console.WriteLine("---"); + Console.WriteLine(code); + Console.WriteLine("---"); + return code; + } + + static string GetRequired(string name) + => Environment.GetEnvironmentVariable(name) + ?? throw new InvalidOperationException($"Environment variable '{name}' is required."); + + static string StripCodeFences(string code) + { + string trimmed = code.Trim(); + if (trimmed.StartsWith("```", StringComparison.Ordinal)) + { + int firstNewline = trimmed.IndexOf('\n'); + if (firstNewline > 0) + { + trimmed = trimmed[(firstNewline + 1)..]; + } + + if (trimmed.EndsWith("```", StringComparison.Ordinal)) + { + trimmed = trimmed[..^3]; + } + } + + return trimmed.Trim(); + } +} + +/// +/// In-process activity. Wraps the sandboxed Python output in a friendly answer. +/// Kept deliberately simple - in a real app this might call the LLM again to +/// turn raw output into a sentence. +/// +[DurableTask(TaskNames.FormatAnswer)] +internal sealed class FormatAnswerActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, FormatAnswerInput input) + { + foreach (RegionExecutionResult result in input.Results) + { + if (result.Execution.ExitCode != 0) + { + return Task.FromResult( + $"Sandbox execution failed for region '{result.Region}' (exit code {result.Execution.ExitCode}): {result.Execution.Stderr}"); + } + } + + var totals = new List<(string Region, decimal Revenue)>(); + foreach (RegionExecutionResult result in input.Results) + { + string stdout = result.Execution.Stdout.Trim(); + if (!decimal.TryParse(stdout, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal revenue)) + { + return Task.FromResult( + $"Sandbox execution returned a non-numeric result for region '{result.Region}': {stdout}"); + } + + totals.Add((result.Region, revenue)); + } + + foreach ((string region, decimal revenue) in totals.OrderByDescending(total => total.Revenue)) + { + Console.WriteLine($"[fan-out] Region {region}: {revenue.ToString(CultureInfo.InvariantCulture)}"); + } + + string topRegion = totals + .OrderByDescending(total => total.Revenue) + .ThenBy(total => total.Region, StringComparer.Ordinal) + .First() + .Region; + + return Task.FromResult($"Q: {input.Question}\nA: {topRegion}"); + } +} diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/main-app/AnalyzeSalesOrchestrator.cs b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/AnalyzeSalesOrchestrator.cs new file mode 100644 index 00000000..c3725e9a --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/AnalyzeSalesOrchestrator.cs @@ -0,0 +1,80 @@ +using Microsoft.DurableTask; + +namespace Demo.Codegen.MainApp; + +/// +/// 3-step workflow that answers a question over a CSV using LLM-generated Python. +/// +[DurableTask(nameof(AnalyzeSalesOrchestrator))] +internal sealed class AnalyzeSalesOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, AnalyzeSalesInput input) + { + // Generate one chunk-friendly Python script up front and reuse it for every region. + string pythonCode = await context.CallActivityAsync( + TaskNames.GenerateCode, + input.Question); + + // Fan out: one sandbox execution per region-specific CSV partition. + RegionChunk[] chunks = SplitCsvByRegion(input.CsvData); + Task[] executions = chunks + .Select(chunk => context.CallActivityAsync( + TaskNames.ExecuteCode, + new ExecuteCodeInput(pythonCode, chunk.CsvData))) + .ToArray(); + + // Fan in: wait for every sandbox result, then hand the set to the formatter. + ExecuteCodeOutput[] results = await Task.WhenAll(executions); + RegionExecutionResult[] regionResults = chunks + .Zip(results, (chunk, execution) => new RegionExecutionResult(chunk.Region, execution)) + .ToArray(); + + return await context.CallActivityAsync( + TaskNames.FormatAnswer, + new FormatAnswerInput(input.Question, regionResults)); + } + + + + + + + + + static RegionChunk[] SplitCsvByRegion(string csvData) + { + // Partition the dataset deterministically so the same generated script can run once per region. + string normalized = csvData.Replace("\r\n", "\n", StringComparison.Ordinal); + string[] lines = normalized.Split('\n', StringSplitOptions.RemoveEmptyEntries); + if (lines.Length < 2) + { + return []; + } + + string header = lines[0]; + Dictionary> rowsByRegion = new(StringComparer.OrdinalIgnoreCase); + + foreach (string row in lines.Skip(1)) + { + string[] cells = row.Split(','); + if (cells.Length < 2) + { + continue; + } + + string region = cells[1].Trim(); + if (!rowsByRegion.TryGetValue(region, out List? rows)) + { + rows = []; + rowsByRegion.Add(region, rows); + } + + rows.Add(row); + } + + return rowsByRegion + .OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase) + .Select(pair => new RegionChunk(pair.Key, string.Join('\n', new[] { header }.Concat(pair.Value)))) + .ToArray(); + } +} diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/main-app/Containerfile b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/Containerfile new file mode 100644 index 00000000..c0a2daf7 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/Containerfile @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1.7 +# +# Builds the orchestrator (main-app) image deployed to AKS. Build from the .NET sample +# root so Directory.Build.props and the data/ folder are in the build context: +# +# docker build --platform linux/amd64 -f main-app/Containerfile -t /main-app: . +# +# --platform linux/amd64 is required: the Grpc.Tools linux_arm64 protoc binary segfaults +# under Docker's arm64 emulation on Apple Silicon. amd64 works under Rosetta and matches +# what the cluster runs. + +FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG TARGETARCH + +WORKDIR /src/dts-ondemand-sandbox-codegen-demo +COPY . . + +WORKDIR /src/dts-ondemand-sandbox-codegen-demo/main-app +RUN case "$TARGETARCH" in \ + amd64) runtime_identifier=linux-x64 ;; \ + arm64) runtime_identifier=linux-arm64 ;; \ + *) echo "Unsupported target architecture: $TARGETARCH" >&2; exit 1 ;; \ + esac \ + && dotnet publish main-app.csproj \ + -c Release \ + -r "$runtime_identifier" \ + --self-contained false \ + -o /app/publish \ + /p:DebugSymbols=false \ + /p:DebugType=None \ + && find /app/publish -type f \( -name '*.xml' -o -name '*.pdb' \) -delete + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +COPY --from=build /app/publish ./ +# Ship the sample dataset and point the app at it. +COPY data ./data +ENV DEMO_CSV_PATH=/app/data/sales_q1.csv + +ENTRYPOINT ["dotnet", "CodegenMainApp.dll"] diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/main-app/Contracts.cs b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/Contracts.cs new file mode 100644 index 00000000..2520df72 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/Contracts.cs @@ -0,0 +1,32 @@ +namespace Demo.Codegen.MainApp; + +/// +/// Input passed from the orchestrator to the on-demand sandbox ExecuteCode activity. +/// The activity writes the CSV to disk and runs the Python script against it. +/// +public sealed record ExecuteCodeInput(string PythonCode, string CsvData); + +/// +/// Output of the sandboxed Python execution. +/// +public sealed record ExecuteCodeOutput(string Stdout, string Stderr, int ExitCode); + +/// +/// A deterministic CSV partition used to fan out on-demand sandbox executions. +/// +public sealed record RegionChunk(string Region, string CsvData); + +/// +/// Captures the sandbox execution result for a single region partition. +/// +public sealed record RegionExecutionResult(string Region, ExecuteCodeOutput Execution); + +/// +/// Input to the orchestrator: a natural-language question and the CSV to answer it over. +/// +public sealed record AnalyzeSalesInput(string Question, string CsvData); + +/// +/// Input to the final formatting activity after fan-out/fan-in completes. +/// +public sealed record FormatAnswerInput(string Question, RegionExecutionResult[] Results); diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/main-app/Program.cs b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/Program.cs new file mode 100644 index 00000000..1f36d793 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/Program.cs @@ -0,0 +1,120 @@ +using Azure.Core; +using Azure.Identity; +using Demo.Codegen.MainApp; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +string endpoint = Environment.GetEnvironmentVariable("DTS_ENDPOINT") + ?? throw new InvalidOperationException("DTS_ENDPOINT is required."); +string taskHub = Environment.GetEnvironmentVariable("DTS_TASK_HUB") ?? "default"; +string aoaiEndpoint = Environment.GetEnvironmentVariable("AOAI_ENDPOINT") + ?? throw new InvalidOperationException("AOAI_ENDPOINT is required."); +string csvPath = Environment.GetEnvironmentVariable("DEMO_CSV_PATH") + ?? Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "data", "sales_q1.csv"); +string question = args.Length > 0 + ? string.Join(' ', args) + : "Which region had the highest total revenue in March 2025?"; + +if (!File.Exists(csvPath)) +{ + Console.Error.WriteLine($"CSV file not found at: {csvPath}"); + return 1; +} + +string csvData = await File.ReadAllTextAsync(csvPath); +TokenCredential credential = new DefaultAzureCredential(); + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Logging.AddSimpleConsole(options => +{ + options.SingleLine = true; + options.UseUtcTimestamp = true; + options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ "; +}); + +builder.Services.AddDurableTaskWorker(workerBuilder => +{ + workerBuilder.AddTasks(tasks => tasks.AddAllGeneratedTasks()); + workerBuilder.UseWorkItemFilters(); + workerBuilder.UseDurableTaskScheduler(options => + { + options.EndpointAddress = endpoint; + options.TaskHubName = taskHub; + options.Credential = credential; + }); +}); + +builder.Services.AddDurableTaskClient(clientBuilder => +{ + clientBuilder.UseDurableTaskScheduler(options => + { + options.EndpointAddress = endpoint; + options.TaskHubName = taskHub; + options.Credential = credential; + }); +}); + +// Profiles are declared in WorkerProfiles.cs via [SandboxWorkerProfile]. +builder.Services.AddDurableTaskSchedulerSandboxActivitiesClient(); + +using IHost host = builder.Build(); +await host.StartAsync(); + +// Declare the sandbox worker profiles with DTS so it can route ExecuteCode to a sandbox. +SandboxActivitiesClient sandboxActivitiesClient = host.Services.GetRequiredService(); +await sandboxActivitiesClient.EnableSandboxActivitiesAsync(); + +DurableTaskClient client = host.Services.GetRequiredService(); + +// Print demo context so the audience understands the dataset before orchestration starts. +string[] allLines = File.ReadAllLines(csvPath); +string[] headers = allLines[0].Split(','); +int rowCount = allLines.Length - 1; +string columnList = string.Join(", ", headers); +Console.WriteLine($"[demo] Dataset: {Path.GetFullPath(csvPath)}"); +Console.WriteLine($"[demo] {rowCount} rows × {headers.Length} columns: [{columnList}]"); +Console.WriteLine($"[demo] Preview (first 3 rows):"); +int previewCount = Math.Min(3, rowCount); +for (int i = 1; i <= previewCount; i++) +{ + string[] cells = allLines[i].Split(','); + Console.WriteLine(" " + string.Join(" ", cells)); +} +Console.WriteLine($"[demo] Question: {question}"); +Console.WriteLine(); + +string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( + TaskNames.AnalyzeSalesOrchestrator, + input: new AnalyzeSalesInput(question, csvData)); + +Console.WriteLine($"Started orchestration: {instanceId}"); + +OrchestrationMetadata? result = await client.WaitForInstanceCompletionAsync( + instanceId, + getInputsAndOutputs: true); + +Console.WriteLine($"Status: {result?.RuntimeStatus}"); +Console.WriteLine(); + +if (result?.FailureDetails is { } failure) +{ + Console.WriteLine($"[failure] {failure.ErrorType}: {failure.ErrorMessage}"); + if (!string.IsNullOrWhiteSpace(failure.StackTrace)) + { + Console.WriteLine(failure.StackTrace); + } +} +else +{ + Console.WriteLine(result?.ReadOutputAs() ?? ""); +} + +await host.StopAsync(); +return 0; + diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/main-app/TaskNames.cs b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/TaskNames.cs new file mode 100644 index 00000000..570e3757 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/TaskNames.cs @@ -0,0 +1,13 @@ +namespace Demo.Codegen.MainApp; + +/// +/// Activity and orchestrator names shared with the sandbox worker. +/// The sandbox worker registers with the same string. +/// +internal static class TaskNames +{ + public const string AnalyzeSalesOrchestrator = nameof(AnalyzeSalesOrchestrator); + public const string GenerateCode = nameof(GenerateCode); + public const string ExecuteCode = nameof(ExecuteCode); + public const string FormatAnswer = nameof(FormatAnswer); +} diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/main-app/WorkerProfiles.cs b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/WorkerProfiles.cs new file mode 100644 index 00000000..ffd52574 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/WorkerProfiles.cs @@ -0,0 +1,26 @@ +using Microsoft.DurableTask.Client.AzureManaged; + +namespace Demo.Codegen.MainApp; + +/// +/// Declares the on-demand sandbox worker profile that hosts the +/// activity in an isolated sandbox container. The profile id ("code-executor") surfaces in +/// the DTS dashboard under the On-demand Sandboxes tab. +/// +[SandboxWorkerProfile("code-executor")] +internal sealed class CodeSandboxWorkerProfile : ISandboxWorkerProfile +{ + public void Configure(SandboxWorkerProfileOptions options) + { + options.Image.ImageRef = Environment.GetEnvironmentVariable("DTS_SANDBOX_CONTAINER_IMAGE") + ?? throw new InvalidOperationException("DTS_SANDBOX_CONTAINER_IMAGE is required."); + options.Image.ManagedIdentityClientId = Environment.GetEnvironmentVariable("DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID") + ?? throw new InvalidOperationException("DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID is required."); + options.SchedulerManagedIdentityClientId = Environment.GetEnvironmentVariable("DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID") + ?? throw new InvalidOperationException("DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID is required."); + options.Cpu = "1000m"; + options.Memory = "2048Mi"; + options.MaxConcurrentActivities = 1; + options.AddActivity(TaskNames.ExecuteCode, version: ""); + } +} diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/main-app/main-app.csproj b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/main-app.csproj new file mode 100644 index 00000000..103b60dd --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/main-app.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + enable + CodegenMainApp + Demo.Codegen.MainApp + + + + + + + + + + + + + diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/main-app/manifests/deployment.tmpl.yaml b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/manifests/deployment.tmpl.yaml new file mode 100644 index 00000000..da3bc458 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/manifests/deployment.tmpl.yaml @@ -0,0 +1,57 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mainapp + namespace: default + annotations: + azure.workload.identity/client-id: {{ .Env.AZURE_USER_ASSIGNED_IDENTITY_CLIENT_ID }} + labels: + azure.workload.identity/use: "true" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mainapp + namespace: default + labels: + app: mainapp +spec: + replicas: 1 + selector: + matchLabels: + app: mainapp + template: + metadata: + labels: + app: mainapp + azure.workload.identity/use: "true" + spec: + serviceAccountName: mainapp + containers: + - name: mainapp + image: {{ .Env.SERVICE_MAINAPP_IMAGE_NAME }} + env: + - name: DTS_ENDPOINT + value: {{ .Env.DTS_ENDPOINT }} + - name: DTS_TASK_HUB + value: {{ .Env.DTS_TASK_HUB }} + - name: AOAI_ENDPOINT + value: {{ .Env.AOAI_ENDPOINT }} + - name: AOAI_DEPLOYMENT + value: {{ .Env.AOAI_DEPLOYMENT }} + - name: AZURE_CLIENT_ID + value: {{ .Env.AZURE_USER_ASSIGNED_IDENTITY_CLIENT_ID }} + # The sandbox worker profile (WorkerProfiles.cs) reads these: + - name: DTS_SANDBOX_CONTAINER_IMAGE + value: {{ .Env.DTS_SANDBOX_CONTAINER_IMAGE }} + - name: DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID + value: {{ .Env.AZURE_USER_ASSIGNED_IDENTITY_CLIENT_ID }} + - name: DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID + value: {{ .Env.AZURE_USER_ASSIGNED_IDENTITY_CLIENT_ID }} + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: "1" + memory: 512Mi diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Containerfile b/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Containerfile new file mode 100644 index 00000000..65ab938e --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Containerfile @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1.7 +# +# Build from the demo root: +# +# docker build \ +# --platform linux/amd64 \ +# -f sandbox-worker/Containerfile \ +# -t .azurecr.io/dts-codegen-sandbox: \ +# . +# +# ⚠️ --platform linux/amd64 is required. The Grpc.Tools 2.78.0 linux_arm64 protoc +# binary segfaults under Docker's arm64 emulation on Apple Silicon. amd64 protoc +# works under Rosetta and matches what DTS sandboxes run anyway. +# +# The sandbox worker is a .NET app that shells out to python3, so we install +# pandas in the runtime stage. + +FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG TARGETARCH + +WORKDIR /src/dts-ondemand-sandbox-codegen-demo +COPY . . + +WORKDIR /src/dts-ondemand-sandbox-codegen-demo/sandbox-worker +RUN case "$TARGETARCH" in \ + amd64) runtime_identifier=linux-x64 ;; \ + arm64) runtime_identifier=linux-arm64 ;; \ + *) echo "Unsupported target architecture: $TARGETARCH" >&2; exit 1 ;; \ + esac \ + && dotnet publish sandbox-worker.csproj \ + -c Release \ + -r "$runtime_identifier" \ + --self-contained false \ + -o /app/publish \ + /p:DebugSymbols=false \ + /p:DebugType=None \ + && find /app/publish -type f \( -name '*.xml' -o -name '*.pdb' \) -delete + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +# Install python3 + pandas. These are the only tools the LLM-generated scripts can use. +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 python3-pip \ + && pip3 install --no-cache-dir --break-system-packages pandas==2.2.* \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 + +COPY --from=build /app/publish ./ + +ENTRYPOINT ["dotnet", "CodegenSandboxWorker.dll"] diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Containerfile.dockerignore b/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Containerfile.dockerignore new file mode 100644 index 00000000..fdf3b455 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Containerfile.dockerignore @@ -0,0 +1,6 @@ +bin/ +obj/ +out/ +.vs/ +.idea/ +.DS_Store diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Contracts.cs b/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Contracts.cs new file mode 100644 index 00000000..eae31c76 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Contracts.cs @@ -0,0 +1,9 @@ +namespace Demo.Codegen.SandboxWorker; + +/// +/// Input contract matching the main app's ExecuteCodeInput record. +/// Defined separately here so the worker has no dependency on the main app. +/// +public sealed record ExecuteCodeInput(string PythonCode, string CsvData); + +public sealed record ExecuteCodeOutput(string Stdout, string Stderr, int ExitCode); diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/ExecuteCodeActivity.cs b/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/ExecuteCodeActivity.cs new file mode 100644 index 00000000..9b23ad9f --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/ExecuteCodeActivity.cs @@ -0,0 +1,133 @@ +using System.Diagnostics; +using Microsoft.DurableTask; + +namespace Demo.Codegen.SandboxWorker; + +/// +/// Runs LLM-generated Python in the on-demand sandbox. +/// +/// Each invocation gets a fresh container instance. The CSV is written to /tmp/data.csv +/// and the generated script is executed against it. We capture stdout/stderr and the +/// exit code so the orchestrator can surface failures cleanly. +/// +[DurableTask("ExecuteCode")] +internal sealed class ExecuteCodeActivity : TaskActivity +{ + public override async Task RunAsync(TaskActivityContext context, ExecuteCodeInput input) + { + string sandboxName = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID") + ?? Environment.MachineName; + + Console.WriteLine($"[sandbox] Starting ExecuteCode in sandbox '{sandboxName}' (pid={Environment.ProcessId})"); + Console.WriteLine("[sandbox] This is isolated on-demand sandbox compute managed by DTS."); + + // Show the audience exactly what untrusted code landed in this sandbox. + string[] codeLines = input.PythonCode.Split('\n'); + int byteCount = System.Text.Encoding.UTF8.GetByteCount(input.PythonCode); + Console.WriteLine($"[sandbox] Received generated Python ({codeLines.Length} lines, {byteCount} bytes)"); + Console.WriteLine("[sandbox] --- generated script ---"); + const int maxDisplayLines = 30; + int displayCount = Math.Min(codeLines.Length, maxDisplayLines); + for (int i = 0; i < displayCount; i++) + { + Console.WriteLine(codeLines[i]); + } + + if (codeLines.Length > maxDisplayLines) + { + Console.WriteLine($"... (truncated, {codeLines.Length - maxDisplayLines} more lines)"); + } + + Console.WriteLine("[sandbox] --- end script ---"); + + string workDir = Path.Combine("/tmp", $"run-{Guid.NewGuid():N}"); + Directory.CreateDirectory(workDir); + Console.WriteLine($"[sandbox] Created isolated work directory: {workDir}"); + + string csvPath = Path.Combine(workDir, "data.csv"); + string scriptPath = Path.Combine(workDir, "script.py"); + + await File.WriteAllTextAsync(csvPath, input.CsvData); + await File.WriteAllTextAsync(scriptPath, input.PythonCode); + Console.WriteLine($"[sandbox] Wrote dataset: {csvPath}"); + Console.WriteLine($"[sandbox] Wrote generated script: {scriptPath}"); + + // The generated script reads /tmp/data.csv. Copy into the canonical location + // so the LLM doesn't need to know about per-invocation working directories. + File.Copy(csvPath, "/tmp/data.csv", overwrite: true); + Console.WriteLine("[sandbox] Mounted dataset at expected path: /tmp/data.csv"); + string[] csvLines = input.CsvData.Split('\n', StringSplitOptions.RemoveEmptyEntries); + if (csvLines.Length > 0) + { + string[] csvHeaders = csvLines[0].Split(','); + int csvRowCount = csvLines.Length - 1; + Console.WriteLine($"[sandbox] Dataset loaded: {csvRowCount} rows × {csvHeaders.Length} columns [{string.Join(", ", csvHeaders)}]"); + } + + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "python3", + ArgumentList = { scriptPath }, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WorkingDirectory = workDir, + }, + }; + + var sw = Stopwatch.StartNew(); + process.Start(); + Console.WriteLine($"[sandbox] Executing command: python3 {scriptPath}"); + + // Cap execution time so a runaway script can't hold the sandbox forever. + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + Task stdoutTask = process.StandardOutput.ReadToEndAsync(cts.Token); + Task stderrTask = process.StandardError.ReadToEndAsync(cts.Token); + + try + { + await process.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) + { + try { process.Kill(entireProcessTree: true); } + catch { /* best effort */ } + + Console.WriteLine("[sandbox] ERROR: Timeout: execution exceeded 30 seconds."); + return new ExecuteCodeOutput( + Stdout: string.Empty, + Stderr: "Execution timed out after 30 seconds.", + ExitCode: 124); + } + + sw.Stop(); + string stdout = await stdoutTask; + string stderr = await stderrTask; + + Console.WriteLine($"[sandbox] Python process completed in {sw.ElapsedMilliseconds}ms (exit code {process.ExitCode})"); + + if (!string.IsNullOrWhiteSpace(stdout)) + { + Console.WriteLine("[sandbox] stdout from generated script:"); + Console.WriteLine(stdout.TrimEnd()); + } + else + { + Console.WriteLine("[sandbox] stdout from generated script: "); + } + + if (process.ExitCode != 0 && !string.IsNullOrWhiteSpace(stderr)) + { + Console.WriteLine($"[sandbox] ERROR: {stderr.TrimEnd()}"); + } + + if (process.ExitCode == 0) + { + Console.WriteLine("[sandbox] Returning captured stdout to the orchestrator."); + } + + return new ExecuteCodeOutput(stdout, stderr, process.ExitCode); + } +} diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Program.cs b/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Program.cs new file mode 100644 index 00000000..ff73d006 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Program.cs @@ -0,0 +1,27 @@ +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Logging.AddSimpleConsole(options => +{ + options.SingleLine = true; + options.UseUtcTimestamp = true; + options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ "; +}); + +builder.Services.AddDurableTaskWorker(workerBuilder => +{ + workerBuilder.AddTasks(tasks => + { + tasks.AddActivity(); + }); + + // DTS injects endpoint, task hub, profile id, and runtime settings into the + // sandbox via environment variables — the worker image stays config-free. + workerBuilder.UseSandboxWorker(); +}); + +await builder.Build().RunAsync(); diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/sandbox-worker.csproj b/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/sandbox-worker.csproj new file mode 100644 index 00000000..9330cd5e --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/sandbox-worker.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + CodegenSandboxWorker + Demo.Codegen.SandboxWorker + + + + + + + + + diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/scripts/acr-build.sh b/preview-features/on-demand-sandboxes/samples/dotnet/scripts/acr-build.sh new file mode 100755 index 00000000..b6192a8e --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/scripts/acr-build.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Builds the two container images for the On-demand Sandboxes demo server-side using +# ACR Tasks (az acr build) — no local Docker required. Called by azd as a predeploy hook. +# +# - main-app : the orchestrator, deployed to AKS (azd reads SERVICE_MAINAPP_IMAGE_NAME +# and skips its own build/push). +# - sandbox : the worker image DTS starts on demand. Not deployed to AKS; its full +# image reference is handed to the app via DTS_SANDBOX_CONTAINER_IMAGE. + +set -euo pipefail + +REGISTRY="${AZURE_CONTAINER_REGISTRY_NAME:?AZURE_CONTAINER_REGISTRY_NAME must be set}" +REGISTRY_ENDPOINT="${AZURE_CONTAINER_REGISTRY_ENDPOINT:?AZURE_CONTAINER_REGISTRY_ENDPOINT must be set}" +ENV_NAME="${AZURE_ENV_NAME:?AZURE_ENV_NAME must be set}" +TAG="azd-deploy-$(date +%s)" + +# The .NET build context is the sample root so Directory.Build.props is available. +build() { + local image_repo="$1" # e.g. dts-ondemand-sandboxes/main-app- + local containerfile="$2" + local full_image="${REGISTRY_ENDPOINT}/${image_repo}:${TAG}" + + echo "==> Building ${image_repo}:${TAG} via ACR Tasks (--platform linux/amd64)..." >&2 + az acr build \ + --registry "${REGISTRY}" \ + --image "${image_repo}:${TAG}" \ + --platform linux/amd64 \ + --file "${containerfile}" \ + . \ + --no-logs \ + --output none >&2 + + echo "${full_image}" +} + +MAIN_APP_IMAGE="$(build "dts-ondemand-sandboxes/main-app-${ENV_NAME}" "main-app/Containerfile")" +SANDBOX_IMAGE="$(build "dts-ondemand-sandboxes/sandbox-worker-${ENV_NAME}" "sandbox-worker/Containerfile")" + +# azd uses SERVICE__IMAGE_NAME to skip its own build and deploy this image instead. +azd env set SERVICE_MAINAPP_IMAGE_NAME "${MAIN_APP_IMAGE}" +# The app declares the sandbox worker profile using this image reference. +azd env set DTS_SANDBOX_CONTAINER_IMAGE "${SANDBOX_IMAGE}" + +echo "==> main-app image : ${MAIN_APP_IMAGE}" +echo "==> sandbox image : ${SANDBOX_IMAGE}" diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/scripts/attach-scheduler-identity.sh b/preview-features/on-demand-sandboxes/samples/dotnet/scripts/attach-scheduler-identity.sh new file mode 100755 index 00000000..3c216af4 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/scripts/attach-scheduler-identity.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Attaches the sample's user-assigned managed identity to the existing Durable Task +# Scheduler so DTS can use it to pull the sandbox image and let the sandbox worker +# connect back. Runs as an azd postprovision hook. The PATCH is merge-safe: it keeps +# any identities already attached to the scheduler. +# +# NOTE: enabling the On-demand Sandboxes preview *feature* on the scheduler is a +# separate, out-of-band step handled during private-preview onboarding. + +set -euo pipefail + +SUBSCRIPTION_ID="${AZURE_SUBSCRIPTION_ID:?AZURE_SUBSCRIPTION_ID must be set}" +SCHEDULER_NAME="${DTS_SCHEDULER_NAME:?DTS_SCHEDULER_NAME must be set}" +SCHEDULER_RG="${DTS_SCHEDULER_RESOURCE_GROUP:?DTS_SCHEDULER_RESOURCE_GROUP must be set}" +IDENTITY_ID="${AZURE_USER_ASSIGNED_IDENTITY_RESOURCE_ID:?AZURE_USER_ASSIGNED_IDENTITY_RESOURCE_ID must be set}" +API_VERSION="2026-05-01-preview" + +if ! command -v python3 >/dev/null 2>&1; then + echo "ERROR: python3 is required to merge the scheduler identity block." >&2 + exit 1 +fi + +URI="https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${SCHEDULER_RG}/providers/Microsoft.DurableTask/schedulers/${SCHEDULER_NAME}?api-version=${API_VERSION}" + +echo "==> Reading current identity on scheduler '${SCHEDULER_NAME}'..." +CURRENT="$(az rest --method get --uri "${URI}")" + +BODY="$(IDENTITY_ID="${IDENTITY_ID}" python3 - "${CURRENT}" <<'PY' +import json, os, sys + +current = json.loads(sys.argv[1]) +identity_id = os.environ["IDENTITY_ID"] + +identity = current.get("identity") or {} +user_assigned = identity.get("userAssignedIdentities") or {} +user_assigned[identity_id] = {} + +current_type = identity.get("type", "") or "" +new_type = "SystemAssigned, UserAssigned" if "SystemAssigned" in current_type else "UserAssigned" + +print(json.dumps({"identity": {"type": new_type, "userAssignedIdentities": user_assigned}})) +PY +)" + +TMP="$(mktemp)" +trap 'rm -f "${TMP}"' EXIT +printf '%s' "${BODY}" > "${TMP}" + +echo "==> Attaching managed identity to scheduler..." +az rest --method patch --uri "${URI}" --body "@${TMP}" >/dev/null +echo "==> Done. Identity ${IDENTITY_ID##*/} is attached to '${SCHEDULER_NAME}'." diff --git a/preview-features/on-demand-sandboxes/samples/python/Containerfile b/preview-features/on-demand-sandboxes/samples/python/Containerfile new file mode 100644 index 00000000..a0d1a71c --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/Containerfile @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1.7 +# +# Build from the python/ demo directory: +# +# docker build \ +# -f Containerfile \ +# -t .azurecr.io/dts-codegen-sandbox-python:v1 \ +# . +# +# Private preview requires the image to be publicly pullable by the sandbox platform. + +FROM python:3.12-slim AS runtime +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +ENV GRPC_DEFAULT_SSL_ROOTS_FILE_PATH=/etc/ssl/certs/ca-certificates.crt + +# Install the Durable Task SDK (with the sandboxes extension), plus pandas for +# the LLM-generated scripts the sandbox executes. +RUN pip install --no-cache-dir \ + durabletask==1.6.0 \ + durabletask-azuremanaged==1.6.0 \ + azure-identity \ + "pandas==2.2.*" + +COPY remote_worker.py /app/remote_worker.py +COPY activities.py /app/activities.py + +ENTRYPOINT ["python", "/app/remote_worker.py"] diff --git a/preview-features/on-demand-sandboxes/samples/python/Containerfile.mainapp b/preview-features/on-demand-sandboxes/samples/python/Containerfile.mainapp new file mode 100644 index 00000000..d25f26f6 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/Containerfile.mainapp @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1.7 +# +# Builds the orchestrator (main_app.py) image deployed to AKS. Build from the python/ +# sample directory so requirements.txt and data/ are in the build context: +# +# docker build -f Containerfile.mainapp -t /main-app: . + +FROM python:3.12-slim AS runtime +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +ENV GRPC_DEFAULT_SSL_ROOTS_FILE_PATH=/etc/ssl/certs/ca-certificates.crt + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY main_app.py /app/main_app.py +COPY activities.py /app/activities.py +COPY data /app/data +ENV DEMO_CSV_PATH=/app/data/sales_q1.csv + +ENTRYPOINT ["python", "/app/main_app.py"] diff --git a/preview-features/on-demand-sandboxes/samples/python/README.md b/preview-features/on-demand-sandboxes/samples/python/README.md new file mode 100644 index 00000000..cf79e16f --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/README.md @@ -0,0 +1,190 @@ +# On-demand Sandboxes demo (Python): LLM-generated code interpreter + +The Python port of the [.NET demo](../dotnet/README.md). A three-step Durable Task +workflow that demonstrates the **On-demand Sandboxes** preview of Azure Durable +Task Scheduler (DTS), using the `durabletask.azuremanaged.preview.sandboxes` +package. + +``` + ┌─────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────────┐ + │ generate_code │ │ execute_code │ │ format_answer │ + │ (in-process Python) │ -> │ (on-demand sandbox) │ -> │ (in-process Python) │ + │ Azure OpenAI -> Python │ │ python3 + pandas │ │ Pick top region │ + └─────────────────────────┘ └─────────────────────────┘ └─────────────────────────┘ +``` + +The orchestrator asks a natural-language question over `data/sales_q1.csv`. The LLM +returns a self-contained pandas script. That script is **untrusted** code, so it runs +in a DTS-managed on-demand sandbox — not in the orchestrator's process. The first and +last activities stay in-process; `execute_code` is fanned out one sandbox execution +per region partition. + +## Layout + +``` +python/ +├── activities.py # Shared activity identities (execute_code is a SandboxActivity) +├── main_app.py # Declarer app: orchestrator + in-process activities + profile +├── remote_worker.py # Sandbox worker image entrypoint: runs execute_code via python3 +├── Containerfile # Builds the remote worker (sandbox) image +├── Containerfile.mainapp # Builds the main_app image deployed to AKS +├── requirements.txt # Declarer-app dependencies +├── azure.yaml # azd service + hooks (Deploy to Azure) +├── infra/ # Bicep: AKS, ACR, identity, Azure OpenAI, scheduler wiring +├── scripts/ # acr-build.sh + attach-scheduler-identity.sh (azd hooks) +├── manifests/ # K8s deployment template for main_app +└── data/sales_q1.csv # Sample dataset (~300 rows) +``` + +- `execute_code` is declared as an on-demand sandbox activity by the `code-executor` + worker profile (the `@sandbox_worker_profile` class in `main_app.py`). It is never + registered on the main app worker. +- `generate_code` and `format_answer` run in-process in the main app worker. + +## Prerequisites + +- Python 3.12+ +- Docker (to build the sandbox image) +- A DTS scheduler + task hub with the On-demand Sandboxes preview enabled +- An Azure Container Registry the sandbox platform can pull from +- Two user-assigned managed identities (image pull + scheduler connect) +- An Azure OpenAI deployment of a chat model (GPT-4o, GPT-4.1, etc.) + +## Install + +From the `python/` directory: + +```bash +pip install -r requirements.txt +pip install durabletask==1.6.0 durabletask-azuremanaged==1.6.0 +``` + +## Build the sandbox image + +From the `python/` directory: + +```bash +ACR= +IMAGE=$ACR.azurecr.io/dts-codegen-sandbox-python:v1 + +docker build \ + -f Containerfile \ + -t $IMAGE \ + . + +# Enable anonymous pull so DTS can fetch the sandbox image without credentials +az acr update --name $ACR --anonymous-pull-enabled true +az acr login --name $ACR +docker push $IMAGE +``` + +## Run the orchestrator + +```bash +export DTS_ENDPOINT="https://" +export DTS_TASK_HUB="" +export DTS_WORKER_PROFILE_ID="code-executor" +export DTS_SANDBOX_CONTAINER_IMAGE=".azurecr.io/dts-codegen-sandbox-python:v1" +export DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID="" +export DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID="" + +export AOAI_ENDPOINT="https://.openai.azure.com" +export AOAI_DEPLOYMENT="" + +# Sign in so DefaultAzureCredential can reach DTS and Azure OpenAI +az login + +python main_app.py "Which region had the highest total revenue in March 2025?" +``` + +The declarer prints a dataset preview, the AOAI-generated Python (prefixed +`[generate]`), the orchestration id, and the final answer. The sandbox container +logs (prefixed `[sandbox]`) stream through the DTS dashboard's **On-demand +Sandboxes** tab while `execute_code` runs. + +## Deploy to Azure (AKS) with `azd` + +The `infra/` folder and `azure.yaml` deploy the **main_app** orchestrator to **Azure +Kubernetes Service** with [`azd`](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd). +The sandbox worker image (`remote_worker.py`) is built and pushed to ACR; DTS starts it +on demand, so it is never deployed to the cluster. + +> The Durable Task Scheduler is **not created** by this template — you pass in an +> existing one. On-demand Sandboxes is a private-preview feature that must be enabled on +> the scheduler out of band, so the scheduler is patched separately and supplied here by +> name. + +### What gets provisioned + +| Resource | Purpose | +|----------|---------| +| **AKS cluster** | Hosts the `main_app` orchestrator pod (workload identity enabled) | +| **Azure Container Registry** | Stores the main-app and sandbox-worker images (built server-side via ACR Tasks) | +| **User-assigned managed identity** + federated credential | Pod auth to DTS/Azure OpenAI, ACR pull for the sandbox, and the sandbox's connection back to DTS | +| **Azure OpenAI** + `gpt-4o` deployment | Backs the in-process `generate_code` activity | +| **VNet** | Network isolation for AKS | + +The deployment also **ensures the task hub** exists, grants the identity the roles it +needs (AcrPull, Durable Task data access, Cognitive Services OpenAI User), and a +`postprovision` hook **attaches the identity to your scheduler** (a merge-safe PATCH). + +### Prerequisites + +- An existing **DTS scheduler** with the On-demand Sandboxes preview enabled, and its + resource group name. +- [Azure Developer CLI (`azd`)](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd), [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli), and [kubectl](https://kubernetes.io/docs/tasks/tools/). +- Azure OpenAI quota for `gpt-4o` in your target region. + +### Deploy + +```bash +azd auth login && az login + +# Point the template at your existing (preview-enabled) scheduler. +azd env set DTS_SCHEDULER_NAME "" +azd env set DTS_SCHEDULER_RESOURCE_GROUP "" +# Optional overrides: DTS_TASK_HUB (default: default), AZURE_OPENAI_LOCATION + +azd up +``` + +`azd` provisions the resources, builds both images via ACR Tasks, attaches the identity +to your scheduler, and deploys the `main_app` pod. If you don't set `DTS_SCHEDULER_NAME` +/ `DTS_SCHEDULER_RESOURCE_GROUP` first, `azd` prompts for them. + +### Verify + +```bash +az aks get-credentials --resource-group --name # from `azd env get-values` +kubectl get pods +kubectl logs -l app=mainapp --tail=50 +``` + +The `main_app` pod runs the orchestration; `[sandbox]` logs from `execute_code` stream in +the DTS dashboard's **On-demand Sandboxes** tab. + +### Clean up + +```bash +azd down +``` + +This removes the resources the template created. Your scheduler is left untouched (it was +not created here); detach the identity manually if you no longer need it. + +## Sample questions to try + +- `Which region had the highest total revenue in March 2025?` +- `What was the best-selling product in Q1?` +- `Average revenue per transaction in February?` + +## What's in-process vs on-demand sandbox + +| Activity | Runs where | Why | +| --------------- | ------------- | ----------------------------------------------------- | +| generate_code | In-process | Plain Azure OpenAI HTTP call. No reason to split out. | +| execute_code | **Sandbox** | Untrusted LLM-generated code + different runtime. | +| format_answer | In-process | Trivial result aggregation. | + +Only `execute_code` is declared on the `code-executor` sandbox worker profile via +`options.add_activity(...)`. Everything else runs wherever the orchestrator runs. diff --git a/preview-features/on-demand-sandboxes/samples/python/activities.py b/preview-features/on-demand-sandboxes/samples/python/activities.py new file mode 100644 index 00000000..72b0c21d --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/activities.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Activity identities shared between the declarer app and the sandbox worker.""" + +from durabletask.azuremanaged.preview.sandboxes import SandboxActivity + +# ExecuteCode runs in a DTS-managed on-demand sandbox. Python orchestrations call +# activities by name, so the sandbox activity identity is unversioned. +EXECUTE_CODE = SandboxActivity(name="execute_code", version=None) + +# In-process activities that run inside the main app worker. +GENERATE_CODE = "generate_code" +FORMAT_ANSWER = "format_answer" diff --git a/preview-features/on-demand-sandboxes/samples/python/azure.yaml b/preview-features/on-demand-sandboxes/samples/python/azure.yaml new file mode 100644 index 00000000..058f3559 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/azure.yaml @@ -0,0 +1,24 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +# On-demand Sandboxes (Python) on Azure Kubernetes Service. +# +# Only the orchestrator (main_app.py) is deployed to AKS. The sandbox worker image +# (remote_worker.py) is built and pushed to ACR by the predeploy hook; DTS starts it on +# demand, so it is not an azd service. Images are built server-side with ACR Tasks +# (az acr build) to avoid local Docker builds. + +metadata: + template: dts-ondemand-sandboxes-python-aks +name: dts-ondemand-sandboxes-python +hooks: + predeploy: + shell: bash + run: ./scripts/acr-build.sh + postprovision: + shell: bash + run: ./scripts/attach-scheduler-identity.sh +services: + mainapp: + project: . + language: python + host: aks diff --git a/preview-features/on-demand-sandboxes/samples/python/data/sales_q1.csv b/preview-features/on-demand-sandboxes/samples/python/data/sales_q1.csv new file mode 100644 index 00000000..aa6d0ee8 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/data/sales_q1.csv @@ -0,0 +1,301 @@ +date,region,product,units,revenue +2025-01-01,West,Gadget,18,9000 +2025-01-01,East,Widget,25,5000 +2025-01-01,Central,Gizmo,32,9600 +2025-01-01,South,Gadget,39,19500 +2025-01-02,West,Widget,46,9200 +2025-01-02,East,Gizmo,53,15900 +2025-01-02,Central,Gadget,60,30000 +2025-01-03,South,Widget,24,4800 +2025-01-03,West,Gizmo,31,9300 +2025-01-03,East,Gadget,38,19000 +2025-01-03,Central,Widget,45,9000 +2025-01-04,South,Gizmo,52,15600 +2025-01-04,West,Gadget,59,29500 +2025-01-04,East,Widget,23,4600 +2025-01-05,Central,Gizmo,30,9000 +2025-01-05,South,Gadget,37,18500 +2025-01-05,West,Widget,44,8800 +2025-01-06,East,Gizmo,51,15300 +2025-01-06,Central,Gadget,58,29000 +2025-01-06,South,Widget,22,4400 +2025-01-06,West,Gizmo,29,8700 +2025-01-07,East,Gadget,36,18000 +2025-01-07,Central,Widget,43,8600 +2025-01-07,South,Gizmo,50,15000 +2025-01-08,West,Gadget,57,28500 +2025-01-08,East,Widget,21,4200 +2025-01-08,Central,Gizmo,28,8400 +2025-01-09,South,Gadget,35,17500 +2025-01-09,West,Widget,42,8400 +2025-01-09,East,Gizmo,49,14700 +2025-01-09,Central,Gadget,56,28000 +2025-01-10,South,Widget,20,4000 +2025-01-10,West,Gizmo,27,8100 +2025-01-10,East,Gadget,34,17000 +2025-01-11,Central,Widget,41,8200 +2025-01-11,South,Gizmo,48,14400 +2025-01-11,West,Gadget,55,27500 +2025-01-12,East,Widget,19,3800 +2025-01-12,Central,Gizmo,26,7800 +2025-01-12,South,Gadget,33,16500 +2025-01-12,West,Widget,40,8000 +2025-01-13,East,Gizmo,47,14100 +2025-01-13,Central,Gadget,54,27000 +2025-01-13,South,Widget,18,3600 +2025-01-14,West,Gizmo,25,7500 +2025-01-14,East,Gadget,32,16000 +2025-01-14,Central,Widget,39,7800 +2025-01-14,South,Gizmo,46,13800 +2025-01-15,West,Gadget,53,26500 +2025-01-15,East,Widget,60,12000 +2025-01-15,Central,Gizmo,24,7200 +2025-01-16,South,Gadget,31,15500 +2025-01-16,West,Widget,38,7600 +2025-01-16,East,Gizmo,45,13500 +2025-01-17,Central,Gadget,52,26000 +2025-01-17,South,Widget,59,11800 +2025-01-17,West,Gizmo,23,6900 +2025-01-17,East,Gadget,30,15000 +2025-01-18,Central,Widget,37,7400 +2025-01-18,South,Gizmo,44,13200 +2025-01-18,West,Gadget,51,25500 +2025-01-19,East,Widget,58,11600 +2025-01-19,Central,Gizmo,22,6600 +2025-01-19,South,Gadget,29,14500 +2025-01-20,West,Widget,36,7200 +2025-01-20,East,Gizmo,43,12900 +2025-01-20,Central,Gadget,50,25000 +2025-01-20,South,Widget,57,11400 +2025-01-21,West,Gizmo,21,6300 +2025-01-21,East,Gadget,28,14000 +2025-01-21,Central,Widget,35,7000 +2025-01-22,South,Gizmo,42,12600 +2025-01-22,West,Gadget,49,24500 +2025-01-22,East,Widget,56,11200 +2025-01-23,Central,Gizmo,20,6000 +2025-01-23,South,Gadget,27,13500 +2025-01-23,West,Widget,34,6800 +2025-01-23,East,Gizmo,41,12300 +2025-01-24,Central,Gadget,48,24000 +2025-01-24,South,Widget,55,11000 +2025-01-24,West,Gizmo,19,5700 +2025-01-25,East,Gadget,26,13000 +2025-01-25,Central,Widget,33,6600 +2025-01-25,South,Gizmo,40,12000 +2025-01-26,West,Gadget,47,23500 +2025-01-26,East,Widget,54,10800 +2025-01-26,Central,Gizmo,18,5400 +2025-01-26,South,Gadget,25,12500 +2025-01-27,West,Widget,32,6400 +2025-01-27,East,Gizmo,39,11700 +2025-01-27,Central,Gadget,46,23000 +2025-01-28,South,Widget,53,10600 +2025-01-28,West,Gizmo,60,18000 +2025-01-28,East,Gadget,24,12000 +2025-01-28,Central,Widget,31,6200 +2025-01-29,South,Gizmo,38,11400 +2025-01-29,West,Gadget,45,22500 +2025-01-29,East,Widget,52,10400 +2025-01-30,Central,Gizmo,59,17700 +2025-01-30,South,Gadget,23,11500 +2025-01-30,West,Widget,30,6000 +2025-01-31,East,Gizmo,37,11100 +2025-01-31,Central,Gadget,44,22000 +2025-01-31,South,Widget,51,10200 +2025-01-31,West,Gizmo,58,17400 +2025-02-01,East,Gadget,22,11000 +2025-02-01,Central,Widget,29,5800 +2025-02-01,South,Gizmo,36,10800 +2025-02-02,West,Gadget,43,21500 +2025-02-02,East,Widget,50,10000 +2025-02-02,Central,Gizmo,57,17100 +2025-02-03,South,Gadget,21,10500 +2025-02-03,West,Widget,28,5600 +2025-02-03,East,Gizmo,35,10500 +2025-02-03,Central,Gadget,42,21000 +2025-02-04,South,Widget,49,9800 +2025-02-04,West,Gizmo,56,16800 +2025-02-04,East,Gadget,20,10000 +2025-02-05,Central,Widget,27,5400 +2025-02-05,South,Gizmo,34,10200 +2025-02-05,West,Gadget,41,20500 +2025-02-06,East,Widget,48,9600 +2025-02-06,Central,Gizmo,55,16500 +2025-02-06,South,Gadget,19,9500 +2025-02-06,West,Widget,26,5200 +2025-02-07,East,Gizmo,33,9900 +2025-02-07,Central,Gadget,40,20000 +2025-02-07,South,Widget,47,9400 +2025-02-08,West,Gizmo,54,16200 +2025-02-08,East,Gadget,18,9000 +2025-02-08,Central,Widget,25,5000 +2025-02-08,South,Gizmo,32,9600 +2025-02-09,West,Gadget,39,19500 +2025-02-09,East,Widget,46,9200 +2025-02-09,Central,Gizmo,53,15900 +2025-02-10,South,Gadget,60,30000 +2025-02-10,West,Widget,24,4800 +2025-02-10,East,Gizmo,31,9300 +2025-02-11,Central,Gadget,38,19000 +2025-02-11,South,Widget,45,9000 +2025-02-11,West,Gizmo,52,15600 +2025-02-11,East,Gadget,59,29500 +2025-02-12,Central,Widget,23,4600 +2025-02-12,South,Gizmo,30,9000 +2025-02-12,West,Gadget,37,18500 +2025-02-13,East,Widget,44,8800 +2025-02-13,Central,Gizmo,51,15300 +2025-02-13,South,Gadget,58,29000 +2025-02-14,West,Widget,22,4400 +2025-02-14,East,Gizmo,29,8700 +2025-02-14,Central,Gadget,36,18000 +2025-02-14,South,Widget,43,8600 +2025-02-15,West,Gizmo,50,15000 +2025-02-15,East,Gadget,57,28500 +2025-02-15,Central,Widget,21,4200 +2025-02-16,South,Gizmo,28,8400 +2025-02-16,West,Gadget,35,17500 +2025-02-16,East,Widget,42,8400 +2025-02-17,Central,Gizmo,49,14700 +2025-02-17,South,Gadget,56,28000 +2025-02-17,West,Widget,20,4000 +2025-02-17,East,Gizmo,27,8100 +2025-02-18,Central,Gadget,34,17000 +2025-02-18,South,Widget,41,8200 +2025-02-18,West,Gizmo,48,14400 +2025-02-19,East,Gadget,55,27500 +2025-02-19,Central,Widget,19,3800 +2025-02-19,South,Gizmo,26,7800 +2025-02-20,West,Gadget,33,16500 +2025-02-20,East,Widget,40,8000 +2025-02-20,Central,Gizmo,47,14100 +2025-02-20,South,Gadget,54,27000 +2025-02-21,West,Widget,18,3600 +2025-02-21,East,Gizmo,25,7500 +2025-02-21,Central,Gadget,32,16000 +2025-02-22,South,Widget,39,7800 +2025-02-22,West,Gizmo,46,13800 +2025-02-22,East,Gadget,53,26500 +2025-02-22,Central,Widget,60,12000 +2025-02-23,South,Gizmo,24,7200 +2025-02-23,West,Gadget,31,15500 +2025-02-23,East,Widget,38,7600 +2025-02-24,Central,Gizmo,45,13500 +2025-02-24,South,Gadget,52,26000 +2025-02-24,West,Widget,59,11800 +2025-02-25,East,Gizmo,23,6900 +2025-02-25,Central,Gadget,30,15000 +2025-02-25,South,Widget,37,7400 +2025-02-25,West,Gizmo,44,13200 +2025-02-26,East,Gadget,51,25500 +2025-02-26,Central,Widget,58,11600 +2025-02-26,South,Gizmo,22,6600 +2025-02-27,West,Gadget,29,14500 +2025-02-27,East,Widget,36,7200 +2025-02-27,Central,Gizmo,43,12900 +2025-02-28,South,Gadget,50,25000 +2025-02-28,West,Widget,57,11400 +2025-02-28,East,Gizmo,21,6300 +2025-02-28,Central,Gadget,28,14000 +2025-03-01,South,Widget,35,7000 +2025-03-01,West,Gizmo,60,18000 +2025-03-01,East,Gadget,53,26500 +2025-03-02,Central,Widget,58,11600 +2025-03-02,South,Gizmo,20,6000 +2025-03-02,West,Gadget,45,22500 +2025-03-03,East,Widget,38,7600 +2025-03-03,Central,Gizmo,43,12900 +2025-03-03,South,Gadget,48,24000 +2025-03-03,West,Widget,73,14600 +2025-03-04,East,Gizmo,23,6900 +2025-03-04,Central,Gadget,28,14000 +2025-03-04,South,Widget,33,6600 +2025-03-05,West,Gizmo,58,17400 +2025-03-05,East,Gadget,51,25500 +2025-03-05,Central,Widget,56,11200 +2025-03-05,South,Gizmo,18,5400 +2025-03-06,West,Gadget,43,21500 +2025-03-06,East,Widget,36,7200 +2025-03-06,Central,Gizmo,41,12300 +2025-03-07,South,Gadget,46,23000 +2025-03-07,West,Widget,71,14200 +2025-03-07,East,Gizmo,64,19200 +2025-03-08,Central,Gadget,26,13000 +2025-03-08,South,Widget,31,6200 +2025-03-08,West,Gizmo,56,16800 +2025-03-08,East,Gadget,49,24500 +2025-03-09,Central,Widget,54,10800 +2025-03-09,South,Gizmo,59,17700 +2025-03-09,West,Gadget,41,20500 +2025-03-10,East,Widget,34,6800 +2025-03-10,Central,Gizmo,39,11700 +2025-03-10,South,Gadget,44,22000 +2025-03-11,West,Widget,69,13800 +2025-03-11,East,Gizmo,62,18600 +2025-03-11,Central,Gadget,24,12000 +2025-03-11,South,Widget,29,5800 +2025-03-12,West,Gizmo,54,16200 +2025-03-12,East,Gadget,47,23500 +2025-03-12,Central,Widget,52,10400 +2025-03-13,South,Gizmo,57,17100 +2025-03-13,West,Gadget,39,19500 +2025-03-13,East,Widget,32,6400 +2025-03-14,Central,Gizmo,37,11100 +2025-03-14,South,Gadget,42,21000 +2025-03-14,West,Widget,67,13400 +2025-03-14,East,Gizmo,60,18000 +2025-03-15,Central,Gadget,22,11000 +2025-03-15,South,Widget,27,5400 +2025-03-15,West,Gizmo,52,15600 +2025-03-16,East,Gadget,45,22500 +2025-03-16,Central,Widget,50,10000 +2025-03-16,South,Gizmo,55,16500 +2025-03-17,West,Gadget,37,18500 +2025-03-17,East,Widget,30,6000 +2025-03-17,Central,Gizmo,35,10500 +2025-03-17,South,Gadget,40,20000 +2025-03-18,West,Widget,65,13000 +2025-03-18,East,Gizmo,58,17400 +2025-03-18,Central,Gadget,20,10000 +2025-03-19,South,Widget,25,5000 +2025-03-19,West,Gizmo,50,15000 +2025-03-19,East,Gadget,43,21500 +2025-03-19,Central,Widget,48,9600 +2025-03-20,South,Gizmo,53,15900 +2025-03-20,West,Gadget,78,39000 +2025-03-20,East,Widget,28,5600 +2025-03-21,Central,Gizmo,33,9900 +2025-03-21,South,Gadget,38,19000 +2025-03-21,West,Widget,63,12600 +2025-03-22,East,Gizmo,56,16800 +2025-03-22,Central,Gadget,61,30500 +2025-03-22,South,Widget,23,4600 +2025-03-22,West,Gizmo,48,14400 +2025-03-23,East,Gadget,41,20500 +2025-03-23,Central,Widget,46,9200 +2025-03-23,South,Gizmo,51,15300 +2025-03-24,West,Gadget,76,38000 +2025-03-24,East,Widget,26,5200 +2025-03-24,Central,Gizmo,31,9300 +2025-03-25,South,Gadget,36,18000 +2025-03-25,West,Widget,61,12200 +2025-03-25,East,Gizmo,54,16200 +2025-03-25,Central,Gadget,59,29500 +2025-03-26,South,Widget,21,4200 +2025-03-26,West,Gizmo,46,13800 +2025-03-26,East,Gadget,39,19500 +2025-03-27,Central,Widget,44,8800 +2025-03-27,South,Gizmo,49,14700 +2025-03-27,West,Gadget,74,37000 +2025-03-28,East,Widget,24,4800 +2025-03-28,Central,Gizmo,29,8700 +2025-03-28,South,Gadget,34,17000 +2025-03-28,West,Widget,59,11800 +2025-03-29,East,Gizmo,52,15600 +2025-03-29,Central,Gadget,57,28500 +2025-03-29,South,Widget,19,3800 +2025-03-30,West,Gizmo,44,13200 +2025-03-30,East,Gadget,37,18500 +2025-03-30,Central,Widget,42,8400 +2025-03-31,South,Gizmo,47,14100 diff --git a/preview-features/on-demand-sandboxes/samples/python/infra/abbreviations.json b/preview-features/on-demand-sandboxes/samples/python/infra/abbreviations.json new file mode 100644 index 00000000..10424d3d --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/infra/abbreviations.json @@ -0,0 +1,17 @@ +{ + "analysisServicesServers": "as", + "apiManagementService": "apim-", + "appConfigurationStores": "appcs-", + "appManagedEnvironments": "cae-", + "appContainerApps": "ca-", + "authorizationPolicyDefinitions": "policy-", + "automationAutomationAccounts": "aa-", + "containerRegistryRegistries": "cr", + "containerServiceManagedClusters": "aks-", + "networkVirtualNetworks": "vnet-", + "networkNetworkSecurityGroups": "nsg-", + "managedIdentityUserAssignedIdentities": "id-", + "resourcesResourceGroups": "rg-", + "dts": "dts-", + "taskhub": "taskhub-" +} diff --git a/preview-features/on-demand-sandboxes/samples/python/infra/app/federated-identity.bicep b/preview-features/on-demand-sandboxes/samples/python/infra/app/federated-identity.bicep new file mode 100644 index 00000000..18cadeaa --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/infra/app/federated-identity.bicep @@ -0,0 +1,34 @@ +metadata description = 'Creates a federated identity credential for AKS workload identity.' + +@description('The name of the user-assigned managed identity') +param identityName string + +@description('The name of the federated credential') +param federatedCredentialName string + +@description('The OIDC issuer URL from the AKS cluster') +param oidcIssuerUrl string + +@description('The Kubernetes namespace for the service account') +param serviceAccountNamespace string = 'default' + +@description('The Kubernetes service account name') +param serviceAccountName string + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = { + name: identityName +} + +// Federated identity credential binds a Kubernetes service account to the +// user-assigned managed identity, enabling pods to authenticate as that identity. +resource federatedCredential 'Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials@2024-11-30' = { + parent: identity + name: federatedCredentialName + properties: { + issuer: oidcIssuerUrl + subject: 'system:serviceaccount:${serviceAccountNamespace}:${serviceAccountName}' + audiences: [ + 'api://AzureADTokenExchange' + ] + } +} diff --git a/preview-features/on-demand-sandboxes/samples/python/infra/app/openai.bicep b/preview-features/on-demand-sandboxes/samples/python/infra/app/openai.bicep new file mode 100644 index 00000000..bf724f67 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/infra/app/openai.bicep @@ -0,0 +1,92 @@ +metadata description = 'Creates an Azure OpenAI account with a chat model deployment for the GenerateCode activity.' + +@description('Name of the Azure OpenAI (Cognitive Services) account') +param name string + +@description('Azure region for the Azure OpenAI account') +param location string = resourceGroup().location + +@description('Tags to apply to the account') +param tags object = {} + +@description('Custom subdomain used to build the account endpoint') +param customSubDomainName string = name + +@description('Name of the chat model deployment the app calls') +param chatDeploymentName string = 'gpt-5.1' + +@description('Chat model name') +param chatModelName string = 'gpt-5.1' + +@description('Chat model version') +param chatModelVersion string = '2025-11-13' + +@description('Deployment SKU name for the chat model') +param chatDeploymentSkuName string = 'GlobalStandard' + +@description('Tokens-per-minute capacity (in thousands) for the chat deployment') +param chatDeploymentCapacity int = 30 + +@description('Principal id of the workload identity that calls Azure OpenAI') +param workloadPrincipalId string + +@description('Principal id of the deploying user (optional)') +param userPrincipalId string = '' + +resource account 'Microsoft.CognitiveServices/accounts@2024-10-01' = { + name: name + location: location + tags: tags + kind: 'OpenAI' + sku: { + name: 'S0' + } + properties: { + customSubDomainName: customSubDomainName + publicNetworkAccess: 'Enabled' + disableLocalAuth: true + } +} + +resource chatDeployment 'Microsoft.CognitiveServices/accounts/deployments@2024-10-01' = { + parent: account + name: chatDeploymentName + sku: { + name: chatDeploymentSkuName + capacity: chatDeploymentCapacity + } + properties: { + model: { + format: 'OpenAI' + name: chatModelName + version: chatModelVersion + } + } +} + +// Cognitive Services OpenAI User role (5e0bd9bd-7b93-4f28-af87-19fc36ad61bd) +var openAiUserRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd') + +resource workloadOpenAiAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: account + name: guid(account.id, workloadPrincipalId, openAiUserRole) + properties: { + roleDefinitionId: openAiUserRole + principalId: workloadPrincipalId + principalType: 'ServicePrincipal' + } +} + +resource userOpenAiAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(userPrincipalId)) { + scope: account + name: guid(account.id, userPrincipalId, openAiUserRole) + properties: { + roleDefinitionId: openAiUserRole + principalId: userPrincipalId + principalType: 'User' + } +} + +output endpoint string = account.properties.endpoint +output name string = account.name +output chatDeploymentName string = chatDeployment.name diff --git a/preview-features/on-demand-sandboxes/samples/python/infra/app/scheduler-access.bicep b/preview-features/on-demand-sandboxes/samples/python/infra/app/scheduler-access.bicep new file mode 100644 index 00000000..aea4ebf5 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/infra/app/scheduler-access.bicep @@ -0,0 +1,53 @@ +metadata description = 'Wires up an existing Durable Task Scheduler: ensures the task hub, grants data-plane access, and surfaces the endpoint. Deployed into the scheduler\'s resource group.' + +@description('Name of the existing Durable Task Scheduler') +param schedulerName string + +@description('Name of the task hub to use (created if it does not already exist)') +param taskHubName string = 'default' + +@description('Principal id of the workload identity that connects to DTS') +param workloadPrincipalId string + +@description('Principal id of the deploying user, for dashboard access (optional)') +param userPrincipalId string = '' + +// The scheduler is created and patched out of band (preview feature enablement + +// managed-identity attach), so it is referenced as an existing resource here. +resource scheduler 'Microsoft.DurableTask/schedulers@2025-11-01' existing = { + name: schedulerName +} + +// Ensure the task hub the app uses exists on the scheduler. +resource taskHub 'Microsoft.DurableTask/schedulers/taskhubs@2025-11-01' = { + parent: scheduler + name: taskHubName +} + +// Durable Task Data Contributor (0ad04412-c4d5-4796-b79c-f76d14c8d402) — data-plane +// access used by the orchestrator app and the sandbox worker to connect to DTS. +var dtsDataRole = '0ad04412-c4d5-4796-b79c-f76d14c8d402' + +resource workloadDtsAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: scheduler + name: guid(scheduler.id, workloadPrincipalId, dtsDataRole) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', dtsDataRole) + principalId: workloadPrincipalId + principalType: 'ServicePrincipal' + } +} + +resource userDtsAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(userPrincipalId)) { + scope: scheduler + name: guid(scheduler.id, userPrincipalId, dtsDataRole) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', dtsDataRole) + principalId: userPrincipalId + principalType: 'User' + } +} + +output endpoint string = scheduler.properties.endpoint +output taskHubName string = taskHub.name +output schedulerName string = scheduler.name diff --git a/preview-features/on-demand-sandboxes/samples/python/infra/app/user-assigned-identity.bicep b/preview-features/on-demand-sandboxes/samples/python/infra/app/user-assigned-identity.bicep new file mode 100644 index 00000000..0583ab8d --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/infra/app/user-assigned-identity.bicep @@ -0,0 +1,17 @@ +metadata description = 'Creates a Microsoft Entra user-assigned identity.' + +param name string +param location string = resourceGroup().location +param tags object = {} + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: name + location: location + tags: tags +} + +output name string = identity.name +output resourceId string = identity.id +output principalId string = identity.properties.principalId +output clientId string = identity.properties.clientId +output tenantId string = identity.properties.tenantId diff --git a/preview-features/on-demand-sandboxes/samples/python/infra/core/host/aks-cluster.bicep b/preview-features/on-demand-sandboxes/samples/python/infra/core/host/aks-cluster.bicep new file mode 100644 index 00000000..d2a767e3 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/infra/core/host/aks-cluster.bicep @@ -0,0 +1,94 @@ +metadata description = 'Creates an Azure Kubernetes Service (AKS) cluster.' + +@description('The name of the AKS cluster') +param name string + +@description('The Azure region for the AKS cluster') +param location string = resourceGroup().location + +@description('Tags to apply to the AKS cluster') +param tags object = {} + +@description('The Kubernetes version for the AKS cluster') +param kubernetesVersion string = '1.32' + +@description('The VM size for the default node pool') +param agentVMSize string = 'standard_d4s_v5' + +@description('The number of nodes in the default node pool') +param agentCount int = 2 + +@description('The minimum number of nodes for autoscaling') +param agentMinCount int = 1 + +@description('The maximum number of nodes for autoscaling') +param agentMaxCount int = 5 + +@description('The subnet resource ID for the AKS nodes') +param subnetId string = '' + +@description('The name of the container registry to attach') +param containerRegistryName string = '' + +@description('Enable OIDC issuer for workload identity') +param enableOidcIssuer bool = true + +@description('Enable workload identity') +param enableWorkloadIdentity bool = true + +// AKS cluster with workload identity and OIDC issuer enabled +resource aksCluster 'Microsoft.ContainerService/managedClusters@2024-09-01' = { + name: name + location: location + tags: tags + identity: { + type: 'SystemAssigned' + } + properties: { + kubernetesVersion: kubernetesVersion + dnsPrefix: name + enableRBAC: true + agentPoolProfiles: [ + { + name: 'system' + count: agentCount + vmSize: agentVMSize + mode: 'System' + osType: 'Linux' + osSKU: 'AzureLinux' + enableAutoScaling: true + minCount: agentMinCount + maxCount: agentMaxCount + vnetSubnetID: !empty(subnetId) ? subnetId : null + } + ] + networkProfile: { + networkPlugin: 'azure' + networkPolicy: 'azure' + serviceCidr: '10.1.0.0/16' + dnsServiceIP: '10.1.0.10' + } + oidcIssuerProfile: { + enabled: enableOidcIssuer + } + securityProfile: { + workloadIdentity: { + enabled: enableWorkloadIdentity + } + } + } +} + +// Grant AKS kubelet identity AcrPull access to the container registry +module registryAccess '../security/registry-access.bicep' = if (!empty(containerRegistryName)) { + name: 'aks-registry-access' + params: { + containerRegistryName: containerRegistryName + principalId: aksCluster.properties.identityProfile.kubeletidentity.objectId + } +} + +output clusterName string = aksCluster.name +output clusterId string = aksCluster.id +output oidcIssuerUrl string = aksCluster.properties.oidcIssuerProfile.issuerURL +output kubeletIdentityObjectId string = aksCluster.properties.identityProfile.kubeletidentity.objectId diff --git a/preview-features/on-demand-sandboxes/samples/python/infra/core/host/container-registry.bicep b/preview-features/on-demand-sandboxes/samples/python/infra/core/host/container-registry.bicep new file mode 100644 index 00000000..4cd24453 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/infra/core/host/container-registry.bicep @@ -0,0 +1,59 @@ +metadata description = 'Creates an Azure Container Registry.' +param name string +param location string = resourceGroup().location +param tags object = {} + +@description('Indicates whether admin user is enabled') +param adminUserEnabled bool = false + +@description('Indicates whether anonymous pull is enabled') +param anonymousPullEnabled bool = false + +@description('SKU settings') +param sku object = { + name: 'Standard' +} + +@description('The log analytics workspace ID used for logging and monitoring') +param workspaceId string = '' + +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = { + name: name + location: location + tags: tags + sku: sku + properties: { + adminUserEnabled: adminUserEnabled + anonymousPullEnabled: anonymousPullEnabled + publicNetworkAccess: 'Enabled' + } +} + +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { + name: 'registry-diagnostics' + scope: containerRegistry + properties: { + workspaceId: workspaceId + logs: [ + { + category: 'ContainerRegistryRepositoryEvents' + enabled: true + } + { + category: 'ContainerRegistryLoginEvents' + enabled: true + } + ] + metrics: [ + { + category: 'AllMetrics' + enabled: true + timeGrain: 'PT1M' + } + ] + } +} + +output id string = containerRegistry.id +output loginServer string = containerRegistry.properties.loginServer +output name string = containerRegistry.name diff --git a/preview-features/on-demand-sandboxes/samples/python/infra/core/networking/vnet.bicep b/preview-features/on-demand-sandboxes/samples/python/infra/core/networking/vnet.bicep new file mode 100644 index 00000000..46764a8e --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/infra/core/networking/vnet.bicep @@ -0,0 +1,40 @@ +@description('The name of the Virtual Network') +param name string + +@description('The Azure region where the Virtual Network should exist') +param location string = resourceGroup().location + +@description('Optional tags for the resources') +param tags object = {} + +@description('The address prefixes of the Virtual Network') +param addressPrefixes array = ['10.0.0.0/16'] + +@description('The subnets to create in the Virtual Network') +param subnets array = [ + { + name: 'aks-subnet' + properties: { + addressPrefix: '10.0.0.0/21' + delegations: [] + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Enabled' + } + } +] + +resource vnet 'Microsoft.Network/virtualNetworks@2023-11-01' = { + name: name + location: location + tags: tags + properties: { + addressSpace: { + addressPrefixes: addressPrefixes + } + subnets: subnets + } +} + +output id string = vnet.id +output name string = vnet.name +output aksSubnetId string = resourceId('Microsoft.Network/virtualNetworks/subnets', name, 'aks-subnet') diff --git a/preview-features/on-demand-sandboxes/samples/python/infra/core/security/registry-access.bicep b/preview-features/on-demand-sandboxes/samples/python/infra/core/security/registry-access.bicep new file mode 100644 index 00000000..f977b9f1 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/infra/core/security/registry-access.bicep @@ -0,0 +1,19 @@ +metadata description = 'Assigns ACR Pull permissions to access an Azure Container Registry.' +param containerRegistryName string +param principalId string + +var acrPullRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + +resource aksAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: containerRegistry // Use when specifying a scope that is different than the deployment scope + name: guid(subscription().id, resourceGroup().id, principalId, acrPullRole) + properties: { + roleDefinitionId: acrPullRole + principalType: 'ServicePrincipal' + principalId: principalId + } +} + +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' existing = { + name: containerRegistryName +} diff --git a/preview-features/on-demand-sandboxes/samples/python/infra/core/security/role.bicep b/preview-features/on-demand-sandboxes/samples/python/infra/core/security/role.bicep new file mode 100644 index 00000000..0b30cfd3 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/infra/core/security/role.bicep @@ -0,0 +1,21 @@ +metadata description = 'Creates a role assignment for a service principal.' +param principalId string + +@allowed([ + 'Device' + 'ForeignGroup' + 'Group' + 'ServicePrincipal' + 'User' +]) +param principalType string = 'ServicePrincipal' +param roleDefinitionId string + +resource role 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().id, resourceGroup().id, principalId, roleDefinitionId) + properties: { + principalId: principalId + principalType: principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId) + } +} diff --git a/preview-features/on-demand-sandboxes/samples/python/infra/main.bicep b/preview-features/on-demand-sandboxes/samples/python/infra/main.bicep new file mode 100644 index 00000000..66ee57f2 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/infra/main.bicep @@ -0,0 +1,222 @@ +targetScope = 'subscription' + +// Provisions the Azure resources to run the On-demand Sandboxes code-interpreter demo +// in the cloud: the orchestrator (main-app) runs on AKS, and DTS starts the sandbox +// worker image on demand. The Durable Task Scheduler is NOT created here — it is passed +// in as an existing resource (schedulerName + schedulerResourceGroupName) because it is +// patched out of band to enable the On-demand Sandboxes preview feature. + +@minLength(1) +@maxLength(64) +@description('Name of the environment which is used to generate a short unique hash used in all resources.') +param environmentName string + +@minLength(1) +@description('Primary location for all resources') +param location string + +@description('Id of the user or app to assign application roles') +param principalId string = '' + +// AKS parameters +param aksClusterName string = '' +param kubernetesVersion string = '1.32' +param aksVmSize string = 'standard_d4s_v5' +param aksNodeCount int = 2 + +// Container registry parameters +param containerRegistryName string = '' + +// Existing Durable Task Scheduler (created + patched out of band for the preview). +@description('Name of the existing Durable Task Scheduler to use as the durable backend.') +param schedulerName string + +@description('Resource group that contains the existing Durable Task Scheduler.') +param schedulerResourceGroupName string + +@description('Task hub to use on the scheduler (created if it does not exist).') +param taskHubName string = 'default' + +// Azure OpenAI parameters (used by the in-process GenerateCode activity). +param openAiServiceName string = '' +param openAiLocation string = 'eastus' +param chatDeploymentName string = 'gpt-5.1' +param chatModelName string = 'gpt-5.1' +param chatModelVersion string = '2025-11-13' +param chatDeploymentSkuName string = 'GlobalStandard' +param chatDeploymentCapacity int = 30 + +// Service name (must match the service name in azure.yaml). +param mainAppServiceName string = 'mainapp' + +// Optional resource group name override +param resourceGroupName string = '' + +var abbrs = loadJsonContent('./abbreviations.json') + +var tags = { + 'azd-env-name': environmentName +} + +var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) + +resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}' + location: location + tags: tags +} + +// ============================ +// Identity +// ============================ + +// A single user-assigned managed identity is used for everything in this sample: +// - AKS workload identity (the app authenticates to DTS and Azure OpenAI) +// - DTS image pull for the sandbox (AcrPull on the registry) +// - the sandbox worker connecting back to DTS +module identity './app/user-assigned-identity.bicep' = { + scope: rg + params: { + name: '${abbrs.managedIdentityUserAssignedIdentities}${resourceToken}' + location: location + tags: tags + } +} + +// ============================ +// Networking +// ============================ + +module vnet './core/networking/vnet.bicep' = { + scope: rg + params: { + name: '${abbrs.networkVirtualNetworks}${resourceToken}' + location: location + tags: tags + } +} + +// ============================ +// Container Registry +// ============================ + +module containerRegistry './core/host/container-registry.bicep' = { + name: 'container-registry' + scope: rg + params: { + name: !empty(containerRegistryName) ? containerRegistryName : '${abbrs.containerRegistryRegistries}${resourceToken}' + location: location + tags: tags + sku: { + name: 'Standard' + } + anonymousPullEnabled: false + } +} + +// Grant the managed identity AcrPull so DTS can pull the sandbox worker image. +module identityAcrPull './core/security/registry-access.bicep' = { + name: 'identity-acr-pull' + scope: rg + params: { + containerRegistryName: containerRegistry.outputs.name + principalId: identity.outputs.principalId + } +} + +// ============================ +// AKS Cluster +// ============================ + +module aksCluster './core/host/aks-cluster.bicep' = { + name: 'aks-cluster' + scope: rg + params: { + name: !empty(aksClusterName) ? aksClusterName : '${abbrs.containerServiceManagedClusters}${resourceToken}' + location: location + tags: tags + kubernetesVersion: kubernetesVersion + agentVMSize: aksVmSize + agentCount: aksNodeCount + subnetId: vnet.outputs.aksSubnetId + containerRegistryName: containerRegistry.outputs.name + } +} + +// ============================ +// Workload Identity Federation +// ============================ + +module federatedIdentityMainApp './app/federated-identity.bicep' = { + name: 'federated-identity-mainapp' + scope: rg + params: { + identityName: identity.outputs.name + federatedCredentialName: 'fed-${mainAppServiceName}' + oidcIssuerUrl: aksCluster.outputs.oidcIssuerUrl + serviceAccountNamespace: 'default' + serviceAccountName: mainAppServiceName + } +} + +// ============================ +// Azure OpenAI +// ============================ + +module openAi './app/openai.bicep' = { + name: 'openai' + scope: rg + params: { + name: !empty(openAiServiceName) ? openAiServiceName : 'aoai-${resourceToken}' + location: openAiLocation + tags: tags + chatDeploymentName: chatDeploymentName + chatModelName: chatModelName + chatModelVersion: chatModelVersion + chatDeploymentSkuName: chatDeploymentSkuName + chatDeploymentCapacity: chatDeploymentCapacity + workloadPrincipalId: identity.outputs.principalId + userPrincipalId: principalId + } +} + +// ============================ +// Existing Durable Task Scheduler +// ============================ + +module schedulerAccess './app/scheduler-access.bicep' = { + name: 'scheduler-access' + scope: resourceGroup(schedulerResourceGroupName) + params: { + schedulerName: schedulerName + taskHubName: taskHubName + workloadPrincipalId: identity.outputs.principalId + userPrincipalId: principalId + } +} + +// ============================ +// Outputs +// ============================ + +output AZURE_LOCATION string = location +output AZURE_TENANT_ID string = tenant().tenantId + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer +output AZURE_CONTAINER_REGISTRY_NAME string = containerRegistry.outputs.name + +output AZURE_AKS_CLUSTER_NAME string = aksCluster.outputs.clusterName + +output AZURE_USER_ASSIGNED_IDENTITY_NAME string = identity.outputs.name +output AZURE_USER_ASSIGNED_IDENTITY_CLIENT_ID string = identity.outputs.clientId +output AZURE_USER_ASSIGNED_IDENTITY_RESOURCE_ID string = identity.outputs.resourceId + +// Scheduler details (DTS_ENDPOINT/DTS_TASK_HUB feed the app; name/RG feed the +// postprovision identity-attach hook). +output DTS_ENDPOINT string = schedulerAccess.outputs.endpoint +output DTS_TASK_HUB string = schedulerAccess.outputs.taskHubName +output DTS_SCHEDULER_NAME string = schedulerName +output DTS_SCHEDULER_RESOURCE_GROUP string = schedulerResourceGroupName + +output AOAI_ENDPOINT string = openAi.outputs.endpoint +output AOAI_DEPLOYMENT string = openAi.outputs.chatDeploymentName diff --git a/preview-features/on-demand-sandboxes/samples/python/infra/main.parameters.json b/preview-features/on-demand-sandboxes/samples/python/infra/main.parameters.json new file mode 100644 index 00000000..f98d2108 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/infra/main.parameters.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "principalId": { + "value": "${AZURE_PRINCIPAL_ID}" + }, + "schedulerName": { + "value": "${DTS_SCHEDULER_NAME}" + }, + "schedulerResourceGroupName": { + "value": "${DTS_SCHEDULER_RESOURCE_GROUP}" + }, + "taskHubName": { + "value": "${DTS_TASK_HUB=default}" + } + } +} diff --git a/preview-features/on-demand-sandboxes/samples/python/main_app.py b/preview-features/on-demand-sandboxes/samples/python/main_app.py new file mode 100644 index 00000000..44c65071 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/main_app.py @@ -0,0 +1,258 @@ +"""Declarer app for the On-demand Sandboxes code-interpreter demo. + +A three-step Durable Task workflow: + + generate_code (in-process, Azure OpenAI -> Python) + -> execute_code (on-demand sandbox, python3 + pandas, fanned out per region) + -> format_answer (in-process) + +Only ``execute_code`` runs in a DTS-managed sandbox; the LLM-generated Python is +untrusted, so it never executes inside this process. +""" + +import os +import sys + +from azure.identity import DefaultAzureCredential, get_bearer_token_provider +from openai import AzureOpenAI + +from durabletask import client, task +from durabletask.azuremanaged.client import DurableTaskSchedulerClient +from durabletask.azuremanaged.preview.sandboxes import ( + SandboxActivitiesClient, + SandboxWorkerProfile, + sandbox_worker_profile, +) +from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker + +from activities import EXECUTE_CODE, FORMAT_ANSWER, GENERATE_CODE + + +SYSTEM_PROMPT = """\ +You are a Python code generator. Given a question about a sales dataset, +produce a single self-contained Python script that: + +1. Reads /tmp/data.csv with pandas. The columns are: date, region, product, units, revenue. +2. Assumes the CSV contains rows for exactly one region. +3. Computes the total revenue for March 2025 in this subset. +4. Prints ONLY the numeric revenue total to stdout. No code fences, no explanation, no commentary. + +Constraints: +- Use only the Python standard library and pandas. +- Do not access the network or filesystem outside /tmp. +- If there is no March 2025 data in this subset, print 0. +- Output must be plain text containing only the number. + +Respond with the Python script only. No markdown, no backticks. +""" + + +def _require(name: str) -> str: + value = os.getenv(name) + if value and value.strip(): + return value.strip() + raise RuntimeError(f"Set {name} before running the sandbox demo.") + + +def _strip_code_fences(code: str) -> str: + trimmed = code.strip() + if trimmed.startswith("```"): + newline = trimmed.find("\n") + if newline > 0: + trimmed = trimmed[newline + 1:] + if trimmed.endswith("```"): + trimmed = trimmed[:-3] + return trimmed.strip() + + +def split_csv_by_region(csv_data: str) -> list[tuple[str, str]]: + """Partition the dataset deterministically so one generated script runs once per region.""" + lines = [line for line in csv_data.replace("\r\n", "\n").split("\n") if line.strip()] + if len(lines) < 2: + return [] + + header = lines[0] + rows_by_region: dict[str, list[str]] = {} + for row in lines[1:]: + cells = row.split(",") + if len(cells) < 2: + continue + region = cells[1].strip() + rows_by_region.setdefault(region, []).append(row) + + return [ + (region, "\n".join([header, *rows_by_region[region]])) + for region in sorted(rows_by_region, key=str.casefold) + ] + + +# --- In-process activities (run in this app's worker) ----------------------- + +def generate_code(ctx: task.ActivityContext, question: str) -> str: + """Translate a natural-language question into a self-contained pandas script.""" + endpoint = _require("AOAI_ENDPOINT") + deployment = _require("AOAI_DEPLOYMENT") + + token_provider = get_bearer_token_provider( + DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default") + aoai = AzureOpenAI( + azure_endpoint=endpoint, + azure_ad_token_provider=token_provider, + api_version="2024-10-21") + + completion = aoai.chat.completions.create( + model=deployment, + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": question}, + ]) + + code = _strip_code_fences(completion.choices[0].message.content or "") + print(f"[generate] AOAI returned {len(code.splitlines())} lines of Python:") + print("---") + print(code) + print("---") + return code + + +def format_answer(ctx: task.ActivityContext, payload: dict) -> str: + """Aggregate the per-region sandbox results into a single answer.""" + question = payload["question"] + totals: list[tuple[str, float]] = [] + for result in payload["results"]: + region = result["region"] + if result["exit_code"] != 0: + return (f"Sandbox execution failed for region '{region}' " + f"(exit code {result['exit_code']}): {result['stderr']}") + stdout = (result["stdout"] or "").strip() + try: + revenue = float(stdout) + except ValueError: + return f"Sandbox execution returned a non-numeric result for region '{region}': {stdout}" + totals.append((region, revenue)) + + for region, revenue in sorted(totals, key=lambda item: item[1], reverse=True): + print(f"[fan-out] Region {region}: {revenue}") + + top_region = sorted(totals, key=lambda item: (-item[1], item[0]))[0][0] + return f"Q: {question}\nA: {top_region}" + + +# --- Orchestrator ----------------------------------------------------------- + +def analyze_sales(ctx: task.OrchestrationContext, payload: dict): + """3-step workflow answering a question over a CSV using LLM-generated Python.""" + question = payload["question"] + csv_data = payload["csv"] + + # Generate one chunk-friendly Python script up front and reuse it for every region. + code = yield ctx.call_activity(GENERATE_CODE, input=question) + + # Fan out: one sandbox execution per region-specific CSV partition. + chunks = split_csv_by_region(csv_data) + executions = [ + ctx.call_activity(EXECUTE_CODE.name, input={"code": code, "csv": chunk_csv}) + for _region, chunk_csv in chunks + ] + + # Fan in: wait for every sandbox result, then hand the set to the formatter. + results = yield task.when_all(executions) + region_results = [ + {"region": region, **result} + for (region, _csv), result in zip(chunks, results) + ] + + return (yield ctx.call_activity( + FORMAT_ANSWER, + input={"question": question, "results": region_results})) + + +# --- Sandbox worker profile ------------------------------------------------- + +worker_profile_id = os.getenv("DTS_WORKER_PROFILE_ID", "code-executor") +container_image = os.getenv("DTS_SANDBOX_CONTAINER_IMAGE") or "dts-codegen-sandbox-python:local" +image_pull_managed_identity_client_id = _require("DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID") +scheduler_managed_identity_client_id = _require("DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID") + + +@sandbox_worker_profile(worker_profile_id) +class CodeSandboxWorkerProfile(SandboxWorkerProfile): + """Declares the on-demand sandbox that hosts ``execute_code`` in an isolated container.""" + + def configure(self, options) -> None: + options.image.image_ref = container_image + options.image.managed_identity_client_id = image_pull_managed_identity_client_id + options.scheduler_managed_identity_client_id = scheduler_managed_identity_client_id + options.cpu = "1000m" + options.memory = "2048Mi" + options.max_concurrent_activities = 1 + options.add_activity(EXECUTE_CODE.name, version=EXECUTE_CODE.version) + + +# --- Entry point ------------------------------------------------------------ + +def main() -> int: + endpoint = _require("DTS_ENDPOINT") + taskhub = os.getenv("DTS_TASK_HUB", "default") + csv_path = os.getenv("DEMO_CSV_PATH") or os.path.join( + os.path.dirname(os.path.abspath(__file__)), "data", "sales_q1.csv") + question = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else ( + "Which region had the highest total revenue in March 2025?") + + if not os.path.exists(csv_path): + print(f"CSV file not found at: {csv_path}", file=sys.stderr) + return 1 + + with open(csv_path, encoding="utf-8") as handle: + csv_data = handle.read() + + # Print demo context so the audience understands the dataset before orchestration starts. + all_lines = csv_data.splitlines() + headers = all_lines[0].split(",") + print(f"[demo] Dataset: {os.path.abspath(csv_path)}") + print(f"[demo] {len(all_lines) - 1} rows x {len(headers)} columns: [{', '.join(headers)}]") + print(f"[demo] Question: {question}\n") + + secure_channel = endpoint.startswith("https://") or endpoint.startswith("grpcs://") + credential = DefaultAzureCredential() if secure_channel else None + + # Declare the sandbox worker profile with DTS so it can route execute_code to a sandbox. + sandbox_client = SandboxActivitiesClient( + host_address=endpoint, + secure_channel=secure_channel, + taskhub=taskhub, + token_credential=credential) + sandbox_client.enable_sandbox_activities() + + with DurableTaskSchedulerWorker( + host_address=endpoint, + secure_channel=secure_channel, + taskhub=taskhub, + token_credential=credential) as worker: + worker.add_orchestrator(analyze_sales) + worker.add_activity(generate_code) + worker.add_activity(format_answer) + worker.use_work_item_filters() + worker.start() + + durable_client = DurableTaskSchedulerClient( + host_address=endpoint, + secure_channel=secure_channel, + taskhub=taskhub, + token_credential=credential) + instance_id = durable_client.schedule_new_orchestration( + analyze_sales, + input={"question": question, "csv": csv_data}) + print(f"Started orchestration: {instance_id}") + + state = durable_client.wait_for_orchestration_completion(instance_id, timeout=300) + print(f"Status: {state.runtime_status if state else 'unknown'}\n") + if state and state.runtime_status == client.OrchestrationStatus.COMPLETED: + print(state.serialized_output) + elif state and state.failure_details: + print(f"[failure] {state.failure_details}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/preview-features/on-demand-sandboxes/samples/python/manifests/deployment.tmpl.yaml b/preview-features/on-demand-sandboxes/samples/python/manifests/deployment.tmpl.yaml new file mode 100644 index 00000000..fa8cf849 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/manifests/deployment.tmpl.yaml @@ -0,0 +1,57 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mainapp + namespace: default + annotations: + azure.workload.identity/client-id: {{ .Env.AZURE_USER_ASSIGNED_IDENTITY_CLIENT_ID }} + labels: + azure.workload.identity/use: "true" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mainapp + namespace: default + labels: + app: mainapp +spec: + replicas: 1 + selector: + matchLabels: + app: mainapp + template: + metadata: + labels: + app: mainapp + azure.workload.identity/use: "true" + spec: + serviceAccountName: mainapp + containers: + - name: mainapp + image: {{ .Env.SERVICE_MAINAPP_IMAGE_NAME }} + env: + - name: DTS_ENDPOINT + value: {{ .Env.DTS_ENDPOINT }} + - name: DTS_TASK_HUB + value: {{ .Env.DTS_TASK_HUB }} + - name: AOAI_ENDPOINT + value: {{ .Env.AOAI_ENDPOINT }} + - name: AOAI_DEPLOYMENT + value: {{ .Env.AOAI_DEPLOYMENT }} + - name: AZURE_CLIENT_ID + value: {{ .Env.AZURE_USER_ASSIGNED_IDENTITY_CLIENT_ID }} + # The sandbox worker profile (main_app.py) reads these: + - name: DTS_SANDBOX_CONTAINER_IMAGE + value: {{ .Env.DTS_SANDBOX_CONTAINER_IMAGE }} + - name: DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID + value: {{ .Env.AZURE_USER_ASSIGNED_IDENTITY_CLIENT_ID }} + - name: DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID + value: {{ .Env.AZURE_USER_ASSIGNED_IDENTITY_CLIENT_ID }} + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: "1" + memory: 512Mi diff --git a/preview-features/on-demand-sandboxes/samples/python/remote_worker.py b/preview-features/on-demand-sandboxes/samples/python/remote_worker.py new file mode 100644 index 00000000..33102d06 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/remote_worker.py @@ -0,0 +1,115 @@ +"""Remote worker image entrypoint for the On-demand Sandboxes code-interpreter demo. + +Runs the untrusted, LLM-generated Python in an isolated DTS-managed sandbox. +Each invocation writes the CSV partition and generated script to /tmp and shells +out to python3, capturing stdout/stderr and the exit code. +""" + +import os +import subprocess +import threading +import uuid + +from durabletask import task +from durabletask.azuremanaged.preview.sandboxes import SandboxWorker + +from activities import EXECUTE_CODE + +EXECUTION_TIMEOUT_SECONDS = 30 +MAX_DISPLAY_LINES = 30 + + +def execute_code(ctx: task.ActivityContext, payload: dict) -> dict: + """Activity that runs the generated pandas script inside the sandbox container.""" + sandbox_name = os.getenv("DTS_SANDBOX_ID") or os.uname().nodename + python_code = payload["code"] + csv_data = payload["csv"] + + print(f"[sandbox] Starting execute_code in sandbox '{sandbox_name}' (pid={os.getpid()})") + print("[sandbox] This is isolated on-demand sandbox compute managed by DTS.") + + code_lines = python_code.split("\n") + byte_count = len(python_code.encode("utf-8")) + print(f"[sandbox] Received generated Python ({len(code_lines)} lines, {byte_count} bytes)") + print("[sandbox] --- generated script ---") + for line in code_lines[:MAX_DISPLAY_LINES]: + print(line) + if len(code_lines) > MAX_DISPLAY_LINES: + print(f"... (truncated, {len(code_lines) - MAX_DISPLAY_LINES} more lines)") + print("[sandbox] --- end script ---") + + work_dir = os.path.join("/tmp", f"run-{uuid.uuid4().hex}") + os.makedirs(work_dir, exist_ok=True) + print(f"[sandbox] Created isolated work directory: {work_dir}") + + csv_path = os.path.join(work_dir, "data.csv") + script_path = os.path.join(work_dir, "script.py") + with open(csv_path, "w", encoding="utf-8") as handle: + handle.write(csv_data) + with open(script_path, "w", encoding="utf-8") as handle: + handle.write(python_code) + print(f"[sandbox] Wrote dataset: {csv_path}") + print(f"[sandbox] Wrote generated script: {script_path}") + + # The generated script reads /tmp/data.csv. Copy into the canonical location + # so the LLM doesn't need to know about per-invocation working directories. + with open("/tmp/data.csv", "w", encoding="utf-8") as handle: + handle.write(csv_data) + print("[sandbox] Mounted dataset at expected path: /tmp/data.csv") + + csv_lines = [line for line in csv_data.split("\n") if line.strip()] + if csv_lines: + csv_headers = csv_lines[0].split(",") + print(f"[sandbox] Dataset loaded: {len(csv_lines) - 1} rows x " + f"{len(csv_headers)} columns [{', '.join(csv_headers)}]") + + print(f"[sandbox] Executing command: python3 {script_path}") + try: + completed = subprocess.run( + ["python3", script_path], + cwd=work_dir, + capture_output=True, + text=True, + timeout=EXECUTION_TIMEOUT_SECONDS, + check=False) + except subprocess.TimeoutExpired: + print(f"[sandbox] ERROR: Timeout: execution exceeded {EXECUTION_TIMEOUT_SECONDS} seconds.") + return { + "stdout": "", + "stderr": f"Execution timed out after {EXECUTION_TIMEOUT_SECONDS} seconds.", + "exit_code": 124, + } + + stdout = completed.stdout or "" + stderr = completed.stderr or "" + print(f"[sandbox] Python process completed (exit code {completed.returncode})") + if stdout.strip(): + print("[sandbox] stdout from generated script:") + print(stdout.rstrip()) + else: + print("[sandbox] stdout from generated script: ") + if completed.returncode != 0 and stderr.strip(): + print(f"[sandbox] ERROR: {stderr.rstrip()}") + if completed.returncode == 0: + print("[sandbox] Returning captured stdout to the orchestrator.") + + return {"stdout": stdout, "stderr": stderr, "exit_code": completed.returncode} + + +execute_code.__name__ = EXECUTE_CODE.name + + +def main() -> None: + with SandboxWorker() as worker: + worker.add_activity(execute_code, version=EXECUTE_CODE.version) + worker.start() + print("Python on-demand sandbox worker is running. Press Ctrl+C to stop.") + try: + threading.Event().wait() + except KeyboardInterrupt: + # Expected on Ctrl+C: let the context manager stop the worker gracefully. + pass + + +if __name__ == "__main__": + main() diff --git a/preview-features/on-demand-sandboxes/samples/python/requirements.txt b/preview-features/on-demand-sandboxes/samples/python/requirements.txt new file mode 100644 index 00000000..4b56dadc --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/requirements.txt @@ -0,0 +1,5 @@ +# Declarer-app (main_app.py) dependencies. +durabletask==1.6.0 +durabletask-azuremanaged==1.6.0 +azure-identity>=1.16 +openai>=1.40 diff --git a/preview-features/on-demand-sandboxes/samples/python/scripts/acr-build.sh b/preview-features/on-demand-sandboxes/samples/python/scripts/acr-build.sh new file mode 100755 index 00000000..6f93d402 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/scripts/acr-build.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Builds the two container images for the On-demand Sandboxes demo server-side using +# ACR Tasks (az acr build) — no local Docker required. Called by azd as a predeploy hook. +# +# - main-app : the orchestrator (main_app.py), deployed to AKS (azd reads +# SERVICE_MAINAPP_IMAGE_NAME and skips its own build/push). +# - sandbox : the worker image (remote_worker.py) DTS starts on demand. Not deployed +# to AKS; its full image reference is handed to the app via +# DTS_SANDBOX_CONTAINER_IMAGE. + +set -euo pipefail + +REGISTRY="${AZURE_CONTAINER_REGISTRY_NAME:?AZURE_CONTAINER_REGISTRY_NAME must be set}" +REGISTRY_ENDPOINT="${AZURE_CONTAINER_REGISTRY_ENDPOINT:?AZURE_CONTAINER_REGISTRY_ENDPOINT must be set}" +ENV_NAME="${AZURE_ENV_NAME:?AZURE_ENV_NAME must be set}" +TAG="azd-deploy-$(date +%s)" + +build() { + local image_repo="$1" # e.g. dts-ondemand-sandboxes/main-app- + local containerfile="$2" + local full_image="${REGISTRY_ENDPOINT}/${image_repo}:${TAG}" + + echo "==> Building ${image_repo}:${TAG} via ACR Tasks (--platform linux/amd64)..." >&2 + az acr build \ + --registry "${REGISTRY}" \ + --image "${image_repo}:${TAG}" \ + --platform linux/amd64 \ + --file "${containerfile}" \ + . \ + --no-logs \ + --output none >&2 + + echo "${full_image}" +} + +MAIN_APP_IMAGE="$(build "dts-ondemand-sandboxes/main-app-${ENV_NAME}" "Containerfile.mainapp")" +SANDBOX_IMAGE="$(build "dts-ondemand-sandboxes/sandbox-worker-${ENV_NAME}" "Containerfile")" + +# azd uses SERVICE__IMAGE_NAME to skip its own build and deploy this image instead. +azd env set SERVICE_MAINAPP_IMAGE_NAME "${MAIN_APP_IMAGE}" +# The app declares the sandbox worker profile using this image reference. +azd env set DTS_SANDBOX_CONTAINER_IMAGE "${SANDBOX_IMAGE}" + +echo "==> main-app image : ${MAIN_APP_IMAGE}" +echo "==> sandbox image : ${SANDBOX_IMAGE}" diff --git a/preview-features/on-demand-sandboxes/samples/python/scripts/attach-scheduler-identity.sh b/preview-features/on-demand-sandboxes/samples/python/scripts/attach-scheduler-identity.sh new file mode 100755 index 00000000..3c216af4 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/scripts/attach-scheduler-identity.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Attaches the sample's user-assigned managed identity to the existing Durable Task +# Scheduler so DTS can use it to pull the sandbox image and let the sandbox worker +# connect back. Runs as an azd postprovision hook. The PATCH is merge-safe: it keeps +# any identities already attached to the scheduler. +# +# NOTE: enabling the On-demand Sandboxes preview *feature* on the scheduler is a +# separate, out-of-band step handled during private-preview onboarding. + +set -euo pipefail + +SUBSCRIPTION_ID="${AZURE_SUBSCRIPTION_ID:?AZURE_SUBSCRIPTION_ID must be set}" +SCHEDULER_NAME="${DTS_SCHEDULER_NAME:?DTS_SCHEDULER_NAME must be set}" +SCHEDULER_RG="${DTS_SCHEDULER_RESOURCE_GROUP:?DTS_SCHEDULER_RESOURCE_GROUP must be set}" +IDENTITY_ID="${AZURE_USER_ASSIGNED_IDENTITY_RESOURCE_ID:?AZURE_USER_ASSIGNED_IDENTITY_RESOURCE_ID must be set}" +API_VERSION="2026-05-01-preview" + +if ! command -v python3 >/dev/null 2>&1; then + echo "ERROR: python3 is required to merge the scheduler identity block." >&2 + exit 1 +fi + +URI="https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${SCHEDULER_RG}/providers/Microsoft.DurableTask/schedulers/${SCHEDULER_NAME}?api-version=${API_VERSION}" + +echo "==> Reading current identity on scheduler '${SCHEDULER_NAME}'..." +CURRENT="$(az rest --method get --uri "${URI}")" + +BODY="$(IDENTITY_ID="${IDENTITY_ID}" python3 - "${CURRENT}" <<'PY' +import json, os, sys + +current = json.loads(sys.argv[1]) +identity_id = os.environ["IDENTITY_ID"] + +identity = current.get("identity") or {} +user_assigned = identity.get("userAssignedIdentities") or {} +user_assigned[identity_id] = {} + +current_type = identity.get("type", "") or "" +new_type = "SystemAssigned, UserAssigned" if "SystemAssigned" in current_type else "UserAssigned" + +print(json.dumps({"identity": {"type": new_type, "userAssignedIdentities": user_assigned}})) +PY +)" + +TMP="$(mktemp)" +trap 'rm -f "${TMP}"' EXIT +printf '%s' "${BODY}" > "${TMP}" + +echo "==> Attaching managed identity to scheduler..." +az rest --method patch --uri "${URI}" --body "@${TMP}" >/dev/null +echo "==> Done. Identity ${IDENTITY_ID##*/} is attached to '${SCHEDULER_NAME}'."