Skip to content

Commit 2ccbf85

Browse files
committed
Align sandbox preview APIs with dotnet
1 parent 34c7f8c commit 2ccbf85

4 files changed

Lines changed: 220 additions & 87 deletions

File tree

durabletask-azuremanaged/durabletask/azuremanaged/preview/sandboxes/worker_profiles.py

Lines changed: 67 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,43 @@
1919
DEFAULT_CPU = "1000m"
2020
DEFAULT_MEMORY = "2048Mi"
2121
DEFAULT_MAX_CONCURRENT_ACTIVITIES = 100
22+
MIN_CPU_MILLICORES = 250
23+
MAX_CPU_MILLICORES = 16000
24+
CPU_STEP_MILLICORES = 250
25+
MEMORY_MIB_PER_CORE = 2 * 1024
2226

2327

2428
@dataclass
25-
class SandboxWorkerProfileOptions:
26-
"""Options for a decorated sandbox worker profile."""
29+
class SandboxWorkerProfileImageOptions:
30+
"""Options for the sandbox worker image DTS should start."""
2731

28-
worker_profile_id: str
2932
# Full OCI image reference for the sandbox worker container, for example
3033
# "myregistry.azurecr.io/workers/hello:1.0" or
3134
# "myregistry.azurecr.io/workers/hello@sha256:0123456789abcdef...".
32-
container_image: Optional[str] = None
33-
image_pull_managed_identity_client_id: Optional[str] = None
35+
image_ref: Optional[str] = None
36+
managed_identity_client_id: Optional[str] = None
37+
entrypoint: list[str] = field(default_factory=list[str])
38+
cmd: list[str] = field(default_factory=list[str])
39+
40+
41+
@dataclass
42+
class SandboxWorkerProfileOptions:
43+
"""Options for a decorated sandbox worker profile."""
44+
45+
@dataclass(frozen=True)
46+
class Activity:
47+
"""Activity name and optional version for a sandbox worker profile."""
48+
49+
name: str
50+
version: Optional[str]
51+
52+
worker_profile_id: str
53+
image: SandboxWorkerProfileImageOptions = field(default_factory=SandboxWorkerProfileImageOptions)
3454
scheduler_managed_identity_client_id: Optional[str] = None
3555
cpu: str = DEFAULT_CPU
3656
memory: str = DEFAULT_MEMORY
3757
environment_variables: dict[str, str] = field(default_factory=dict[str, str])
3858
max_concurrent_activities: int = DEFAULT_MAX_CONCURRENT_ACTIVITIES
39-
entrypoint: list[str] = field(default_factory=list[str])
40-
cmd: list[str] = field(default_factory=list[str])
4159
activities: list[SandboxActivity] = field(default_factory=list[SandboxActivity])
4260

4361
def add_activity(
@@ -50,6 +68,11 @@ def add_activity(
5068
name=normalize_required(activity_name, "Sandbox activity name is required."),
5169
version=(version.strip() if version and version.strip() else None)))
5270

71+
def add_activities(self, activities: Iterable[Activity]) -> None:
72+
"""Add activity names and versions to the sandbox worker profile."""
73+
for activity in activities:
74+
self.add_activity(activity.name, activity.version)
75+
5376

