diff --git a/src/deadline/maya_submitter/maya_render_submitter.py b/src/deadline/maya_submitter/maya_render_submitter.py index 46b216b9..5fcaec11 100644 --- a/src/deadline/maya_submitter/maya_render_submitter.py +++ b/src/deadline/maya_submitter/maya_render_submitter.py @@ -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", @@ -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 @@ -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( diff --git a/test/unit/deadline_submitter_for_maya/scripts/test_submitter.py b/test/unit/deadline_submitter_for_maya/scripts/test_submitter.py index 75a37277..66c29456 100644 --- a/test/unit/deadline_submitter_for_maya/scripts/test_submitter.py +++ b/test/unit/deadline_submitter_for_maya/scripts/test_submitter.py @@ -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"]}]}