From 2c6cb2b7d9140331d6d3d716ce074dca600f7a0b Mon Sep 17 00:00:00 2001 From: malmert Date: Sat, 28 Feb 2026 14:35:22 +0100 Subject: [PATCH] add sort endpoint to nftmanager --- backend/src/api/nft_manager.py | 162 +++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/backend/src/api/nft_manager.py b/backend/src/api/nft_manager.py index c9452c7..283e467 100644 --- a/backend/src/api/nft_manager.py +++ b/backend/src/api/nft_manager.py @@ -265,6 +265,34 @@ class CreateRuleRequest(BaseModel): } +# ---------- Move endpoints: request/response models ---------- +class MoveRequest(BaseModel): + """ + Request body for moving a rule. Exactly one of these should typically be provided: + - position: numeric index to insert at (0 = first) + - before_handle: insert before an existing handle in the same chain + - to_top: boolean + - to_bottom: boolean + family/table/chain are required to identify the chain. + """ + family: str = Field(..., example="bridge") + table: str = Field(..., example="filter") + chain: str = Field(..., example="forward") + position: Optional[int] = Field(None, description="Zero-based position to insert at (0 = top)") + before_handle: Optional[int] = Field(None, description="Insert before this handle (find its index and use that)") + to_top: Optional[bool] = Field(False, description="Move to top (equivalent to position=0)") + to_bottom: Optional[bool] = Field(False, description="Move to bottom (append)") + +class MoveSubResult(BaseModel): + cmd: str + out: Optional[ExecResult] = None + err: Optional[str] = None + +class MoveResult(BaseModel): + added: MoveSubResult + deleted: MoveSubResult + + # ruleset may be typed RulesetModel or raw textual string (fallback) RulesetValue = Optional[Union[RulesetModel, str]] @@ -790,4 +818,138 @@ def exec_raw(req: RawCmdRequest): raise HTTPException(status_code=500, detail=str(e)) +# ---------- New endpoints: move rules ---------- +@router.post("/rules/{handle}/move", response_model=MoveResult, summary="Move rule to a new position within the same chain") +def move_rule(handle: int, req: MoveRequest): + """ + Move a rule identified by its handle to a new position in the same chain. + Strategy: + - read JSON ruleset to find the rule with given handle and its expr + - render expr -> textual fragment with expr_to_text + - add rule at desired position with 'add rule position ' + - delete the original rule by handle + Notes: + - If expr_to_text cannot deterministically render the expr, the endpoint returns 400. + - position is zero-based; position equal to number of rules appends. + """ + try: + # validate chain identity + family = req.family + table = req.table + chain = req.chain + + # load ruleset JSON + try: + nft_json = mgr.list_rules_json() + except NftError as e: + logger.warning("move_rule: cannot read nft JSON: %s", e) + raise HTTPException(status_code=500, detail="Could not read nft JSON ruleset") + + custom = build_predictable_ruleset(nft_json) + # find chain rules + chain_rules: List[Dict[str, Any]] = [] + for t in custom.get("tables", []): + if t.get("family") == family and t.get("name") == table: + for ch in t.get("chains", []): + if ch.get("name") == chain: + chain_rules = ch.get("rules", []) + break + + if not chain_rules: + raise HTTPException(status_code=404, detail="Chain not found or chain contains no rules") + + # find the rule by handle + source_rule = None + for r in chain_rules: + if r.get("handle") == handle: + source_rule = r + break + if source_rule is None: + raise HTTPException(status_code=404, detail=f"Rule with handle {handle} not found in chain") + + # render textual fragment + expr = source_rule.get("expr") + rendered = expr_to_text(expr) + if rendered is None: + raise HTTPException(status_code=400, detail="Cannot deterministically render rule expr to textual nft; move not possible via this endpoint") + + # compute target position + target_pos: Optional[int] = None + if req.to_top: + target_pos = 0 + elif req.to_bottom: + target_pos = len(chain_rules) # append + elif req.before_handle is not None: + # find index of before_handle + idx = next((i for i, rr in enumerate(chain_rules) if rr.get("handle") == req.before_handle), None) + if idx is None: + raise HTTPException(status_code=404, detail=f"before_handle {req.before_handle} not found in chain") + target_pos = idx + elif req.position is not None: + # clamp to [0, len] + target_pos = max(0, min(len(chain_rules), int(req.position))) + else: + raise HTTPException(status_code=400, detail="No target position specified (provide position, before_handle, to_top or to_bottom)") + + # If the position corresponds to the same location as original and moving a later->earlier or vice versa might shift indices, + # we proceed anyway: simplest reliable approach is add at target_pos then delete original handle. + add_cmd = f"add rule {family} {table} {chain} position {target_pos} {rendered}" + logger.info("move_rule: add_cmd=%s (moving handle %s to pos %s)", add_cmd, handle, target_pos) + + # Execute add + add_res_raw = mgr.cmd(add_cmd) + add_rc = int(add_res_raw.get("rc") or -1) + add_stdout = add_res_raw.get("stdout") or "" + add_stderr = add_res_raw.get("stderr") or "" + + add_exec = MoveSubResult( + cmd=add_cmd, + out=ExecResult(rc=add_rc, stdout=add_stdout or None, stderr=add_stderr or None) + if add_res_raw is not None + else None, + err=None, + ) + + # If add failed decisively (non-zero rc and stderr present) -> return error without deleting original + if add_rc != 0 and add_stderr.strip() != "": + add_exec.err = f"add failed: rc={add_rc}, stderr={add_stderr}" + logger.warning("move_rule: add failed: %s", add_exec.err) + raise HTTPException(status_code=400, detail=add_exec.err) + + # If add produced rc !=0 but stderr empty, attempt to detect presence like you do elsewhere + if add_rc != 0 and add_stderr.strip() == "": + try: + chain_text = mgr.list_chain_text(family, table, chain) or "" + if rendered not in chain_text: + add_exec.err = f"add reported rc={add_rc} and rule not found in chain text" + logger.warning("move_rule: add ambiguous: %s", add_exec.err) + raise HTTPException(status_code=400, detail=add_exec.err) + # otherwise treat as success + add_exec.out = ExecResult(rc=0, stdout=add_stdout or None, stderr=add_stderr or None) + except Exception: + add_exec.err = f"add reported rc={add_rc}, and verification failed" + raise HTTPException(status_code=400, detail=add_exec.err) + + # Now delete the original rule by handle + try: + mgr.delete_rule_by_handle_text(family=family, table=table, chain=chain, handle=handle) + del_exec = MoveSubResult(cmd=f"delete rule {family} {table} {chain} handle {handle}", out=ExecResult(rc=0, stdout="", stderr=""), err=None) + except Exception as e: + # We succeeded adding but failed deleting - report both + err_msg = f"added new rule but deleting original handle {handle} failed: {e}" + logger.exception(err_msg) + del_exec = MoveSubResult(cmd=f"delete rule {family} {table} {chain} handle {handle}", out=None, err=str(e)) + # Return 500 to indicate partial failure + return MoveResult(added=add_exec, deleted=del_exec) + + # success + return MoveResult(added=add_exec, deleted=del_exec) + + except HTTPException: + raise + except Exception as e: + logger.exception("move_rule internal error") + raise HTTPException(status_code=500, detail=str(e)) + + app.include_router(router) \ No newline at end of file