5477
class SandboxWorkerProfile:
5578
"""Base class for configuring a decorated sandbox worker profile."""
@@ -94,21 +117,19 @@ def _build_sandbox_worker_profile(
94117
activities: Iterable[SandboxActivity],
95118
scheduler_managed_identity_client_id: Optional[str],
96119
worker_profile_id: str,
97-
container_image: Optional[str] = None,
98-
image_pull_managed_identity_client_id: Optional[str] = None,
120+
image: Optional[SandboxWorkerProfileImageOptions] = None,
99121
cpu: str = DEFAULT_CPU,
100122
memory: str = DEFAULT_MEMORY,
101123
environment_variables: Optional[dict[str, str]] = None,
102-
max_concurrent_activities: int = DEFAULT_MAX_CONCURRENT_ACTIVITIES,
103-
entrypoint: Optional[Iterable[str]] = None,
104-
cmd: Optional[Iterable[str]] = None) -> pb.SandboxWorkerProfile:
124+
max_concurrent_activities: int = DEFAULT_MAX_CONCURRENT_ACTIVITIES) -> pb.SandboxWorkerProfile:
105125
"""Build a sandbox activity worker_profile.
106126
107127
Args:
108-
container_image: Full OCI image reference for the sandbox worker container,
128+
image: Sandbox worker image options with the full OCI image reference,
109129
such as "myregistry.azurecr.io/workers/hello:1.0" or
110130
"myregistry.azurecr.io/workers/hello@sha256:0123456789abcdef...".
111131
"""
132+
image_options = image or SandboxWorkerProfileImageOptions()
112133
resolved_activities = resolve_activities(activities)
113134
if not resolved_activities:
114135
raise ValueError("Sandbox activity worker_profile requires at least one activity.")
@@ -120,7 +141,7 @@ def _build_sandbox_worker_profile(
120141
raise ValueError("Sandbox activity max concurrent activities must be greater than zero.")
121142

122143
image_ref = normalize_required(
123-
container_image,
144+
image_options.image_ref,
124145
"Sandbox activity image metadata requires a container image reference like "
125146
"'myregistry.azurecr.io/workers/hello:1.0' or "
126147
"'myregistry.azurecr.io/workers/hello@sha256:...'.")
@@ -129,11 +150,11 @@ def _build_sandbox_worker_profile(
129150
scheduler_managed_identity_client_id,
130151
"Sandbox activity worker_profile requires the managed identity client ID workers use to connect to Durable Task Scheduler.")
131152
resolved_image_pull_managed_identity_client_id = normalize_required(
132-
image_pull_managed_identity_client_id,
133-
"Sandbox activity worker_profile requires the managed identity client ID ADC uses to pull the worker image.")
153+
image_options.managed_identity_client_id,
154+
"Sandbox activity worker_profile requires the managed identity client ID used to pull the worker image.")
134155

135-
resolved_cpu = _normalize_cpu(cpu)
136-
resolved_memory = _normalize_memory(memory)
156+
resolved_cpu, cpu_millicores = _normalize_cpu(cpu)
157+
resolved_memory = _normalize_memory(memory, cpu_millicores)
137158

138159
worker_profile = pb.SandboxWorkerProfile(
139160
worker_profile_id=worker_profile_id.strip(),
@@ -150,8 +171,8 @@ def _build_sandbox_worker_profile(
150171
for activity in resolved_activities
151172
])
152173
worker_profile.environment_variables.update(environment_variables or {})
153-
worker_profile.image.entrypoint.extend(_normalize_optional_strings(entrypoint or []))
154-
worker_profile.image.cmd.extend(_normalize_optional_strings(cmd or []))
174+
worker_profile.image.entrypoint.extend(_normalize_optional_strings(image_options.entrypoint))
175+
worker_profile.image.cmd.extend(_normalize_optional_strings(image_options.cmd))
155176
return worker_profile
156177

157178

@@ -175,15 +196,12 @@ def build_sandbox_worker_profiles() -> list[pb.SandboxWorkerProfile]:
175196
worker_profiles.append(_build_sandbox_worker_profile(
176197
activities=activities,
177198
worker_profile_id=profile.worker_profile_id,
178-
container_image=profile.container_image,
179-
image_pull_managed_identity_client_id=profile.image_pull_managed_identity_client_id,
199+
image=profile.image,
180200
scheduler_managed_identity_client_id=profile.scheduler_managed_identity_client_id,
181201
cpu=profile.cpu,
182202
memory=profile.memory,
183203
environment_variables=profile.environment_variables,
184-
max_concurrent_activities=profile.max_concurrent_activities,
185-
entrypoint=profile.entrypoint,
186-
cmd=profile.cmd))
204+
max_concurrent_activities=profile.max_concurrent_activities))
187205

