Skip to content

Commit 9aced1a

Browse files
committed
api: Implement PATCH endpoint for node
PATCH /node/{node_id} endpoint: - Fetches the existing node from DB - Merges only the fields provided in the request (exclude_unset=True) - Validates the node subtype via parse_node_obj - Validates state transitions (same logic as PUT) - Resets processed_by_kcidb_bridge on update (same logic as PUT) - Publishes CloudEvent (same as PUT) Example: curl -s -X PATCH -H 'Content-Type: application/json' \ -H 'Authorization: Bearer ...' \ -d '{"state":"done","result":"pass"}' \ 'https://api.kernelci.org/latest/node/69c24ea76d1f8ea2ada0f485' Signed-off-by: Denys Fedoryshchenko <denys.f@collabora.com>
1 parent ea4b1c6 commit 9aced1a

File tree

3 files changed

+98
-2
lines changed

3 files changed

+98
-2
lines changed

api/main.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import traceback
1818
from contextlib import asynccontextmanager
1919
from datetime import datetime, timedelta, timezone
20-
from typing import List, Optional, Union
20+
from typing import Any, Dict, List, Optional, Union
2121

2222
import pymongo
2323
from beanie import PydanticObjectId
@@ -53,6 +53,8 @@
5353
KernelVersion,
5454
Node,
5555
PublishEvent,
56+
ResultValues,
57+
StateValues,
5658
TelemetryEvent,
5759
parse_node_obj,
5860
)
@@ -1649,6 +1651,80 @@ async def put_node(
16491651
return obj
16501652

16511653

1654+
class NodePatchRequest(BaseModel):
1655+
"""Request model for partial node updates"""
1656+
1657+
state: Optional[StateValues] = None
1658+
result: Optional[ResultValues] = None
1659+
artifacts: Optional[Dict[str, str]] = None
1660+
data: Optional[Dict[str, Any]] = None
1661+
debug: Optional[Dict[str, Any]] = None
1662+
jobfilter: Optional[List[str]] = None
1663+
platform_filter: Optional[List[str]] = None
1664+
timeout: Optional[datetime] = None
1665+
holdoff: Optional[datetime] = None
1666+
processed_by_kcidb_bridge: Optional[bool] = None
1667+
1668+
1669+
@app.patch("/node/{node_id}", response_model=Node, response_model_by_alias=False)
1670+
async def patch_node(
1671+
node_id: str,
1672+
patch: NodePatchRequest,
1673+
user: str = Depends(authorize_user),
1674+
noevent: Optional[bool] = Query(None),
1675+
):
1676+
"""Partial update of an existing node"""
1677+
metrics.add("http_requests_total", 1)
1678+
node_from_id = await db.find_by_id(Node, node_id)
1679+
if not node_from_id:
1680+
raise HTTPException(
1681+
status_code=status.HTTP_404_NOT_FOUND,
1682+
detail=f"Node not found with id: {node_id}",
1683+
)
1684+
1685+
update_data = patch.model_dump(exclude_unset=True)
1686+
if not update_data:
1687+
raise HTTPException(
1688+
status_code=status.HTTP_400_BAD_REQUEST,
1689+
detail="No fields to update",
1690+
)
1691+
1692+
# Handle state transition separately
1693+
new_state = update_data.pop("state", None)
1694+
1695+
# Apply non-state fields to existing node
1696+
if update_data:
1697+
new_node_def = node_from_id.model_copy(update=update_data)
1698+
else:
1699+
new_node_def = node_from_id.model_copy()
1700+
1701+
# Validate node subtype
1702+
specialized_node = parse_node_obj(new_node_def)
1703+
1704+
# State transition checks
1705+
if new_state is not None:
1706+
is_valid, message = specialized_node.validate_node_state_transition(new_state)
1707+
if not is_valid:
1708+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
1709+
if new_state != new_node_def.state:
1710+
new_node_def.processed_by_kcidb_bridge = False
1711+
new_node_def.state = new_state
1712+
1713+
# KCIDB flags reset logic
1714+
if "processed_by_kcidb_bridge" not in patch.model_dump(exclude_unset=True):
1715+
new_node_def.processed_by_kcidb_bridge = False
1716+
1717+
# Update node in the DB
1718+
obj = await db.update(new_node_def)
1719+
data = _get_node_event_data("updated", obj)
1720+
attributes = {}
1721+
if data.get("owner", None):
1722+
attributes["owner"] = data["owner"]
1723+
if not noevent:
1724+
await pubsub.publish_cloudevent("node", data, attributes)
1725+
return obj
1726+
1727+
16521728
class NodeUpdateRequest(BaseModel):
16531729
"""Request model for updating multiple nodes"""
16541730

tests/e2e_tests/test_node_handler.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,23 @@ async def update_node(test_async_client, node):
9191
)
9292
assert response.status_code == 200
9393
assert response.json().keys() == node_model_fields
94+
95+
96+
async def patch_node(test_async_client, node_id, patch_data):
97+
"""
98+
Test Case : Test KernelCI API PATCH /node/{node_id} endpoint
99+
Expected Result :
100+
HTTP Response Code 200 OK
101+
JSON with updated Node object
102+
"""
103+
response = await test_async_client.patch(
104+
f"node/{node_id}",
105+
headers={
106+
"Accept": "application/json",
107+
"Authorization": f"Bearer {pytest.BEARER_TOKEN}", # pylint: disable=no-member
108+
},
109+
data=json.dumps(patch_data),
110+
)
111+
assert response.status_code == 200
112+
assert response.json().keys() == node_model_fields
113+
return response

tests/e2e_tests/test_pipeline.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from cloudevents.http import from_json
1111

1212
from .listen_handler import create_listen_task
13-
from .test_node_handler import create_node, get_node_by_id, update_node
13+
from .test_node_handler import create_node, get_node_by_id, patch_node, update_node
1414

1515

1616
@pytest.mark.dependency(

0 commit comments

Comments
 (0)