From d3fec4f04ad3551fb48e258ac4d5720dc72666c8 Mon Sep 17 00:00:00 2001 From: Nick Greenfield Date: Wed, 17 Jun 2026 10:21:09 -0700 Subject: [PATCH 1/5] Add docs and samples for on-demand sandboxes --- .../on-demand-sandboxes/README.md | 159 +++++++++ .../on-demand-sandboxes/docs/dotnet.md | 119 +++++++ .../on-demand-sandboxes/docs/python.md | 210 ++++++++++++ .../on-demand-sandboxes/samples/README.md | 19 ++ .../samples/dotnet/Directory.Build.props | 11 + .../samples/dotnet/README.md | 169 ++++++++++ .../samples/dotnet/data/sales_q1.csv | 301 ++++++++++++++++++ .../samples/dotnet/main-app/Activities.cs | 128 ++++++++ .../main-app/AnalyzeSalesOrchestrator.cs | 80 +++++ .../samples/dotnet/main-app/Contracts.cs | 32 ++ .../samples/dotnet/main-app/Program.cs | 120 +++++++ .../samples/dotnet/main-app/TaskNames.cs | 13 + .../samples/dotnet/main-app/WorkerProfiles.cs | 26 ++ .../samples/dotnet/main-app/main-app.csproj | 27 ++ .../dotnet/sandbox-worker/Containerfile | 58 ++++ .../sandbox-worker/Containerfile.dockerignore | 6 + .../dotnet/sandbox-worker/Contracts.cs | 9 + .../sandbox-worker/ExecuteCodeActivity.cs | 133 ++++++++ .../samples/dotnet/sandbox-worker/Program.cs | 27 ++ .../sandbox-worker/sandbox-worker.csproj | 22 ++ .../samples/python/Containerfile | 34 ++ .../samples/python/README.md | 120 +++++++ .../samples/python/activities.py | 14 + .../samples/python/data/sales_q1.csv | 301 ++++++++++++++++++ .../samples/python/main_app.py | 258 +++++++++++++++ .../samples/python/remote_worker.py | 115 +++++++ .../samples/python/requirements.txt | 9 + 27 files changed, 2520 insertions(+) create mode 100644 preview-features/on-demand-sandboxes/README.md create mode 100644 preview-features/on-demand-sandboxes/docs/dotnet.md create mode 100644 preview-features/on-demand-sandboxes/docs/python.md create mode 100644 preview-features/on-demand-sandboxes/samples/README.md create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/Directory.Build.props create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/README.md create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/data/sales_q1.csv create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/main-app/Activities.cs create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/main-app/AnalyzeSalesOrchestrator.cs create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/main-app/Contracts.cs create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/main-app/Program.cs create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/main-app/TaskNames.cs create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/main-app/WorkerProfiles.cs create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/main-app/main-app.csproj create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Containerfile create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Containerfile.dockerignore create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Contracts.cs create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/ExecuteCodeActivity.cs create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Program.cs create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/sandbox-worker.csproj create mode 100644 preview-features/on-demand-sandboxes/samples/python/Containerfile create mode 100644 preview-features/on-demand-sandboxes/samples/python/README.md create mode 100644 preview-features/on-demand-sandboxes/samples/python/activities.py create mode 100644 preview-features/on-demand-sandboxes/samples/python/data/sales_q1.csv create mode 100644 preview-features/on-demand-sandboxes/samples/python/main_app.py create mode 100644 preview-features/on-demand-sandboxes/samples/python/remote_worker.py create mode 100644 preview-features/on-demand-sandboxes/samples/python/requirements.txt diff --git a/preview-features/on-demand-sandboxes/README.md b/preview-features/on-demand-sandboxes/README.md new file mode 100644 index 00000000..50b30421 --- /dev/null +++ b/preview-features/on-demand-sandboxes/README.md @@ -0,0 +1,159 @@ +# 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. +- **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** (Python preview flow) that DTS uses to pull your + worker image and start the sandbox. You provide their client IDs on the worker profile + (`image_pull_managed_identity_client_id` and `scheduler_managed_identity_client_id`). + +## 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. + +## 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 | +| --- | --- | --- | --- | +| `ContainerImage` / `container_image` | 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* | +| `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..5f1fbc44 --- /dev/null +++ b/preview-features/on-demand-sandboxes/docs/dotnet.md @@ -0,0 +1,119 @@ +# 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. + +## 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, resource shape, concurrency setting, +and the activity names that should run in a sandbox. + +```csharp +using Microsoft.DurableTask.Worker.AzureManaged.Sandbox; + +[SandboxWorkerProfile("")] +internal sealed class CodeSandboxWorkerProfile : ISandboxWorkerProfile +{ + public void Configure(SandboxOptions options) + { + options.ContainerImage = Environment.GetEnvironmentVariable("DTS_SANDBOX_IMAGE") + ?? throw new InvalidOperationException("DTS_SANDBOX_IMAGE is required."); + options.Cpu = "1000m"; + options.Memory = "2048Mi"; + options.MaxConcurrentActivities = 1; + options.AddActivity(TaskNames.ExecuteCode); + } +} +``` + +Then enable on-demand sandbox discovery when you configure the Durable Task worker in +the main app: + +```csharp +workerBuilder.AddTasks(tasks => tasks.AddAllGeneratedTasks()); +workerBuilder.UseDurableTaskScheduler(options => +{ + options.EndpointAddress = Environment.GetEnvironmentVariable("DTS_ENDPOINT"); + options.TaskHubName = Environment.GetEnvironmentVariable("DTS_TASK_HUB"); + options.Credential = credential; +}); +workerBuilder.EnableSandboxes(); +``` + +`EnableSandboxes()` is the key line—it turns on on-demand sandbox discovery so DTS picks +up your sandbox worker profiles 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 `SandboxOptions` setting, see the +[worker profile configuration reference](./README.md#worker-profile-configuration-reference). +In short: `ContainerImage` is the image with your activity implementations; `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 `ContainerImage` +option. + +## 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) +- [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..b25653e8 --- /dev/null +++ b/preview-features/on-demand-sandboxes/docs/python.md @@ -0,0 +1,210 @@ +# 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 in a preview package namespace, +`durabletask.azuremanaged.preview.on_demand_sandbox`. Install the Durable Task packages: + +```bash +pip install durabletask==1.6.0 durabletask.azuremanaged==1.6.0 +``` + +> [!NOTE] +> The on-demand sandbox APIs are available starting in `durabletask==1.6.0` and +> `durabletask.azuremanaged==1.6.0`, under the +> `durabletask.azuremanaged.preview.on_demand_sandbox` namespace. + +## 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 on-demand 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.on_demand_sandbox import ( + OnDemandSandboxActivitiesClient, + OnDemandSandboxWorkerProfile, + on_demand_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_ON_DEMAND_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)) + + +@on_demand_sandbox_worker_profile(worker_profile_id) +class RemoteWorkerProfile(OnDemandSandboxWorkerProfile): + def configure(self, options) -> None: + options.container_image = container_image + options.image_pull_managed_identity_client_id = os.environ[ + "DTS_ON_DEMAND_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID"] + options.scheduler_managed_identity_client_id = os.environ[ + "DTS_ON_DEMAND_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() + +# Register the on-demand sandbox activity metadata with DTS. +sandbox_client = OnDemandSandboxActivitiesClient( + host_address=endpoint, + secure_channel=True, + taskhub=taskhub_name, + token_credential=credential) +sandbox_client.enable_on_demand_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_on_demand_sandbox_activities()` is the key call—it registers the declared +profiles with DTS so it can route those activities to the sandbox image. + +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: `container_image` is the image with your activity implementations; +`image_pull_managed_identity_client_id` / `scheduler_managed_identity_client_id` are the +managed identity client IDs DTS uses to pull the image and start the sandbox; `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 `OnDemandSandboxWorker()`, registers the activity implementations +it owns, and starts. The sandbox worker does **not** configure the DTS endpoint, task +hub, profile id, or credentials—`OnDemandSandboxWorker()` reads the runtime settings +(`DTS_ENDPOINT`, `DTS_TASK_HUB`, `DTS_WORKER_PROFILE_ID`, +`DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES`, `DTS_SANDBOX_ID`, and related values) from +environment variables that DTS injects when it starts the container. + +```python +import os +import threading + +from durabletask import task +from durabletask.azuremanaged.preview.on_demand_sandbox import OnDemandSandboxWorker + +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 OnDemandSandboxWorker() 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 +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 SDKs and 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 activity_names.py /app/activity_names.py + +EXPOSE 8080 +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_ON_DEMAND_SANDBOX_CONTAINER_IMAGE` environment variable). During private preview the +image must be publicly pullable by the sandbox platform. + +## 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) +- [End-to-end sample (`examples/on_demand_sandbox`)](https://github.com/microsoft/durabletask-python/tree/main/examples/on_demand_sandbox) +- [.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..5af2d576 --- /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 | [durabletask-dotnet #736](https://github.com/microsoft/durabletask-dotnet/pull/736) | +| [`python/`](python/README.md) | Python 3.12 | [durabletask-python #151](https://github.com/microsoft/durabletask-python/pull/151) | + +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..09b7d6a9 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/Directory.Build.props @@ -0,0 +1,11 @@ + + + + + $(MSBuildThisFileDirectory)../../../durabletask-dotnet/src/ + + + 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..0b25e418 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/README.md @@ -0,0 +1,169 @@ +# 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 # Points at the durabletask-dotnet SDK source +├── data/sales_q1.csv # Sample dataset (~35 rows) +├── main-app/ # Orchestrator host (.NET 10) +│ ├── Program.cs +│ ├── AnalyzeSalesOrchestrator.cs +│ ├── Activities.cs # GenerateCode + FormatAnswer (in-process) +│ ├── Contracts.cs +│ └── TaskNames.cs +└── 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 `durabletask-dotnet` repo checked out alongside (or override `DtsSdkRoot`) + +Default layout assumed: + +``` +~/durabletask-dotnet/ # private preview SDK source +~/workspace/dts-ondemand-sandbox-codegen-demo/ # this repo +``` + +If your durabletask-dotnet checkout lives elsewhere, override `DtsSdkRoot` on every +`dotnet` and `docker build` command (examples below). + +## 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 \ + --build-context sdk=$HOME/durabletask-dotnet \ + -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. + +## 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/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/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/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..f672171b --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/main-app.csproj @@ -0,0 +1,27 @@ + + + + Exe + net10.0 + enable + enable + CodegenMainApp + Demo.Codegen.MainApp + + + + + + + + + + + + + + + + + + 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..3682c5d8 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Containerfile @@ -0,0 +1,58 @@ +# syntax=docker/dockerfile:1.7 +# +# Build from the demo root with two contexts: +# +# docker build \ +# --platform linux/amd64 \ +# -f sandbox-worker/Containerfile \ +# --build-context sdk=/Users//durabletask-dotnet \ +# -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 `sdk` build context points at the durabletask-dotnet repo. The main context +# is this demo. 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 +COPY --from=sdk . /src/durabletask-dotnet +COPY . /src/dts-ondemand-sandbox-codegen-demo + +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:DtsSdkRoot=/src/durabletask-dotnet/src/ \ + /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..813f7647 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/sandbox-worker.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + enable + CodegenSandboxWorker + Demo.Codegen.SandboxWorker + + + + + + + + + + + + + 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..f8187ee8 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/Containerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1.7 +# +# Build from the python/ demo directory with the durabletask-python preview SDK +# (PR microsoft/durabletask-python#151) provided as a named build context: +# +# docker build \ +# -f Containerfile \ +# --build-context sdk=/path/to/durabletask-python \ +# -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 durabletask-python preview SDK (with the sandboxes extension), +# plus pandas for the LLM-generated scripts the sandbox executes. +COPY --from=sdk . /src/durabletask-python +RUN pip install --no-cache-dir \ + /src/durabletask-python \ + /src/durabletask-python/durabletask-azuremanaged \ + 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/README.md b/preview-features/on-demand-sandboxes/samples/python/README.md new file mode 100644 index 00000000..670e71e1 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/README.md @@ -0,0 +1,120 @@ +# 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`](https://github.com/microsoft/durabletask-python/pull/151) +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 image (installs SDK + pandas) +├── requirements.txt # Declarer-app dependencies +└── 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.) +- The `durabletask-python` preview source checked out (PR #151) + +## Install + +From the `python/` directory: + +```bash +pip install -r requirements.txt +# Durable Task preview SDK from source (PR microsoft/durabletask-python#151): +pip install -e /path/to/durabletask-python -e /path/to/durabletask-python/durabletask-azuremanaged +``` + +## Build the sandbox image + +From the `python/` directory, pass the durabletask-python checkout as the `sdk` +build context: + +```bash +ACR= +IMAGE=$ACR.azurecr.io/dts-codegen-sandbox-python:v1 + +docker build \ + -f Containerfile \ + --build-context sdk=$HOME/durabletask-python \ + -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. + +## 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/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/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/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..304cf6f8 --- /dev/null +++ b/preview-features/on-demand-sandboxes/samples/python/requirements.txt @@ -0,0 +1,9 @@ +# Declarer-app (main_app.py) dependencies. +# +# The Durable Task SDK itself ships from the durabletask-python preview source +# tree (PR microsoft/durabletask-python#151) and is installed separately, e.g.: +# +# pip install -e /path/to/durabletask-python -e /path/to/durabletask-python/durabletask-azuremanaged +# +azure-identity>=1.16 +openai>=1.40 From 10c2ecc5734ef4ebc877a18a57e5f78c5674c822 Mon Sep 17 00:00:00 2001 From: Nick Greenfield Date: Wed, 17 Jun 2026 10:29:47 -0700 Subject: [PATCH 2/5] Update on-demand sandbox docs to match samples (managed identity) Align the .NET and Python guides and the overview README with the preview-feature-sandbox samples, which are the source of truth. - .NET: SandboxWorkerProfileOptions with Image.ImageRef, Image.ManagedIdentityClientId, and SchedulerManagedIdentityClientId; AddDurableTaskSchedulerSandboxActivitiesClient + EnableSandboxActivitiesAsync; UseWorkItemFilters; preview package names. Adds the missing managed identity. - Python: durabletask.azuremanaged.preview.sandboxes namespace (SandboxWorkerProfile, sandbox_worker_profile, SandboxActivitiesClient, SandboxWorker), image.image_ref / managed identity options, install from preview source. - README: managed-identity prerequisite, image-ref/identity rows in the worker profile reference table, and a Configure the scheduler identity for image pull section. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../on-demand-sandboxes/README.md | 96 +++++++++++++- .../on-demand-sandboxes/docs/dotnet.md | 102 +++++++++++---- .../on-demand-sandboxes/docs/python.md | 122 +++++++++++------- 3 files changed, 241 insertions(+), 79 deletions(-) diff --git a/preview-features/on-demand-sandboxes/README.md b/preview-features/on-demand-sandboxes/README.md index 50b30421..1fe1630a 100644 --- a/preview-features/on-demand-sandboxes/README.md +++ b/preview-features/on-demand-sandboxes/README.md @@ -50,12 +50,17 @@ Before you begin, make sure you have: 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. +- **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** (Python preview flow) that DTS uses to pull your - worker image and start the sandbox. You provide their client IDs on the worker profile - (`image_pull_managed_identity_client_id` and `scheduler_managed_identity_client_id`). +- **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 @@ -102,6 +107,85 @@ flowchart LR 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: @@ -122,7 +206,9 @@ slightly between .NET (`PascalCase`) and Python (`snake_case`) but map one to on | Setting (.NET / Python) | What it controls | Accepted values | Default | | --- | --- | --- | --- | -| `ContainerImage` / `container_image` | 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.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` | diff --git a/preview-features/on-demand-sandboxes/docs/dotnet.md b/preview-features/on-demand-sandboxes/docs/dotnet.md index 5f1fbc44..a14c3124 100644 --- a/preview-features/on-demand-sandboxes/docs/dotnet.md +++ b/preview-features/on-demand-sandboxes/docs/dotnet.md @@ -11,54 +11,105 @@ contains those activity implementations. Your orchestrator still calls activitie 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()`). + +Reference both from your orchestrator app, and the worker package from the sandbox worker +image project. + ## 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, resource shape, concurrency setting, -and the activity names that should run in a sandbox. +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.Worker.AzureManaged.Sandbox; +using Microsoft.DurableTask.Client.AzureManaged; [SandboxWorkerProfile("")] internal sealed class CodeSandboxWorkerProfile : ISandboxWorkerProfile { - public void Configure(SandboxOptions options) + public void Configure(SandboxWorkerProfileOptions options) { - options.ContainerImage = Environment.GetEnvironmentVariable("DTS_SANDBOX_IMAGE") - ?? throw new InvalidOperationException("DTS_SANDBOX_IMAGE is required."); + 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); + options.AddActivity(TaskNames.ExecuteCode, version: ""); } } ``` -Then enable on-demand sandbox discovery when you configure the Durable Task worker in -the main app: +> [!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 -workerBuilder.AddTasks(tasks => tasks.AddAllGeneratedTasks()); -workerBuilder.UseDurableTaskScheduler(options => +builder.Services.AddDurableTaskWorker(workerBuilder => { - options.EndpointAddress = Environment.GetEnvironmentVariable("DTS_ENDPOINT"); - options.TaskHubName = Environment.GetEnvironmentVariable("DTS_TASK_HUB"); - options.Credential = credential; + workerBuilder.AddTasks(tasks => tasks.AddAllGeneratedTasks()); + workerBuilder.UseWorkItemFilters(); + workerBuilder.UseDurableTaskScheduler(options => + { + options.EndpointAddress = endpoint; + options.TaskHubName = taskHub; + options.Credential = credential; + }); }); -workerBuilder.EnableSandboxes(); + +// 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(); ``` -`EnableSandboxes()` is the key line—it turns on on-demand sandbox discovery so DTS picks -up your sandbox worker profiles and routes their declared activities to managed compute. +`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 `SandboxOptions` setting, see the +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: `ContainerImage` is the image with your activity implementations; `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). +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: @@ -102,8 +153,9 @@ 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 `ContainerImage` -option. +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 @@ -115,5 +167,7 @@ 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 index b25653e8..03a27875 100644 --- a/preview-features/on-demand-sandboxes/docs/python.md +++ b/preview-features/on-demand-sandboxes/docs/python.md @@ -14,24 +14,26 @@ configuration. ## Install the SDK The on-demand sandbox APIs ship in a preview package namespace, -`durabletask.azuremanaged.preview.on_demand_sandbox`. Install the Durable Task packages: +`durabletask.azuremanaged.preview.sandboxes`. During private preview they're installed +from the `durabletask-python` preview source tree +([PR microsoft/durabletask-python#151](https://github.com/microsoft/durabletask-python/pull/151)): ```bash -pip install durabletask==1.6.0 durabletask.azuremanaged==1.6.0 +pip install -e /path/to/durabletask-python \ + -e /path/to/durabletask-python/durabletask-azuremanaged ``` > [!NOTE] -> The on-demand sandbox APIs are available starting in `durabletask==1.6.0` and -> `durabletask.azuremanaged==1.6.0`, under the -> `durabletask.azuremanaged.preview.on_demand_sandbox` namespace. +> The on-demand sandbox APIs live under the +> `durabletask.azuremanaged.preview.sandboxes` namespace. ## 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 on-demand 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(...)`. +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 @@ -40,10 +42,10 @@ from azure.identity import DefaultAzureCredential from durabletask import client, task from durabletask.azuremanaged.client import DurableTaskSchedulerClient -from durabletask.azuremanaged.preview.on_demand_sandbox import ( - OnDemandSandboxActivitiesClient, - OnDemandSandboxWorkerProfile, - on_demand_sandbox_worker_profile, +from durabletask.azuremanaged.preview.sandboxes import ( + SandboxActivitiesClient, + SandboxWorkerProfile, + sandbox_worker_profile, ) from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker @@ -52,7 +54,7 @@ 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_ON_DEMAND_SANDBOX_CONTAINER_IMAGE"] +container_image = os.environ["DTS_SANDBOX_CONTAINER_IMAGE"] def hello_orchestrator(ctx: task.OrchestrationContext, name: str): @@ -60,14 +62,14 @@ def hello_orchestrator(ctx: task.OrchestrationContext, name: str): return (yield ctx.call_activity(REMOTE_HELLO, input=name)) -@on_demand_sandbox_worker_profile(worker_profile_id) -class RemoteWorkerProfile(OnDemandSandboxWorkerProfile): +@sandbox_worker_profile(worker_profile_id) +class RemoteWorkerProfile(SandboxWorkerProfile): def configure(self, options) -> None: - options.container_image = container_image - options.image_pull_managed_identity_client_id = os.environ[ - "DTS_ON_DEMAND_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID"] + 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_ON_DEMAND_SANDBOX_SCHEDULER_UMI_CLIENT_ID"] + "DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID"] options.cpu = "1000m" options.memory = "2048Mi" options.max_concurrent_activities = 1 @@ -77,13 +79,13 @@ class RemoteWorkerProfile(OnDemandSandboxWorkerProfile): credential = DefaultAzureCredential() -# Register the on-demand sandbox activity metadata with DTS. -sandbox_client = OnDemandSandboxActivitiesClient( +# 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_on_demand_sandbox_activities() +sandbox_client.enable_sandbox_activities() with DurableTaskSchedulerWorker( host_address=endpoint, @@ -105,37 +107,47 @@ with DurableTaskSchedulerWorker( print(state.serialized_output if state else "no result") ``` -`enable_on_demand_sandbox_activities()` is the key call—it registers the declared -profiles with DTS so it can route those activities to the sandbox image. +`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: `container_image` is the image with your activity implementations; -`image_pull_managed_identity_client_id` / `scheduler_managed_identity_client_id` are the -managed identity client IDs DTS uses to pull the image and start the sandbox; `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). +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 `OnDemandSandboxWorker()`, registers the activity implementations -it owns, and starts. The sandbox worker does **not** configure the DTS endpoint, task -hub, profile id, or credentials—`OnDemandSandboxWorker()` reads the runtime settings -(`DTS_ENDPOINT`, `DTS_TASK_HUB`, `DTS_WORKER_PROFILE_ID`, -`DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES`, `DTS_SANDBOX_ID`, and related values) from -environment variables that DTS injects when it starts the container. +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.on_demand_sandbox import OnDemandSandboxWorker +from durabletask.azuremanaged.preview.sandboxes import SandboxWorker REMOTE_HELLO = "remote_hello" @@ -149,7 +161,7 @@ def _remote_hello(ctx: task.ActivityContext, name: str) -> str: # The registered activity name must match the name declared in the worker profile. _remote_hello.__name__ = REMOTE_HELLO -with OnDemandSandboxWorker() as worker: +with SandboxWorker() as worker: worker.add_activity(_remote_hello) worker.start() print("Python on-demand sandbox remote worker is running.") @@ -164,10 +176,12 @@ declarer app and the remote worker stay in sync. When the worker connects, it re 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: +Build and push the image with a `Containerfile`/`Dockerfile` that installs the preview +SDK and your activity's dependencies, then copies in the worker entry point. During +private preview the SDK is provided from source as a named build context: ```dockerfile +# syntax=docker/dockerfile:1.7 FROM python:3.12-slim AS runtime WORKDIR /app @@ -176,24 +190,31 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* ENV GRPC_DEFAULT_SSL_ROOTS_FILE_PATH=/etc/ssl/certs/ca-certificates.crt -# Install the Durable Task SDKs and your activity dependencies. -RUN pip install --no-cache-dir durabletask==1.6.0 durabletask.azuremanaged==1.6.0 +# Install the durabletask-python preview SDK (with the sandboxes extension), +# plus your activity dependencies. +COPY --from=sdk . /src/durabletask-python +RUN pip install --no-cache-dir \ + /src/durabletask-python \ + /src/durabletask-python/durabletask-azuremanaged COPY remote_worker.py /app/remote_worker.py -COPY activity_names.py /app/activity_names.py +COPY activities.py /app/activities.py -EXPOSE 8080 ENTRYPOINT ["python", "/app/remote_worker.py"] ``` ```bash -docker build -f Containerfile -t . +docker build \ + -f Containerfile \ + --build-context sdk=/path/to/durabletask-python \ + -t . docker push ``` Then set the image reference on the declarer profile (for example, via the -`DTS_ON_DEMAND_SANDBOX_CONTAINER_IMAGE` environment variable). During private preview the -image must be publicly pullable by the sandbox platform. +`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 @@ -205,6 +226,7 @@ overview for details. ## Next steps - [Worker profile configuration reference](./README.md#worker-profile-configuration-reference) -- [End-to-end sample (`examples/on_demand_sandbox`)](https://github.com/microsoft/durabletask-python/tree/main/examples/on_demand_sandbox) +- [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) From f8f9e3d22032466b848bb2c8447bd881b0ced98a Mon Sep 17 00:00:00 2001 From: Nick Greenfield Date: Wed, 17 Jun 2026 13:08:49 -0700 Subject: [PATCH 3/5] Use published preview packages instead of source in sandbox docs/samples Remove all references to building the Durable Task SDK from source and links to the unreleased preview PRs (microsoft/durabletask-python#151, microsoft/durabletask-dotnet#736) so nothing referencing private/unreleased sources goes to public source control. - Docs and sample install steps now use published preview packages: .NET 1.25.0-preview.2, Python durabletask==1.6.0 / durabletask-azuremanaged==1.6.0. - .NET sample: csproj ProjectReferences -> PackageReferences, Directory.Build.props now centralizes DtsSdkVersion, sandbox-worker Containerfile drops the sdk build context and DtsSdkRoot. - Python sample: requirements.txt and Containerfile install from packages; READMEs drop the from-source steps. Note: the .NET package wiring (including Microsoft.DurableTask.Generators) and exact versions need a build validation once the published packages are confirmed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../on-demand-sandboxes/docs/dotnet.md | 13 ++++++-- .../on-demand-sandboxes/docs/python.md | 32 ++++++------------- .../on-demand-sandboxes/samples/README.md | 4 +-- .../samples/dotnet/Directory.Build.props | 6 ++-- .../samples/dotnet/README.md | 14 ++------ .../samples/dotnet/main-app/main-app.csproj | 14 +++----- .../dotnet/sandbox-worker/Containerfile | 14 +++----- .../sandbox-worker/sandbox-worker.csproj | 8 ++--- .../samples/python/Containerfile | 13 +++----- .../samples/python/README.md | 11 ++----- .../samples/python/requirements.txt | 8 ++--- 11 files changed, 49 insertions(+), 88 deletions(-) diff --git a/preview-features/on-demand-sandboxes/docs/dotnet.md b/preview-features/on-demand-sandboxes/docs/dotnet.md index a14c3124..76e3229c 100644 --- a/preview-features/on-demand-sandboxes/docs/dotnet.md +++ b/preview-features/on-demand-sandboxes/docs/dotnet.md @@ -21,8 +21,17 @@ Azure-managed client and worker packages: - `Microsoft.DurableTask.Worker.AzureManaged.Sandboxes` — sandbox-worker side (`UseSandboxWorker()`). -Reference both from your orchestrator app, and the worker package from the sandbox worker -image project. +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 diff --git a/preview-features/on-demand-sandboxes/docs/python.md b/preview-features/on-demand-sandboxes/docs/python.md index 03a27875..ae168d84 100644 --- a/preview-features/on-demand-sandboxes/docs/python.md +++ b/preview-features/on-demand-sandboxes/docs/python.md @@ -13,20 +13,13 @@ configuration. ## Install the SDK -The on-demand sandbox APIs ship in a preview package namespace, -`durabletask.azuremanaged.preview.sandboxes`. During private preview they're installed -from the `durabletask-python` preview source tree -([PR microsoft/durabletask-python#151](https://github.com/microsoft/durabletask-python/pull/151)): +The on-demand sandbox APIs ship under the `durabletask.azuremanaged.preview.sandboxes` +namespace. Install the Durable Task packages: ```bash -pip install -e /path/to/durabletask-python \ - -e /path/to/durabletask-python/durabletask-azuremanaged +pip install durabletask==1.6.0 durabletask-azuremanaged==1.6.0 ``` -> [!NOTE] -> The on-demand sandbox APIs live under the -> `durabletask.azuremanaged.preview.sandboxes` namespace. - ## Step 1 — Declare a sandbox worker profile The declarer app uses a decorated profile class to declare the remote worker image and @@ -176,9 +169,8 @@ declarer app and the remote worker stay in sync. When the worker connects, it re 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 preview -SDK and your activity's dependencies, then copies in the worker entry point. During -private preview the SDK is provided from source as a named build context: +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 @@ -190,12 +182,9 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* ENV GRPC_DEFAULT_SSL_ROOTS_FILE_PATH=/etc/ssl/certs/ca-certificates.crt -# Install the durabletask-python preview SDK (with the sandboxes extension), -# plus your activity dependencies. -COPY --from=sdk . /src/durabletask-python -RUN pip install --no-cache-dir \ - /src/durabletask-python \ - /src/durabletask-python/durabletask-azuremanaged +# 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 @@ -204,10 +193,7 @@ ENTRYPOINT ["python", "/app/remote_worker.py"] ``` ```bash -docker build \ - -f Containerfile \ - --build-context sdk=/path/to/durabletask-python \ - -t . +docker build -f Containerfile -t . docker push ``` diff --git a/preview-features/on-demand-sandboxes/samples/README.md b/preview-features/on-demand-sandboxes/samples/README.md index 5af2d576..089651ef 100644 --- a/preview-features/on-demand-sandboxes/samples/README.md +++ b/preview-features/on-demand-sandboxes/samples/README.md @@ -8,8 +8,8 @@ aggregates the answer. | Directory | Implementation | SDK | | --- | --- | --- | -| [`dotnet/`](dotnet/README.md) | .NET 10 | [durabletask-dotnet #736](https://github.com/microsoft/durabletask-dotnet/pull/736) | -| [`python/`](python/README.md) | Python 3.12 | [durabletask-python #151](https://github.com/microsoft/durabletask-python/pull/151) | +| [`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, diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/Directory.Build.props b/preview-features/on-demand-sandboxes/samples/dotnet/Directory.Build.props index 09b7d6a9..83129993 100644 --- a/preview-features/on-demand-sandboxes/samples/dotnet/Directory.Build.props +++ b/preview-features/on-demand-sandboxes/samples/dotnet/Directory.Build.props @@ -1,11 +1,11 @@ - $(MSBuildThisFileDirectory)../../../durabletask-dotnet/src/ + 1.25.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 index 0b25e418..7a184244 100644 --- a/preview-features/on-demand-sandboxes/samples/dotnet/README.md +++ b/preview-features/on-demand-sandboxes/samples/dotnet/README.md @@ -86,17 +86,8 @@ dts-ondemand-sandbox-codegen-demo/ - 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 `durabletask-dotnet` repo checked out alongside (or override `DtsSdkRoot`) - -Default layout assumed: - -``` -~/durabletask-dotnet/ # private preview SDK source -~/workspace/dts-ondemand-sandbox-codegen-demo/ # this repo -``` - -If your durabletask-dotnet checkout lives elsewhere, override `DtsSdkRoot` on every -`dotnet` and `docker build` command (examples below). +- 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 @@ -109,7 +100,6 @@ IMAGE=$ACR.azurecr.io/dts-codegen-sandbox:v1 docker build \ --platform linux/amd64 \ -f sandbox-worker/Containerfile \ - --build-context sdk=$HOME/durabletask-dotnet \ -t $IMAGE \ . 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 index f672171b..39ee4bf6 100644 --- 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 @@ -13,15 +13,11 @@ - - - - - - - - - + + + + + diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Containerfile b/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Containerfile index 3682c5d8..65ab938e 100644 --- a/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Containerfile +++ b/preview-features/on-demand-sandboxes/samples/dotnet/sandbox-worker/Containerfile @@ -1,11 +1,10 @@ # syntax=docker/dockerfile:1.7 # -# Build from the demo root with two contexts: +# Build from the demo root: # # docker build \ # --platform linux/amd64 \ # -f sandbox-worker/Containerfile \ -# --build-context sdk=/Users//durabletask-dotnet \ # -t .azurecr.io/dts-codegen-sandbox: \ # . # @@ -13,16 +12,14 @@ # binary segfaults under Docker's arm64 emulation on Apple Silicon. amd64 protoc # works under Rosetta and matches what DTS sandboxes run anyway. # -# The `sdk` build context points at the durabletask-dotnet repo. The main context -# is this demo. The sandbox worker is a .NET app that shells out to python3, so -# we install pandas in the runtime stage. +# 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 -COPY --from=sdk . /src/durabletask-dotnet -COPY . /src/dts-ondemand-sandbox-codegen-demo +WORKDIR /src/dts-ondemand-sandbox-codegen-demo +COPY . . WORKDIR /src/dts-ondemand-sandbox-codegen-demo/sandbox-worker RUN case "$TARGETARCH" in \ @@ -35,7 +32,6 @@ RUN case "$TARGETARCH" in \ -r "$runtime_identifier" \ --self-contained false \ -o /app/publish \ - /p:DtsSdkRoot=/src/durabletask-dotnet/src/ \ /p:DebugSymbols=false \ /p:DebugType=None \ && find /app/publish -type f \( -name '*.xml' -o -name '*.pdb' \) -delete 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 index 813f7647..9330cd5e 100644 --- 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 @@ -11,12 +11,8 @@ - - - - - - + + diff --git a/preview-features/on-demand-sandboxes/samples/python/Containerfile b/preview-features/on-demand-sandboxes/samples/python/Containerfile index f8187ee8..a0d1a71c 100644 --- a/preview-features/on-demand-sandboxes/samples/python/Containerfile +++ b/preview-features/on-demand-sandboxes/samples/python/Containerfile @@ -1,11 +1,9 @@ # syntax=docker/dockerfile:1.7 # -# Build from the python/ demo directory with the durabletask-python preview SDK -# (PR microsoft/durabletask-python#151) provided as a named build context: +# Build from the python/ demo directory: # # docker build \ # -f Containerfile \ -# --build-context sdk=/path/to/durabletask-python \ # -t .azurecr.io/dts-codegen-sandbox-python:v1 \ # . # @@ -19,12 +17,11 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* ENV GRPC_DEFAULT_SSL_ROOTS_FILE_PATH=/etc/ssl/certs/ca-certificates.crt -# Install the durabletask-python preview SDK (with the sandboxes extension), -# plus pandas for the LLM-generated scripts the sandbox executes. -COPY --from=sdk . /src/durabletask-python +# 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 \ - /src/durabletask-python \ - /src/durabletask-python/durabletask-azuremanaged \ + durabletask==1.6.0 \ + durabletask-azuremanaged==1.6.0 \ azure-identity \ "pandas==2.2.*" diff --git a/preview-features/on-demand-sandboxes/samples/python/README.md b/preview-features/on-demand-sandboxes/samples/python/README.md index 670e71e1..8194dfdd 100644 --- a/preview-features/on-demand-sandboxes/samples/python/README.md +++ b/preview-features/on-demand-sandboxes/samples/python/README.md @@ -2,8 +2,7 @@ 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`](https://github.com/microsoft/durabletask-python/pull/151) +Task Scheduler (DTS), using the `durabletask.azuremanaged.preview.sandboxes` package. ``` @@ -45,7 +44,6 @@ python/ - 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.) -- The `durabletask-python` preview source checked out (PR #151) ## Install @@ -53,14 +51,12 @@ From the `python/` directory: ```bash pip install -r requirements.txt -# Durable Task preview SDK from source (PR microsoft/durabletask-python#151): -pip install -e /path/to/durabletask-python -e /path/to/durabletask-python/durabletask-azuremanaged +pip install durabletask==1.6.0 durabletask-azuremanaged==1.6.0 ``` ## Build the sandbox image -From the `python/` directory, pass the durabletask-python checkout as the `sdk` -build context: +From the `python/` directory: ```bash ACR= @@ -68,7 +64,6 @@ IMAGE=$ACR.azurecr.io/dts-codegen-sandbox-python:v1 docker build \ -f Containerfile \ - --build-context sdk=$HOME/durabletask-python \ -t $IMAGE \ . diff --git a/preview-features/on-demand-sandboxes/samples/python/requirements.txt b/preview-features/on-demand-sandboxes/samples/python/requirements.txt index 304cf6f8..4b56dadc 100644 --- a/preview-features/on-demand-sandboxes/samples/python/requirements.txt +++ b/preview-features/on-demand-sandboxes/samples/python/requirements.txt @@ -1,9 +1,5 @@ # Declarer-app (main_app.py) dependencies. -# -# The Durable Task SDK itself ships from the durabletask-python preview source -# tree (PR microsoft/durabletask-python#151) and is installed separately, e.g.: -# -# pip install -e /path/to/durabletask-python -e /path/to/durabletask-python/durabletask-azuremanaged -# +durabletask==1.6.0 +durabletask-azuremanaged==1.6.0 azure-identity>=1.16 openai>=1.40 From 77ee87b1809da430cd4f2860de5673dc9738ff80 Mon Sep 17 00:00:00 2001 From: greenie-msft Date: Wed, 17 Jun 2026 14:34:50 -0700 Subject: [PATCH 4/5] Drop unused Worker.AzureManaged.Sandboxes reference from main-app The main-app declares the sandbox worker profile and runs an in-process worker, but never hosts a sandbox worker (UseSandboxWorker lives in sandbox-worker). It only needs Client.AzureManaged[.Sandboxes] and Worker.AzureManaged, so drop the unused Worker.AzureManaged.Sandboxes package reference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../on-demand-sandboxes/samples/dotnet/main-app/main-app.csproj | 1 - 1 file changed, 1 deletion(-) 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 index 39ee4bf6..159945ec 100644 --- 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 @@ -16,7 +16,6 @@ - From 2cff3ddb81558d36719711a6bf586f7289a4ec06 Mon Sep 17 00:00:00 2001 From: greenie-msft Date: Wed, 17 Jun 2026 15:39:21 -0700 Subject: [PATCH 5/5] Add azd + AKS infra to on-demand sandbox samples Adds Azure Developer CLI (azd) deployment to the .NET and Python on-demand sandbox samples so they can run in the cloud: - AKS hosts the main-app orchestrator (workload identity); DTS starts the sandbox worker image on demand (never deployed to the cluster). - The Durable Task Scheduler is taken as an existing resource (schedulerName + schedulerResourceGroupName) and patched out of band for the preview; the template never creates one. A postprovision hook attaches the workload identity to the scheduler (merge-safe PATCH). - Azure OpenAI gpt-5.1 (2025-11-13, GlobalStandard) in East US backs the in-process GenerateCode activity. - ACR builds both images server-side; a single user-assigned identity covers AKS workload identity, ACR pull, and sandbox connect-back. - Pin Microsoft.DurableTask.Generators to its own version line to avoid the NU1603 restore warning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../samples/dotnet/Directory.Build.props | 2 + .../samples/dotnet/README.md | 81 ++++++- .../samples/dotnet/azure.yaml | 24 ++ .../samples/dotnet/infra/abbreviations.json | 17 ++ .../dotnet/infra/app/federated-identity.bicep | 34 +++ .../samples/dotnet/infra/app/openai.bicep | 92 ++++++++ .../dotnet/infra/app/scheduler-access.bicep | 53 +++++ .../infra/app/user-assigned-identity.bicep | 17 ++ .../dotnet/infra/core/host/aks-cluster.bicep | 94 ++++++++ .../infra/core/host/container-registry.bicep | 59 +++++ .../dotnet/infra/core/networking/vnet.bicep | 40 ++++ .../infra/core/security/registry-access.bicep | 19 ++ .../dotnet/infra/core/security/role.bicep | 21 ++ .../samples/dotnet/infra/main.bicep | 222 ++++++++++++++++++ .../samples/dotnet/infra/main.parameters.json | 24 ++ .../samples/dotnet/main-app/Containerfile | 41 ++++ .../samples/dotnet/main-app/main-app.csproj | 2 +- .../main-app/manifests/deployment.tmpl.yaml | 57 +++++ .../samples/dotnet/scripts/acr-build.sh | 45 ++++ .../scripts/attach-scheduler-identity.sh | 51 ++++ .../samples/python/Containerfile.mainapp | 24 ++ .../samples/python/README.md | 77 +++++- .../samples/python/azure.yaml | 24 ++ .../samples/python/infra/abbreviations.json | 17 ++ .../python/infra/app/federated-identity.bicep | 34 +++ .../samples/python/infra/app/openai.bicep | 92 ++++++++ .../python/infra/app/scheduler-access.bicep | 53 +++++ .../infra/app/user-assigned-identity.bicep | 17 ++ .../python/infra/core/host/aks-cluster.bicep | 94 ++++++++ .../infra/core/host/container-registry.bicep | 59 +++++ .../python/infra/core/networking/vnet.bicep | 40 ++++ .../infra/core/security/registry-access.bicep | 19 ++ .../python/infra/core/security/role.bicep | 21 ++ .../samples/python/infra/main.bicep | 222 ++++++++++++++++++ .../samples/python/infra/main.parameters.json | 24 ++ .../python/manifests/deployment.tmpl.yaml | 57 +++++ .../samples/python/scripts/acr-build.sh | 45 ++++ .../scripts/attach-scheduler-identity.sh | 51 ++++ 38 files changed, 1960 insertions(+), 5 deletions(-) create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/azure.yaml create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/infra/abbreviations.json create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/infra/app/federated-identity.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/infra/app/openai.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/infra/app/scheduler-access.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/infra/app/user-assigned-identity.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/infra/core/host/aks-cluster.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/infra/core/host/container-registry.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/infra/core/networking/vnet.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/infra/core/security/registry-access.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/infra/core/security/role.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/infra/main.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/infra/main.parameters.json create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/main-app/Containerfile create mode 100644 preview-features/on-demand-sandboxes/samples/dotnet/main-app/manifests/deployment.tmpl.yaml create mode 100755 preview-features/on-demand-sandboxes/samples/dotnet/scripts/acr-build.sh create mode 100755 preview-features/on-demand-sandboxes/samples/dotnet/scripts/attach-scheduler-identity.sh create mode 100644 preview-features/on-demand-sandboxes/samples/python/Containerfile.mainapp create mode 100644 preview-features/on-demand-sandboxes/samples/python/azure.yaml create mode 100644 preview-features/on-demand-sandboxes/samples/python/infra/abbreviations.json create mode 100644 preview-features/on-demand-sandboxes/samples/python/infra/app/federated-identity.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/python/infra/app/openai.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/python/infra/app/scheduler-access.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/python/infra/app/user-assigned-identity.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/python/infra/core/host/aks-cluster.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/python/infra/core/host/container-registry.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/python/infra/core/networking/vnet.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/python/infra/core/security/registry-access.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/python/infra/core/security/role.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/python/infra/main.bicep create mode 100644 preview-features/on-demand-sandboxes/samples/python/infra/main.parameters.json create mode 100644 preview-features/on-demand-sandboxes/samples/python/manifests/deployment.tmpl.yaml create mode 100755 preview-features/on-demand-sandboxes/samples/python/scripts/acr-build.sh create mode 100755 preview-features/on-demand-sandboxes/samples/python/scripts/attach-scheduler-identity.sh diff --git a/preview-features/on-demand-sandboxes/samples/dotnet/Directory.Build.props b/preview-features/on-demand-sandboxes/samples/dotnet/Directory.Build.props index 83129993..68c0dd96 100644 --- a/preview-features/on-demand-sandboxes/samples/dotnet/Directory.Build.props +++ b/preview-features/on-demand-sandboxes/samples/dotnet/Directory.Build.props @@ -6,6 +6,8 @@ --> 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 index 7a184244..58dfaf0f 100644 --- a/preview-features/on-demand-sandboxes/samples/dotnet/README.md +++ b/preview-features/on-demand-sandboxes/samples/dotnet/README.md @@ -64,14 +64,19 @@ flowchart LR ``` dts-ondemand-sandbox-codegen-demo/ -├── Directory.Build.props # Points at the durabletask-dotnet SDK source +├── Directory.Build.props # Pins the DTS preview package version ├── data/sales_q1.csv # Sample dataset (~35 rows) -├── main-app/ # Orchestrator host (.NET 10) +├── 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 +│ ├── 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 @@ -139,6 +144,76 @@ 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?` 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/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/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/main-app.csproj b/preview-features/on-demand-sandboxes/samples/dotnet/main-app/main-app.csproj index 159945ec..103b60dd 100644 --- 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 @@ -16,7 +16,7 @@ - + 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/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.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 index 8194dfdd..cf79e16f 100644 --- a/preview-features/on-demand-sandboxes/samples/python/README.md +++ b/preview-features/on-demand-sandboxes/samples/python/README.md @@ -26,8 +26,13 @@ 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 image (installs SDK + pandas) +├── 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) ``` @@ -97,6 +102,76 @@ The declarer prints a dataset preview, the AOAI-generated Python (prefixed 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?` 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/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/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/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}'."