188206
return worker_profiles
189207

@@ -236,46 +254,61 @@ def _normalize_optional_strings(values: Iterable[str]) -> list[str]:
236254
return [value.strip() for value in values if value and value.strip()]
237255

238256

239-
def _normalize_cpu(value: str) -> str:
257+
def _normalize_cpu(value: str) -> tuple[str, int]:
240258
normalized = normalize_required(value, "Sandbox activity worker_profile requires CPU resources.")
241259
milli_cpu = _try_parse_cpu_millicores(normalized)
242-
if milli_cpu is None or milli_cpu <= 0:
260+
if (milli_cpu is None
261+
or milli_cpu < MIN_CPU_MILLICORES
262+
or milli_cpu > MAX_CPU_MILLICORES
263+
or milli_cpu % CPU_STEP_MILLICORES != 0):
243264
raise ValueError(
244-
"Sandbox activity CPU resources must be a positive Kubernetes-style CPU quantity. "
265+
"Sandbox activity CPU resources must match an ADC sandbox CPU tier: "
266+
"250m through 16000m, in 250m increments. "
245267
"Use formats like '500m', '2', or '0.5'.")
246-
return normalized
268+
return normalized, milli_cpu
247269

248270

249-
def _normalize_memory(value: str) -> str:
271+
def _normalize_memory(value: str, cpu_millicores: int) -> str:
250272
normalized = normalize_required(value, "Sandbox activity worker_profile requires memory resources.")
273+
max_memory_mib = cpu_millicores * MEMORY_MIB_PER_CORE // 1000
251274
memory_mib = _try_parse_memory_mib(normalized)
252275
if memory_mib is None or memory_mib <= 0:
253276
raise ValueError(
254277
"Sandbox activity memory resources must be a positive Kubernetes-style memory quantity. "
255278
"Use formats like '256Mi', '1Gi', or '2048'.")
279+
if memory_mib > max_memory_mib:
280+
raise ValueError(
281+
"Sandbox activity memory resources exceed the ADC sandbox tier maximum for the configured CPU. "
282+
f"Maximum memory for CPU '{cpu_millicores}m' is {max_memory_mib}Mi.")
256283
return normalized
257284

258285

259286
def _try_parse_cpu_millicores(value: str) -> Optional[int]:
260287
try:
261288
if value[-1:].lower() == "m":
262289
return int(value[:-1])
263-
return int(Decimal(value) * 1000)
290+
millicores = Decimal(value) * 1000
291+
return int(millicores) if millicores == millicores.to_integral_value() else None
264292
except (InvalidOperation, ValueError):
265293
return None
266294

267295

268296
def _try_parse_memory_mib(value: str) -> Optional[int]:
269297
try:
270298
if value[-2:].lower() == "gi":
271-
return int(Decimal(value[:-2]) * 1024)
299+
return _try_convert_memory_to_mib(Decimal(value[:-2]), 1024)
272300
if value[-2:].lower() == "mi":
273-
return int(Decimal(value[:-2]))
274-
return int(Decimal(value))
301+
return _try_convert_memory_to_mib(Decimal(value[:-2]), 1)
302+
return _try_convert_memory_to_mib(Decimal(value), 1)
275303
except (InvalidOperation, ValueError):
276304
return None
277305

278306

307+
def _try_convert_memory_to_mib(value: Decimal, multiplier: int) -> Optional[int]:
308+
memory_mib = value * multiplier
309+
return int(memory_mib) if memory_mib == memory_mib.to_integral_value() else None
310+
311+
279312
def _parse_sandbox_provider(sandbox_provider: Optional[str]) -> "pb.SandboxProviderKind":
280313
if not sandbox_provider:
281314
return pb.SANDBOX_PROVIDER_KIND_UNSPECIFIED

