Skip to content
Open
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
17 changes: 16 additions & 1 deletion src/keboola_agent_cli/services/component_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,15 @@ def run_sync_action(
``storage`` key REPLACES the root key wholesale (never deep-merged),
so e.g. a row ``storage.input`` replaces the root ``storage.input``.

``runtime`` and ``authorization`` are taken from the ROOT configuration
only (rows never override them, per the docker-runner contract) and are
forwarded only when non-empty. ``authorization.oauth_api.id`` is a
broker reference the sync-actions service resolves and decrypts before
invoking the component; omitting it made OAuth/Service-Account
components (e.g. ``keboola.ex-linkedin-ads``) crash before their own
error handling could run, surfacing as an opaque empty-body 400
(AI-3757 / SUPPORT-17393).

Args:
alias: Project alias (resolves stack URL + token).
component_id: Component identifier (e.g. 'keboola.ex-db-mysql').
Expand Down Expand Up @@ -531,7 +540,7 @@ def run_sync_action(
row_configuration = row.get("configuration") or {}
# SHALLOW top-level merge (MCP parity): row keys replace root
# keys wholesale; do NOT deep-merge.
config_data = {
config_data: dict[str, Any] = {
"parameters": {
**root_configuration.get("parameters", {}),
**row_configuration.get("parameters", {}),
Expand All @@ -541,6 +550,12 @@ def run_sync_action(
**row_configuration.get("storage", {}),
},
}
runtime = root_configuration.get("runtime") or {}
authorization = root_configuration.get("authorization") or {}
if runtime:
config_data["runtime"] = runtime
if authorization:
config_data["authorization"] = authorization
result = client.run_sync_action(
component_id,
action,
Expand Down
66 changes: 62 additions & 4 deletions tests/test_component_sync_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,14 +125,21 @@ def test_run_sync_action_api_error(self, httpx_mock) -> None:
def _root_config_response(
parameters: dict[str, Any] | None = None,
storage: dict[str, Any] | None = None,
runtime: dict[str, Any] | None = None,
authorization: dict[str, Any] | None = None,
) -> dict[str, Any]:
configuration: dict[str, Any] = {
"parameters": parameters if parameters is not None else {},
"storage": storage if storage is not None else {},
}
if runtime is not None:
configuration["runtime"] = runtime
if authorization is not None:
configuration["authorization"] = authorization
return {
"id": CONFIG_ID,
"name": "MySQL extractor",
"configuration": {
"parameters": parameters if parameters is not None else {},
"storage": storage if storage is not None else {},
},
"configuration": configuration,
}


Expand Down Expand Up @@ -259,6 +266,57 @@ def test_root_row_shallow_merge_not_deep(self, tmp_config_dir: Path) -> None:
# storage merged independently with the same semantics:
assert sent_config_data["storage"] == {"input": {"tables": [{"source": "in.c-main.row"}]}}

def test_authorization_and_runtime_forwarded_from_root(self, tmp_config_dir: Path) -> None:
"""OAuth/Service-Account components need root authorization+runtime forwarded (AI-3757).

Without this, e.g. keboola.ex-linkedin-ads never receives its OAuth
broker reference and crashes before its own error handling runs,
surfacing as an opaque empty-body 400.
"""
client = MagicMock()
client.get_config_detail.return_value = _root_config_response(
parameters={"ad_account_id": "123"},
runtime={"parallelism": "5"},
authorization={"oauth_api": {"id": "linkedin-ads"}},
)
client.get_config_row.return_value = _row_config_response(
parameters={"ad_account_id": "456"}
)
client.run_sync_action.return_value = {"accounts": []}
service = _make_service(tmp_config_dir, client)

service.run_sync_action(
alias="prod",
component_id="keboola.ex-linkedin-ads",
action="list_accounts",
config_id=CONFIG_ID,
row_id=ROW_ID,
)

sent_config_data = client.run_sync_action.call_args.args[2]
assert sent_config_data["runtime"] == {"parallelism": "5"}
assert sent_config_data["authorization"] == {"oauth_api": {"id": "linkedin-ads"}}

def test_authorization_and_runtime_omitted_when_absent(self, tmp_config_dir: Path) -> None:
"""No authorization/runtime key at all when the root config has none."""
client = MagicMock()
client.get_config_detail.return_value = _root_config_response(
parameters={"host": "example.com"}
)
client.run_sync_action.return_value = {"status": "success"}
service = _make_service(tmp_config_dir, client)

service.run_sync_action(
alias="prod",
component_id=COMPONENT_ID,
action="testConnection",
config_id=CONFIG_ID,
)

sent_config_data = client.run_sync_action.call_args.args[2]
assert "runtime" not in sent_config_data
assert "authorization" not in sent_config_data

def test_branch_pass_through(self, tmp_config_dir: Path) -> None:
"""branch_id flows to config fetch, row fetch, and the action call."""
client = MagicMock()
Expand Down