Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/deadline/maya_submitter/maya_render_submitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,59 @@

logger = getLogger(__name__)

# The "deadline-cloud" conda channel on service-managed fleets only provides
# Maya packages built for Linux. Custom channels may add Windows packages, so we
# only constrain the OS when "deadline-cloud" is the sole channel in use.
DEADLINE_CLOUD_CONDA_CHANNEL = "deadline-cloud"


def _augment_host_requirements_for_conda_channel(
queue_parameters: list[dict[str, Any]],
host_requirements: Optional[dict[str, Any]],
) -> Optional[dict[str, Any]]:
"""Add a Linux OS host requirement when the CondaChannels parameter is exactly
"deadline-cloud".

The Deadline Cloud service-managed Maya conda packages only exist for Linux. When
a job relies solely on the "deadline-cloud" channel, restrict it to Linux fleets so
it isn't scheduled onto Windows workers that can't provide the package. Customers
using custom channels (which may carry Windows packages) are left untouched.

Returns the (possibly newly created) host requirements dict, or the original value
when no change is needed.
"""
conda_channels = None
for param in queue_parameters:
if param["name"] == "CondaChannels":
conda_channels = param.get("value", param.get("default", ""))
break

# Only act when "deadline-cloud" is the exact, sole channel.
if conda_channels is None or conda_channels.split() != [DEADLINE_CLOUD_CONDA_CHANNEL]:
return host_requirements

# Copy so we don't mutate a caller-owned dict (e.g. from the host requirements tab).
result = deepcopy(host_requirements) if host_requirements else {}
attributes = result.setdefault("attributes", [])

os_family = next(
(attr for attr in attributes if attr.get("name") == "attr.worker.os.family"), None
)
if os_family is None:
attributes.append({"name": "attr.worker.os.family", "anyOf": ["linux"]})
else:
# An OS constraint already exists (e.g. from the host requirements tab).
# Intersect it with "linux" so we never widen the user's selection.
any_of = os_family.get("anyOf")
all_of = os_family.get("allOf")
if any_of is not None:
os_family["anyOf"] = ["linux"] if "linux" in any_of else any_of
elif all_of is None:
os_family["anyOf"] = ["linux"]
# If allOf is set we leave it alone; the user has an explicit constraint.

return result


