Compare commits

...

5 Commits

Author SHA1 Message Date
Jeremy Stretch
00f16392fe Replace OAuth tokens with Anthropic keys 2026-04-27 15:13:58 -04:00
Jeremy Stretch
ff5f64abf8 Restore id-token write permission 2026-04-27 14:10:44 -04:00
Jeremy Stretch
f68645bbad Fix Claude issue triage workflow 2026-04-27 12:34:38 -04:00
Martin Hauser
d413b847ab fix(extras): Validate EventRule action_data is a dict or null
Add validation in EventRule.clean() to ensure action_data is a JSON
object or null. Add runtime guard in event processing to handle legacy
rows with invalid data by logging a warning and using an empty dict.

Fixes #21989
2026-04-27 12:01:49 -04:00
Martin Hauser
aa14e1d322 fix(dcim): Resolve link peers for cable profile connectors
Add `_get_profile_link_peers()` method to handle connector-to-connector
mappings when cables use profiles. Includes regression test for
TRUNK_4C1P profile ensuring correct peer resolution between interfaces
and rear ports.

Fixes #21917
2026-04-27 12:00:03 -04:00
9 changed files with 182 additions and 12 deletions

View File

@@ -29,7 +29,7 @@ jobs:
id: claude-review
uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'

View File

@@ -23,7 +23,7 @@ jobs:
id: claude-triage
uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Restrict Claude to read-only inspection of the repo plus posting a single comment
# on THIS issue only. `gh issue comment` is pinned to the current issue number, so an
# injection cannot redirect a comment to another issue. Close, label, reopen, assign,

View File

@@ -64,7 +64,7 @@ jobs:
id: claude
uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |

View File

@@ -254,13 +254,37 @@ class CabledObjectModel(models.Model):
@cached_property
def link_peers(self):
if self.cable:
return [
peer.termination
for peer in self.cable.terminations.all()
if peer.cable_end != self.cable_end
]
return []
if not self.cable:
return []
if self.cable.profile:
return self._get_profile_link_peers()
return [peer.termination for peer in self.cable.terminations.all() if peer.cable_end != self.cable_end]
def _get_profile_link_peers(self):
if self.cable_end is None or self.cable_connector is None or not self.cable_positions:
return []
profile = self.cable.profile_class()
peer_terminations = {
(peer.connector, position): peer.termination
for peer in self.cable.terminations.all()
if peer.cable_end == self.opposite_cable_end and peer.connector is not None
for position in peer.positions or []
}
link_peers = []
for position in self.cable_positions:
mapped_position = profile.get_mapped_position(self.cable_end, self.cable_connector, position)
if mapped_position is None:
continue
peer = peer_terminations.get(mapped_position)
if peer is not None and peer not in link_peers:
link_peers.append(peer)
return link_peers
@property
def _occupied(self):

View File

@@ -0,0 +1,68 @@
from django.test import tag
from dcim.choices import CableProfileChoices
from dcim.models import Cable, Interface, RearPort
from dcim.tests.utils import CablePathTestCase
class CableProfileLinkPeerTests(CablePathTestCase):
"""
Tests for link peer resolution with cable profiles.
"""
@tag('regression') # #21917
def test_trunk_4c1p_link_peers(self):
"""
Link peers for trunk profile cables should honor connector mappings.
"""
interfaces = [Interface.objects.create(device=self.device, name=f'Interface {i}') for i in range(1, 5)]
rear_ports = [
RearPort.objects.create(device=self.device, name=f'Rear Port {i}', positions=1) for i in range(1, 5)
]
cable = Cable(
profile=CableProfileChoices.TRUNK_4C1P,
a_terminations=interfaces,
b_terminations=rear_ports,
)
cable.clean()
cable.save()
for interface, rear_port in zip(interfaces, rear_ports):
interface.refresh_from_db()
rear_port.refresh_from_db()
self.assertEqual(interface.link_peers, [rear_port])
self.assertEqual(rear_port.link_peers, [interface])
@tag('regression') # #21917
def test_breakout_shuffle_link_peers(self):
"""
Link peers for asymmetric breakout profiles should honor mapped connectors.
"""
rear_ports = [
RearPort.objects.create(device=self.device, name=f'Rear Port {i}', positions=4) for i in range(1, 3)
]
interfaces = [Interface.objects.create(device=self.device, name=f'Interface {i}') for i in range(1, 9)]
cable = Cable(
profile=CableProfileChoices.BREAKOUT_2C4P_8C1P_SHUFFLE,
a_terminations=rear_ports,
b_terminations=interfaces,
)
cable.clean()
cable.save()
for rear_port in rear_ports:
rear_port.refresh_from_db()
for interface in interfaces:
interface.refresh_from_db()
self.assertEqual(rear_ports[0].link_peers, [interfaces[0], interfaces[1], interfaces[4], interfaces[5]])
self.assertEqual(rear_ports[1].link_peers, [interfaces[2], interfaces[3], interfaces[6], interfaces[7]])
for interface in interfaces[0:2] + interfaces[4:6]:
self.assertEqual(interface.link_peers, [rear_ports[0]])
for interface in interfaces[2:4] + interfaces[6:8]:
self.assertEqual(interface.link_peers, [rear_ports[1]])

