diff --git a/.github/workflows/bot-ci-failure.yml b/.github/workflows/bot-ci-failure.yml new file mode 100644 index 000000000..05f500c0d --- /dev/null +++ b/.github/workflows/bot-ci-failure.yml @@ -0,0 +1,83 @@ +name: CI Failure Bot + +on: + workflow_run: + workflows: ["Netjsonconfig CI Build"] + types: + - completed + +permissions: + pull-requests: write + actions: read + contents: read + +concurrency: + group: ci-failure-${{ github.repository }}-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.head_branch }} + cancel-in-progress: true + +jobs: + find-pr: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'failure' }} + outputs: + pr_number: ${{ steps.pr.outputs.number }} + pr_author: ${{ steps.pr.outputs.author }} + steps: + - name: Find PR Number + id: pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER_PAYLOAD: ${{ github.event.workflow_run.pull_requests[0].number }} + EVENT_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + emit_pr() { + local pr_number="$1" + local pr_author + pr_author=$(gh pr view "$pr_number" --repo "$REPO" --json author --jq '.author.login' 2>/dev/null || echo "") + if [ -z "$pr_author" ]; then + echo "::warning::Could not fetch PR author for PR #$pr_number" + fi + echo "number=$pr_number" >> "$GITHUB_OUTPUT" + echo "author=$pr_author" >> "$GITHUB_OUTPUT" + } + PR_NUMBER="$PR_NUMBER_PAYLOAD" + if [ -n "$PR_NUMBER" ]; then + echo "Found PR #$PR_NUMBER from workflow payload." + emit_pr "$PR_NUMBER" + exit 0 + fi + HEAD_SHA="$EVENT_HEAD_SHA" + echo "Payload empty. Searching for PR via Commits API..." + PR_NUMBER=$(gh api repos/$REPO/commits/$HEAD_SHA/pulls -q '.[0].number' 2>/dev/null || true) + if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then + echo "Found PR #$PR_NUMBER using Commits API." + emit_pr "$PR_NUMBER" + exit 0 + fi + echo "API lookup failed/empty. Scanning open PRs for matching head SHA..." + PR_NUMBER=$(gh pr list --repo "$REPO" --state open --limit 100 --json number,headRefOid --jq ".[] | select(.headRefOid == \"$HEAD_SHA\") | .number" | head -n 1) + if [ -n "$PR_NUMBER" ]; then + echo "Found PR #$PR_NUMBER by scanning open PRs." + emit_pr "$PR_NUMBER" + exit 0 + fi + echo "::warning::No open PR found. This workflow run might not be attached to an open PR." + exit 0 + + call-ci-failure-bot: + needs: find-pr + if: ${{ needs.find-pr.outputs.pr_number != '' }} + uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-ci-failure.yml@master + with: + pr_number: ${{ needs.find-pr.outputs.pr_number }} + head_sha: ${{ github.event.workflow_run.head_sha }} + head_repo: ${{ github.event.workflow_run.head_repository.full_name }} + base_repo: ${{ github.repository }} + run_id: ${{ github.event.workflow_run.id }} + pr_author: ${{ needs.find-pr.outputs.pr_author }} + actor: ${{ github.event.workflow_run.actor.login }} + secrets: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }} + PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} diff --git a/netjsonconfig/utils.py b/netjsonconfig/utils.py index de9a08bf9..8088ca51c 100644 --- a/netjsonconfig/utils.py +++ b/netjsonconfig/utils.py @@ -2,6 +2,10 @@ from collections import OrderedDict from copy import deepcopy +from jsonschema import ValidationError as JsonSchemaError + +from .exceptions import ValidationError + def merge_config(template, config, list_identifiers=None): """ @@ -19,14 +23,26 @@ def merge_config(template, config, list_identifiers=None): :param config: config ``dict`` :param list_identifiers: ``list`` or ``None`` :returns: merged ``dict`` + :raises ValidationError: if incompatible types are found """ result = deepcopy(template) for key, value in config.items(): - if isinstance(value, dict): - node = result.get(key, OrderedDict()) - result[key] = merge_config(node, value) - elif isinstance(value, list) and isinstance(result.get(key), list): - result[key] = merge_list(result[key], value, list_identifiers) + existing = result.get(key) + if isinstance(value, dict) and isinstance(existing, dict): + result[key] = merge_config(existing, value, list_identifiers) + elif isinstance(value, list) and isinstance(existing, list): + result[key] = merge_list(existing, value, list_identifiers) + elif ( + existing is not None + and (isinstance(value, (dict, list)) or isinstance(existing, (dict, list))) + and type(value) is not type(existing) + ): + raise ValidationError( + JsonSchemaError( + f"incompatible types for '{key}': expected {type(existing).__name__}, " + f"got {type(value).__name__}" + ) + ) else: result[key] = value return result diff --git a/tests/openwrt/test_default.py b/tests/openwrt/test_default.py index 61ed40210..a22802b5b 100644 --- a/tests/openwrt/test_default.py +++ b/tests/openwrt/test_default.py @@ -3,6 +3,7 @@ from openwisp_utils.tests import capture_stdout from netjsonconfig import OpenWrt +from netjsonconfig.exceptions import ValidationError from netjsonconfig.utils import _TabsMixin @@ -245,3 +246,28 @@ def test_render_invalid_uci_name(self): option lan '0.0.0.0/24 domain=1' """) self.assertEqual(o.render(), expected) + + def test_merge_invalid_format(self): + invalid = { + "dhcp": { + "lan": { + "interface": "lan", + "start": 100, + "limit": 150, + "leasetime": "12h", + } + } + } + valid = { + "dhcp": [ + { + "dhcpv6": "disabled", + "ignore": True, + "ra": "disabled", + "config_value": "lan", + "config_name": "dhcp", + } + ] + } + with self.assertRaises(ValidationError): + OpenWrt({}, templates=[valid, invalid])