examples/sandboxes/README.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@ Set these before running the declarer app:
2121
Bash:
2222

2323
~~~bash
24-
export DTS_ENDPOINT="<scheduler endpoint>"
25-
export DTS_TASK_HUB="<task hub name>"
26-
export DTS_WORKER_PROFILE_ID="python-sandbox-worker"
24+
export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=https://<scheduler-endpoint>;TaskHub=<task-hub-name>;Authentication=DefaultAzure"
2725
export DTS_SANDBOX_CONTAINER_IMAGE="<container image reference>"
2826
export DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID="<image-pull UMI client ID>"
2927
export DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID="<scheduler UMI client ID>"
@@ -32,21 +30,25 @@ export DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID="<scheduler UMI client ID>"
3230
PowerShell:
3331

3432
~~~powershell
35-
$env:DTS_ENDPOINT = "<scheduler endpoint>"
36-
$env:DTS_TASK_HUB = "<task hub name>"
37-
$env:DTS_WORKER_PROFILE_ID = "python-sandbox-worker"
33+
$env:DURABLE_TASK_SCHEDULER_CONNECTION_STRING = "Endpoint=https://<scheduler-endpoint>;TaskHub=<task-hub-name>;Authentication=DefaultAzure"
3834
$env:DTS_SANDBOX_CONTAINER_IMAGE = "<container image reference>"
3935
$env:DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID = "<image-pull UMI client ID>"
4036
$env:DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID = "<scheduler UMI client ID>"
4137
~~~
4238

39+
For `Authentication=DefaultAzure`, sign in with Azure CLI or configure another
40+
supported Azure identity before running the declarer app. For a local emulator,
41+
use `Authentication=None`.
42+
4343
After pushing the remote worker image, set `DTS_SANDBOX_CONTAINER_IMAGE` to
4444
the pushed image reference. `RemoteWorkerProfile.configure()` declares CPU,
4545
memory, max concurrency, customer environment variables, and sandbox activity
46-
identities with `options.add_activity(name, version)`. This sample uses an
47-
unversioned activity identity (`version=None`) because Python orchestrations
48-
currently schedule activities by name. The declarer and remote worker both use
49-
`activities.py` so they stay in sync.
46+
identities with `options.image.image_ref`,
47+
`options.image.managed_identity_client_id`, and
48+
`options.add_activity(name, version)`. This sample uses an unversioned activity
49+
identity (`version=None`) because Python orchestrations currently schedule
50+
activities by name. The declarer and remote worker both use `activities.py` so
51+
they stay in sync.
5052

