From 8499b7da85d52dc794040035bd647f2b4f236a85 Mon Sep 17 00:00:00 2001 From: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> Date: Tue, 9 Jun 2026 12:44:18 -0700 Subject: [PATCH] feat: require Linux fleets when using the deadline-cloud conda channel The service-managed deadline-cloud conda channel only ships Maya packages built for Linux. When a job's CondaChannels parameter is exactly "deadline-cloud", inject an attr.worker.os.family=linux host requirement into every step so the job is not scheduled onto Windows fleets that cannot provide the package. Jobs using custom channels (which may carry Windows packages) are left untouched. --- .../maya_submitter/maya_render_submitter.py | 61 +++++++++ .../scripts/test_submitter.py | 126 ++++++++++++++++++ 2 files changed, 187 insertions(+) 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"]}]}