Skip to content

Commit 6b0efbd

Browse files
committed
fix(server): enforce windows profile arch scheduling and merge USER_PORTS defaults
1 parent 7f5a0b1 commit 6b0efbd

3 files changed

Lines changed: 158 additions & 6 deletions

File tree

server/opensandbox_server/services/k8s/batchsandbox_provider.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
_workload_platform_constraint_scope,
4848
)
4949
from opensandbox_server.services.k8s.windows_profile import (
50+
apply_windows_profile_arch_selector,
5051
apply_windows_profile_overrides,
5152
is_windows_profile,
5253
validate_windows_profile_resource_limits,
@@ -188,6 +189,17 @@ def create_workload(
188189
resource_limits=resource_limits,
189190
disable_ipv6_for_egress=disable_ipv6_for_egress,
190191
)
192+
template = self.template_manager.get_base_template()
193+
template_spec = (
194+
template.get("spec", {})
195+
.get("template", {})
196+
.get("spec", {})
197+
)
198+
apply_windows_profile_arch_selector(
199+
pod_spec=pod_spec,
200+
template_spec=template_spec if isinstance(template_spec, dict) else {},
201+
platform=platform,
202+
)
191203
else:
192204
self._apply_platform_node_selector(pod_spec, platform)
193205

server/opensandbox_server/services/k8s/windows_profile.py

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
WINDOWS_OEM_VOLUME_NAME = "opensandbox-win-oem"
2727
WINDOWS_KVM_VOLUME_NAME = "opensandbox-win-kvm"
2828
WINDOWS_TUN_VOLUME_NAME = "opensandbox-win-tun"
29+
WINDOWS_PROFILE_DEFAULT_USER_PORTS = ["44772", "8080", "3389/tcp", "3389/udp", "8006/tcp"]
2930

3031

3132
def is_windows_profile(platform: Optional[PlatformSpec]) -> bool:
@@ -39,12 +40,10 @@ def validate_windows_profile_resource_limits(resource_limits: dict[str, str]) ->
3940
def build_windows_profile_env(
4041
env: dict[str, str],
4142
resource_limits: dict[str, str],
42-
exposed_ports: Optional[list[str]] = None,
4343
) -> list[dict[str, str]]:
4444
env_items = [f"{key}={value}" for key, value in env.items()]
4545
env_items = inject_windows_resource_limits_env(env_items, resource_limits or {})
46-
if exposed_ports is not None:
47-
env_items = inject_windows_user_ports(env_items, exposed_ports)
46+
env_items = inject_windows_user_ports(env_items, WINDOWS_PROFILE_DEFAULT_USER_PORTS)
4847

4948
result: list[dict[str, str]] = []
5049
for item in env_items:
@@ -130,6 +129,46 @@ def apply_windows_profile_overrides(
130129
)
131130

132131

132+
def apply_windows_profile_arch_selector(
133+
pod_spec: Dict[str, Any],
134+
template_spec: Dict[str, Any],
135+
platform: Optional[PlatformSpec],
136+
) -> None:
137+
"""
138+
Apply platform.arch constraint for windows profile pods.
139+
140+
We intentionally avoid forcing kubernetes.io/os=windows for this profile,
141+
but still honor arch constraints from API requests and fail early on
142+
template conflicts.
143+
"""
144+
if platform is None:
145+
return
146+
147+
requested_arch = platform.arch
148+
template_selector = template_spec.get("nodeSelector", {})
149+
if not isinstance(template_selector, dict):
150+
template_selector = {}
151+
152+
existing_arch = template_selector.get("kubernetes.io/arch")
153+
if existing_arch is not None and existing_arch != requested_arch:
154+
raise ValueError(
155+
"platform conflict with template nodeSelector: 'kubernetes.io/arch' "
156+
f"is '{existing_arch}', request expects '{requested_arch}'."
157+
)
158+
159+
if not _template_allows_arch(template_spec, requested_arch):
160+
raise ValueError(
161+
"platform conflict with template nodeAffinity: required node affinity "
162+
f"does not allow requested architecture '{requested_arch}'."
163+
)
164+
165+
node_selector = pod_spec.setdefault("nodeSelector", {})
166+
if not isinstance(node_selector, dict):
167+
node_selector = {}
168+
pod_spec["nodeSelector"] = node_selector
169+
node_selector["kubernetes.io/arch"] = requested_arch
170+
171+
133172
def _merge_volume_mounts(container: Dict[str, Any], mounts_to_add: List[Dict[str, str]]) -> None:
134173
mounts = container.setdefault("volumeMounts", [])
135174
if not isinstance(mounts, list):
@@ -156,3 +195,48 @@ def _merge_volumes(pod_spec: Dict[str, Any], volumes_to_add: List[Dict[str, Any]
156195
continue
157196
volumes.append(volume)
158197
existing_names.add(name)
198+
199+
200+
def _template_allows_arch(template_spec: Dict[str, Any], requested_arch: str) -> bool:
201+
affinity = template_spec.get("affinity", {})
202+
if not isinstance(affinity, dict):
203+
return True
204+
205+
node_affinity = affinity.get("nodeAffinity", {})
206+
if not isinstance(node_affinity, dict):
207+
return True
208+
209+
required = node_affinity.get("requiredDuringSchedulingIgnoredDuringExecution", {})
210+
if not isinstance(required, dict):
211+
return True
212+
213+
terms = required.get("nodeSelectorTerms", [])
214+
if not isinstance(terms, list) or not terms:
215+
return True
216+
217+
return any(_arch_term_satisfiable(term, requested_arch) for term in terms if isinstance(term, dict))
218+
219+
220+
def _arch_term_satisfiable(term: Dict[str, Any], requested_arch: str) -> bool:
221+
expressions = term.get("matchExpressions", [])
222+
if not isinstance(expressions, list):
223+
return True
224+
225+
for expr in expressions:
226+
if not isinstance(expr, dict):
227+
continue
228+
if expr.get("key") != "kubernetes.io/arch":
229+
continue
230+
operator = expr.get("operator")
231+
values = expr.get("values", [])
232+
if not isinstance(values, list):
233+
values = []
234+
235+
if operator == "In" and requested_arch not in values:
236+
return False
237+
if operator == "NotIn" and requested_arch in values:
238+
return False
239+
if operator == "DoesNotExist":
240+
return False
241+
242+
return True

server/tests/k8s/test_batchsandbox_provider.py

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,10 @@ def test_create_workload_windows_profile_uses_windows_runtime_shape(self, mock_k
175175
body = mock_k8s_client.create_custom_object.call_args.kwargs["body"]
176176
pod_spec = body["spec"]["template"]["spec"]
177177

178-
# windows profile should not force k8s windows nodeSelector.
179-
assert "nodeSelector" not in pod_spec
178+
# windows profile should enforce requested arch, but not force os=windows.
179+
node_selector = pod_spec.get("nodeSelector", {})
180+
assert node_selector["kubernetes.io/arch"] == "amd64"
181+
assert "kubernetes.io/os" not in node_selector
180182

181183
init_container = pod_spec["initContainers"][0]
182184
assert init_container["command"] == ["/bin/sh", "-c"]
@@ -192,13 +194,67 @@ def test_create_workload_windows_profile_uses_windows_runtime_shape(self, mock_k
192194
assert env_dict["CPU_CORES"] == "4"
193195
assert env_dict["RAM_SIZE"] == "8G"
194196
assert env_dict["DISK_SIZE"] == "64G"
195-
assert "USER_PORTS" not in env_dict
197+
assert env_dict["USER_PORTS"] == "44772,8080,3389,8006"
196198

197199
volume_names = {volume["name"] for volume in pod_spec.get("volumes", [])}
198200
assert "opensandbox-win-oem" in volume_names
199201
assert "opensandbox-win-kvm" in volume_names
200202
assert "opensandbox-win-tun" in volume_names
201203

204+
def test_create_workload_windows_profile_merges_user_ports(self, mock_k8s_client):
205+
provider = BatchSandboxProvider(mock_k8s_client)
206+
mock_k8s_client.create_custom_object.return_value = {
207+
"metadata": {"name": "test-id", "uid": "test-uid"}
208+
}
209+
210+
provider.create_workload(
211+
sandbox_id="test-id",
212+
namespace="test-ns",
213+
image_spec=ImageSpec(uri="dockurr/windows:latest"),
214+
entrypoint=["cmd", "/c", "echo hello"],
215+
env={"VERSION": "11", "USER_PORTS": "3000,44772"},
216+
resource_limits={"cpu": "4", "memory": "8G", "disk": "64G"},
217+
labels={"opensandbox.io/id": "test-id"},
218+
expires_at=None,
219+
execd_image="execd:latest",
220+
platform=PlatformSpec(os="windows", arch="amd64"),
221+
)
222+
223+
body = mock_k8s_client.create_custom_object.call_args.kwargs["body"]
224+
pod_spec = body["spec"]["template"]["spec"]
225+
main_container = pod_spec["containers"][0]
226+
env_dict = {item["name"]: item["value"] for item in main_container.get("env", [])}
227+
assert env_dict["USER_PORTS"] == "3000,44772,8080,3389,8006"
228+
229+
def test_create_workload_windows_profile_rejects_arch_conflict_with_template_selector(
230+
self, mock_k8s_client, tmp_path
231+
):
232+
template_file = tmp_path / "template.yaml"
233+
template_file.write_text(
234+
"""
235+
spec:
236+
template:
237+
spec:
238+
nodeSelector:
239+
kubernetes.io/arch: arm64
240+
"""
241+
)
242+
provider = BatchSandboxProvider(mock_k8s_client, _app_config_with_template(str(template_file)))
243+
244+
with pytest.raises(ValueError, match="platform conflict with template nodeSelector"):
245+
provider.create_workload(
246+
sandbox_id="test-id",
247+
namespace="test-ns",
248+
image_spec=ImageSpec(uri="dockurr/windows:latest"),
249+
entrypoint=["cmd", "/c", "echo hello"],
250+
env={"VERSION": "11"},
251+
resource_limits={"cpu": "4", "memory": "8G", "disk": "64G"},
252+
labels={"opensandbox.io/id": "test-id"},
253+
expires_at=None,
254+
execd_image="execd:latest",
255+
platform=PlatformSpec(os="windows", arch="amd64"),
256+
)
257+
202258
def test_create_workload_rejects_platform_conflict_with_template_selector(self, mock_k8s_client, tmp_path):
203259
template_file = tmp_path / "template.yaml"
204260
template_file.write_text(

0 commit comments

Comments
 (0)