View File

@@ -181,9 +181,26 @@ def process_event_rules(event_rules, object_type, event):
if not event_rule.eval_conditions(event['data']):
continue
# Guard against action_data that is valid JSON but not a dict
# (e.g. a bare string or number). Existing rows with bad data are
# tolerated at runtime; validation on EventRule.clean() prevents
# new ones.
if event_rule.action_data is None:
action_data = {}
elif isinstance(event_rule.action_data, dict):
action_data = event_rule.action_data
else:
logger.warning(
_('Ignoring invalid action_data on event rule "{rule}" (got {data_type})').format(
rule=event_rule,
data_type=type(event_rule.action_data).__name__,
)
)
action_data = {}
# Merge rule-specific action_data with the event payload.
# Copy to avoid mutating the rule's stored action_data dict.
event_data = {**(event_rule.action_data or {}), **event['data']}
event_data = {**action_data, **event['data']}
# Webhooks
if event_rule.action_type == EventRuleActionChoices.WEBHOOK:

View File

@@ -143,6 +143,10 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, OwnerMixin, TagsMixin,
except ValueError as e:
raise ValidationError({'conditions': e})
# action_data must be a JSON object (or null)
if self.action_data is not None and not isinstance(self.action_data, dict):
raise ValidationError({'action_data': _('Action data must be a JSON object or null.')})
def eval_conditions(self, data):
"""
Test whether the given data meets the conditions of the event rule (if any). Return True

View File

@@ -563,3 +563,30 @@ class EventRuleTest(APITestCase):
job = self.queue.get_jobs()[0]
self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
self.queue.empty()
def test_non_dict_action_data_does_not_crash_flush(self):
"""
Pre-existing non-dict action_data must not cause flush_events() to
raise.
"""
site_type = ObjectType.objects.get_for_model(Site)
webhook = Webhook.objects.get(name='Webhook 1')
webhook_type = ObjectType.objects.get_for_model(Webhook)
bad_rule = EventRule.objects.create(
name='Bad action_data rule',
event_types=[OBJECT_CREATED],
action_type=EventRuleActionChoices.WEBHOOK,
action_object_type=webhook_type,
action_object_id=webhook.pk,
action_data={},
)
bad_rule.object_types.set([site_type])
# Simulate a legacy row that predates model validation.
EventRule.objects.filter(pk=bad_rule.pk).update(action_data='not a dict')
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, {'name': 'Site X', 'slug': 'site-x'}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)

View File

@@ -11,9 +11,18 @@ from django.forms import ValidationError
from django.test import TestCase, tag
from PIL import Image
from core.events import OBJECT_CREATED
from core.models import AutoSyncRecord, DataSource, ObjectType
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
from extras.models import ConfigContext, ConfigContextProfile, ConfigTemplate, ImageAttachment, Tag, TaggedItem
from extras.models import (
ConfigContext,
ConfigContextProfile,
ConfigTemplate,
EventRule,
ImageAttachment,
Tag,
TaggedItem,
)
from tenancy.models import Tenant, TenantGroup
from utilities.exceptions import AbortRequest
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -889,3 +898,24 @@ class ConfigTemplateTest(TestCase):
object_id=config_template.pk
)
self.assertEqual(autosync_records.count(), 0, "AutoSyncRecord should be deleted after detaching")
class EventRuleTest(TestCase):
def test_action_data_clean_accepts_dict(self):
"""
clean() should accept a JSON object (or null) as action_data.
"""
for value in ({'key': 'value'}, None):
rule = EventRule(name='test', event_types=[OBJECT_CREATED], action_data=value)
rule.clean()
def test_action_data_clean_rejects_non_dict(self):
"""
clean() should reject action_data that is valid JSON but not an object (#21989).
"""
for value in ('test', 42, [1, 2, 3], True):
rule = EventRule(name='test', event_types=[OBJECT_CREATED], action_data=value)
with self.assertRaises(ValidationError) as cm:
rule.clean()
self.assertIn('action_data', cm.exception.message_dict)