5153
The remote worker code cannot pass Durable Task Scheduler runtime settings to the SDK. In a
5254
sandbox, `SandboxWorker()` reads `DTS_ENDPOINT`,
@@ -81,7 +83,8 @@ docker build `
8183
docker push <public container image reference>
8284
~~~
8385

84-
Private preview requires the image to be publicly pullable by the sandbox platform.
86+
The sandbox platform pulls the image with `DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID`,
87+
so grant that identity pull access to the pushed image.
8588

8689
## Run the declarer app
8790

examples/sandboxes/main_app.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44

5+
from azure.core.credentials import TokenCredential
56
from azure.identity import DefaultAzureCredential
67

78
from durabletask import client, task
@@ -13,6 +14,8 @@
1314

1415
from activities import REMOTE_HELLO
1516

17+
WORKER_PROFILE_ID = "remote-hello-profile"
18+
1619

1720
def hello_orchestrator(ctx: task.OrchestrationContext, name: str):
1821
"""Orchestrator that calls an activity executed by the remote worker image."""
@@ -26,25 +29,58 @@ def _get_required_env(name: str) -> str:
2629
raise RuntimeError(f"Set {name} before running the sandbox sample.")
2730

2831

29-
taskhub_name = os.getenv("DTS_TASK_HUB") or "SandboxPocHub"
30-
endpoint = os.getenv("DTS_ENDPOINT", "http://localhost:8080")
31-
worker_profile_id = _get_required_env("DTS_WORKER_PROFILE_ID")
32-
container_image = (
33-
os.getenv("DTS_SANDBOX_CONTAINER_IMAGE")
34-
or "sandboxes-remote-worker:local")
32+
def _parse_scheduler_connection_string(connection_string: str) -> dict[str, str]:
33+
settings: dict[str, str] = {}
34+
for segment in connection_string.split(";"):
35+
if not segment.strip():
36+
continue
37+
key, separator, value = segment.partition("=")
38+
if not separator or not key.strip():
39+
raise RuntimeError(
40+
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING must use key=value segments.")
41+
settings[key.strip().lower()] = value.strip()
42+
return settings
43+
44+
45+
def _resolve_scheduler_connection() -> tuple[str, str, bool, TokenCredential | None]:
46+
settings = _parse_scheduler_connection_string(
47+
_get_required_env("DURABLE_TASK_SCHEDULER_CONNECTION_STRING"))
48+
endpoint = settings.get("endpoint")
49+
taskhub = settings.get("taskhub")
50+
if not endpoint:
51+
raise RuntimeError("DURABLE_TASK_SCHEDULER_CONNECTION_STRING must include Endpoint.")
52+
if not taskhub:
53+
raise RuntimeError("DURABLE_TASK_SCHEDULER_CONNECTION_STRING must include TaskHub.")
54+
55+
authentication = settings.get("authentication", "").lower()
56+
if authentication in ("", "none"):
57+
credential = None
58+
elif authentication == "defaultazure":
59+
credential = DefaultAzureCredential()
60+
else:
61+
raise RuntimeError(
62+
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING Authentication must be DefaultAzure or None.")
63+
64+
endpoint = endpoint.strip()
65+
secure_channel = endpoint.lower().startswith(("https://", "grpcs://"))
66+
return endpoint, taskhub.strip(), secure_channel, credential
67+
68+
69+
endpoint, taskhub_name, secure_channel, credential = _resolve_scheduler_connection()
70+
container_image = _get_required_env("DTS_SANDBOX_CONTAINER_IMAGE")
3571
image_pull_managed_identity_client_id = _get_required_env("DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID")
3672
scheduler_managed_identity_client_id = _get_required_env("DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID")
3773
sample_input = os.getenv("DTS_SAMPLE_HELLO_INPUT", "sandbox Python")
3874
sample_timeout_seconds = int(os.getenv("DTS_SAMPLE_TIMEOUT_SECONDS", "300"))
3975

4076

41-
@sandbox_worker_profile(worker_profile_id)
77+
@sandbox_worker_profile(WORKER_PROFILE_ID)
4278
class RemoteWorkerProfile(SandboxWorkerProfile):
4379
"""Sandbox worker profile used by the sample remote activity."""
4480

4581
def configure(self, options) -> None:
46-
options.container_image = container_image
47-
options.image_pull_managed_identity_client_id = image_pull_managed_identity_client_id
82+
options.image.image_ref = container_image
83+
options.image.managed_identity_client_id = image_pull_managed_identity_client_id
4884
options.scheduler_managed_identity_client_id = scheduler_managed_identity_client_id
4985
options.cpu = "1000m"
5086
options.memory = "2048Mi"
@@ -57,9 +93,6 @@ def configure(self, options) -> None:
5793
print(f"Using endpoint: {endpoint}")
5894
print(f"Declaring sandbox activity image: {container_image}")
5995

60-
secure_channel = endpoint.startswith("https://") or endpoint.startswith("grpcs://")
61-
credential = DefaultAzureCredential() if secure_channel else None
62-
6396
sandboxes_client = SandboxActivitiesClient(
6497
host_address=endpoint,
6598
secure_channel=secure_channel,

0 commit comments

Comments
 (0)