def _populate_selectable_cameras(
render_settings: "RenderSubmitterUISettings",
Expand Down Expand Up @@ -827,6 +880,10 @@ def get_job_bundle_for_submission(
"""
context = create_submission_context()

host_requirements = _augment_host_requirements_for_conda_channel(
queue_parameters or [], host_requirements
)

result: dict[str, Any] = {
"job_template": get_job_template_for_submission(
settings, host_requirements, context=context
Expand Down Expand Up @@ -884,6 +941,10 @@ def on_create_job_bundle_callback(

job_bundle_path = Path(job_bundle_dir)

host_requirements = _augment_host_requirements_for_conda_channel(
cast(list[dict[str, Any]], queue_parameters), host_requirements
)

# Reuse the same context — no redundant computation
job_template = get_job_template_for_submission(settings, host_requirements, context=context)
parameter_values = get_parameter_values_for_submission(
Expand Down
126 changes: 126 additions & 0 deletions test/unit/deadline_submitter_for_maya/scripts/test_submitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,129 @@ def test_cameras_sorted_alphabetically(self):
"middle_cam",
"zebra_cam",
]


class TestCondaChannelHostRequirement:
"""Tests for _augment_host_requirements_for_conda_channel.

The "deadline-cloud" conda channel only ships Linux Maya packages, so a job that
relies solely on it must be constrained to Linux fleets.
"""

@pytest.fixture(autouse=True)
def mock_maya_modules(self):
"""Mock Maya modules so we can import the submitter module."""
mocks = {
"maya": Mock(),
"maya.cmds": Mock(),
"maya.mel": Mock(),
"maya.app": Mock(),
"maya.app.renderSetup": Mock(),
"maya.app.renderSetup.model": Mock(),
"maya.app.renderSetup.model.renderSetupPreferences": Mock(),
}
saved = {}
added = []
for mod_name, mock_obj in mocks.items():
if mod_name in sys.modules:
saved[mod_name] = sys.modules[mod_name]
else:
added.append(mod_name)
sys.modules[mod_name] = mock_obj

modules_to_clear = [
"deadline.maya_submitter.cameras",
"deadline.maya_submitter.render_layers",
"deadline.maya_submitter.data_classes",
"deadline.maya_submitter.maya_render_submitter",
]
for mod_name in modules_to_clear:
if mod_name in sys.modules:
saved[mod_name] = sys.modules.pop(mod_name)

yield

for mod_name in added:
sys.modules.pop(mod_name, None)
for mod_name, original in saved.items():
if original is not None:
sys.modules[mod_name] = original
else:
sys.modules.pop(mod_name, None)

@staticmethod
def _augment(queue_parameters, host_requirements=None):
from deadline.maya_submitter.maya_render_submitter import (
_augment_host_requirements_for_conda_channel,
)

return _augment_host_requirements_for_conda_channel(queue_parameters, host_requirements)

def test_deadline_cloud_channel_adds_linux_requirement(self):
"""The exact 'deadline-cloud' channel adds a Linux OS host requirement."""
result = self._augment([{"name": "CondaChannels", "value": "deadline-cloud"}], None)

assert result == {"attributes": [{"name": "attr.worker.os.family", "anyOf": ["linux"]}]}

def test_channel_read_from_default_when_no_value(self):
"""The channel is read from 'default' when no explicit 'value' is set."""
result = self._augment([{"name": "CondaChannels", "default": "deadline-cloud"}], None)

assert result == {"attributes": [{"name": "attr.worker.os.family", "anyOf": ["linux"]}]}

def test_custom_channel_left_untouched(self):
"""A custom channel may carry Windows packages, so no OS constraint is added."""
result = self._augment([{"name": "CondaChannels", "value": "my-custom-channel"}], None)

assert result is None

def test_deadline_cloud_with_extra_channel_left_untouched(self):
"""When extra channels are present we cannot assume Linux-only, so leave it alone."""
result = self._augment(
[{"name": "CondaChannels", "value": "deadline-cloud conda-forge"}], None
)

assert result is None

def test_no_conda_channels_parameter_left_untouched(self):
"""Without a CondaChannels parameter, host requirements are unchanged."""
sentinel = {"attributes": [{"name": "attr.worker.os.family", "anyOf": ["windows"]}]}
result = self._augment([{"name": "RezPackages", "value": "foo"}], sentinel)

assert result is sentinel

def test_merges_into_existing_host_requirements_without_mutating(self):
"""Existing (non-OS) host requirements are preserved and the input isn't mutated."""
existing = {"amounts": [{"name": "amount.worker.vcpu", "min": 4}]}
result = self._augment([{"name": "CondaChannels", "value": "deadline-cloud"}], existing)

assert result == {
"amounts": [{"name": "amount.worker.vcpu", "min": 4}],
"attributes": [{"name": "attr.worker.os.family", "anyOf": ["linux"]}],
}
# The caller-owned dict must not be mutated.
assert "attributes" not in existing

def test_intersects_existing_os_constraint_to_linux(self):
"""An existing OS anyOf that includes linux is narrowed to linux only."""
existing = {
"attributes": [
{"name": "attr.worker.os.family", "anyOf": ["linux", "windows"]},
]
}
result = self._augment([{"name": "CondaChannels", "value": "deadline-cloud"}], existing)

assert result == {"attributes": [{"name": "attr.worker.os.family", "anyOf": ["linux"]}]}

def test_leaves_windows_only_constraint_alone(self):
"""If the user explicitly required windows-only (no linux), we don't widen it."""
existing = {
"attributes": [
{"name": "attr.worker.os.family", "anyOf": ["windows"]},
]
}
result = self._augment([{"name": "CondaChannels", "value": "deadline-cloud"}], existing)

# The job will not match any fleet, but that's the user's explicit choice;
# we never silently widen a constraint they set.
assert result == {"attributes": [{"name": "attr.worker.os.family", "anyOf": ["windows"]}]}
Loading