Skip to content
Open
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ ENHANCEMENTS:
* Specify default_outbound_access_enabled = false setting for all subnets ([#4757](https://github.com/microsoft/AzureTRE/pull/4757))
* Pin all GitHub Actions workflow steps to full commit SHAs to prevent supply chain attacks plus update to latest releases ([#4886](https://github.com/microsoft/AzureTRE/pull/4886))

BUG FIXES:
* Fix `OSError: [Errno 7] Argument list too long` when deploying many workspaces by replacing `--param` CLI arguments with a temporary Porter parameter set file ([#4903](https://github.com/microsoft/AzureTRE/issues/4903))

## (0.28.0) (March 2, 2026)
**BREAKING CHANGES**
* Sonatype Nexus shared service now requires explicit EULA acceptance (`accept_nexus_eula: true`) when deploying. This ensures compliance with Sonatype Nexus Community Edition licensing. ([#4842](https://github.com/microsoft/AzureTRE/issues/4842))
Expand Down
29 changes: 25 additions & 4 deletions resource_processor/helpers/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import json
import base64
import logging
import os
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
marrobi marked this conversation as resolved.
Outdated
from urllib.parse import urlparse

from shared.logging import logger, shell_output_logger
Expand Down Expand Up @@ -78,7 +79,7 @@ def azure_acr_login_command(config):

async def build_porter_command(config, msg_body, custom_action=False):
porter_parameter_keys = await get_porter_parameter_keys(config, msg_body)
porter_parameters = []
param_set_entries = []

if porter_parameter_keys is None:
logger.warning("Unknown porter parameters - explain probably failed.")
Expand Down Expand Up @@ -117,10 +118,29 @@ async def build_porter_command(config, msg_body, custom_action=False):
val_base64_bytes = base64.b64encode(val_bytes)
parameter_value = val_base64_bytes.decode("ascii")

porter_parameters.extend(["--param", f"{parameter_name}={parameter_value}"])
param_set_entries.append({
"name": parameter_name,
"source": {"value": str(parameter_value)}
})

installation_id = msg_body['id']

# Write parameters to a temporary parameter set file to avoid ARG_MAX / MAX_ARG_STRLEN limits
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't need these comments.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in commit e7c1eed.

# when many workspaces are deployed and parameter values (e.g. base64-encoded rule_collections)
# exceed the Linux execve limits.
param_set_file = None
if param_set_entries:
param_set_file = f"/tmp/tre-params-{installation_id}.json"
param_set = {
"schemaType": "ParameterSet",
"schemaVersion": "1.0.1",
"name": f"tre-params-{installation_id}",
"namespace": "",
"parameters": param_set_entries
}
with open(param_set_file, "w") as f:
json.dump(param_set, f)
Comment thread
marrobi marked this conversation as resolved.
Outdated

command = ["porter"]
if custom_action:
command.extend(["invoke", "--action"])
Expand All @@ -131,15 +151,16 @@ async def build_porter_command(config, msg_body, custom_action=False):
"--reference",
f"{config['registry_server']}/{msg_body['name']}:v{msg_body['version']}"
])
command.extend(porter_parameters)
if param_set_file:
command.extend(["--parameter-set", param_set_file])
command.append("--force")
command.extend(["--credential-set", "arm_auth"])
command.extend(["--credential-set", "aad_auth"])

if msg_body['action'] == 'upgrade':
command.append("--force-upgrade")

return [command]
return ([command], param_set_file)


async def build_porter_command_for_outputs(msg_body):
Expand Down
127 changes: 85 additions & 42 deletions resource_processor/tests_rp/test_commands.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import asyncio
import os
import pytest
from unittest.mock import patch, AsyncMock
from helpers.commands import azure_login_command, apply_porter_credentials_sets_command, azure_acr_login_command, build_porter_command, build_porter_command_for_outputs, get_porter_parameter_keys, run_command_helper, get_special_porter_param_value
Expand Down Expand Up @@ -58,17 +59,30 @@ async def test_build_porter_command(mock_get_porter_parameter_keys):
msg_body = {"id": "guid", "action": "install", "name": "mybundle", "version": "1.0.0", "parameters": {"param1": "value1"}}
mock_get_porter_parameter_keys.return_value = ["param1"]

expected_command = [[
"porter", "install", "guid",
"--reference", "myregistry.azurecr.io/mybundle:v1.0.0",
"--param", "param1=value1",
"--force",
"--credential-set", "arm_auth",
"--credential-set", "aad_auth"
]]
commands, param_set_file = await build_porter_command(config, msg_body)
try:
assert commands == [[
"porter", "install", "guid",
"--reference", "myregistry.azurecr.io/mybundle:v1.0.0",
"--parameter-set", param_set_file,
"--force",
"--credential-set", "arm_auth",
"--credential-set", "aad_auth"
]]
Comment thread
marrobi marked this conversation as resolved.
Outdated

command = await build_porter_command(config, msg_body)
assert command == expected_command
assert param_set_file is not None
assert os.path.exists(param_set_file)

with open(param_set_file) as f:
param_set = json.load(f)

assert param_set["schemaType"] == "ParameterSet"
assert param_set["name"] == "tre-params-guid"
assert len(param_set["parameters"]) == 1
assert param_set["parameters"][0] == {"name": "param1", "source": {"value": "value1"}}
finally:
if param_set_file and os.path.exists(param_set_file):
os.unlink(param_set_file)


@pytest.mark.asyncio
Expand All @@ -78,18 +92,23 @@ async def test_build_porter_command_for_upgrade(mock_get_porter_parameter_keys):
msg_body = {"id": "guid", "action": "upgrade", "name": "mybundle", "version": "1.0.0", "parameters": {"param1": "value1"}}
mock_get_porter_parameter_keys.return_value = ["param1"]

expected_command = [[
"porter", "upgrade", "guid",
"--reference", "myregistry.azurecr.io/mybundle:v1.0.0",
"--param", "param1=value1",
"--force",
"--credential-set", "arm_auth",
"--credential-set", "aad_auth",
"--force-upgrade"
]]

command = await build_porter_command(config, msg_body)
assert command == expected_command
commands, param_set_file = await build_porter_command(config, msg_body)
try:
assert commands == [[
"porter", "upgrade", "guid",
"--reference", "myregistry.azurecr.io/mybundle:v1.0.0",
"--parameter-set", param_set_file,
"--force",
"--credential-set", "arm_auth",
"--credential-set", "aad_auth",
"--force-upgrade"
]]

assert param_set_file is not None
assert os.path.exists(param_set_file)
finally:
if param_set_file and os.path.exists(param_set_file):
os.unlink(param_set_file)


@pytest.mark.asyncio
Expand All @@ -106,6 +125,25 @@ async def test_build_porter_command_for_outputs():
assert command == expected_command


@pytest.mark.asyncio
async def test_build_porter_command_no_parameters(mock_get_porter_parameter_keys):
"""Test build_porter_command returns no --parameter-set when there are no parameters."""
config = {"registry_server": "myregistry.azurecr.io"}
msg_body = {"id": "guid", "action": "install", "name": "mybundle", "version": "1.0.0", "parameters": {}}
mock_get_porter_parameter_keys.return_value = []

commands, param_set_file = await build_porter_command(config, msg_body)

assert param_set_file is None
assert commands == [[
"porter", "install", "guid",
"--reference", "myregistry.azurecr.io/mybundle:v1.0.0",
"--force",
"--credential-set", "arm_auth",
"--credential-set", "aad_auth"
]]


@pytest.mark.asyncio
async def test_build_porter_command_with_complex_parameters(mock_get_porter_parameter_keys):
"""Test build_porter_command function with complex parameter types (dict, list)."""
Expand All @@ -127,33 +165,38 @@ async def test_build_porter_command_with_complex_parameters(mock_get_porter_para

mock_get_porter_parameter_keys.return_value = ["dict_param", "list_param", "string_param"]

command = await build_porter_command(config, msg_body)
commands, param_set_file = await build_porter_command(config, msg_body)

try:
command_args = commands[0]

# Verify the command contains properly encoded complex parameters
command_args = command[0]
# Command should have --parameter-set instead of --param
assert "--parameter-set" in command_args
assert "--param" not in command_args

# Find the indices of parameters
param_indices = [i for i, arg in enumerate(command_args) if arg == "--param"]
param_values = [command_args[i + 1] for i in param_indices]
# Verify the param set file contains the correct parameters
assert param_set_file is not None
with open(param_set_file) as f:
param_set = json.load(f)

# Check for all parameters
dict_param = next((p for p in param_values if p.startswith("dict_param=")), None)
list_param = next((p for p in param_values if p.startswith("list_param=")), None)
string_param = next((p for p in param_values if p.startswith("string_param=")), None)
params_by_name = {p["name"]: p["source"]["value"] for p in param_set["parameters"]}

assert dict_param is not None
assert list_param is not None
assert string_param is not None
assert string_param == "string_param=simple_value"
assert "dict_param" in params_by_name
assert "list_param" in params_by_name
assert "string_param" in params_by_name
assert params_by_name["string_param"] == "simple_value"

# Verify the dict and list are base64 encoded
import base64
# Verify the dict and list are base64 encoded
import base64

dict_encoded = base64.b64encode(json.dumps(dict_value).encode("ascii")).decode("ascii")
list_encoded = base64.b64encode(json.dumps(list_value).encode("ascii")).decode("ascii")
dict_encoded = base64.b64encode(json.dumps(dict_value).encode("ascii")).decode("ascii")
list_encoded = base64.b64encode(json.dumps(list_value).encode("ascii")).decode("ascii")

assert dict_param == f"dict_param={dict_encoded}"
assert list_param == f"list_param={list_encoded}"
assert params_by_name["dict_param"] == dict_encoded
assert params_by_name["list_param"] == list_encoded
finally:
if param_set_file and os.path.exists(param_set_file):
os.unlink(param_set_file)


@pytest.mark.asyncio
Expand Down
18 changes: 16 additions & 2 deletions resource_processor/vmss_porter/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from multiprocessing import Process
import json
import asyncio
import os
import sys
from helpers.commands import azure_acr_login_command, azure_login_command, build_porter_command, build_porter_command_for_outputs, apply_porter_credentials_sets_command, run_command_helper
from shared.config import get_config
Expand Down Expand Up @@ -178,12 +179,19 @@ async def invoke_porter_action(msg_body: dict, sb_client: ServiceBusClient, conf

# Build and run porter command (flagging if its a built-in action or custom so we can adapt porter command appropriately)
is_custom_action = action not in ["install", "upgrade", "uninstall"]
porter_command = await build_porter_command(config, msg_body, is_custom_action)
porter_command, param_set_file = await build_porter_command(config, msg_body, is_custom_action)

logger.debug("Starting to run porter execution command...")
returncode, _, err = await run_porter(porter_command, config)
logger.debug("Finished running porter execution command.")
Comment on lines 189 to 199

# Clean up the temporary parameter set file now that the porter command has completed
if param_set_file:
try:
os.unlink(param_set_file)
except OSError:
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
pass
Comment thread
marrobi marked this conversation as resolved.
Outdated

action_completed_without_error = False

if returncode == 0:
Expand All @@ -203,8 +211,14 @@ async def invoke_porter_action(msg_body: dict, sb_client: ServiceBusClient, conf
if "upgrade" == action and ("could not find installation" in err or "The installation cannot be upgraded, because it is not installed." in err):
logger.warning("Upgrade failed, attempting install...")
msg_body['action'] = "install"
porter_command = await build_porter_command(config, msg_body, False)
porter_command, param_set_file = await build_porter_command(config, msg_body, False)
returncode, _, err = await run_porter(porter_command, config)
# Clean up the temporary parameter set file for the fallback install command
if param_set_file:
try:
os.unlink(param_set_file)
except OSError:
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
pass
if returncode == 0:
action_completed_without_error = True

Expand Down