Creating Entities
Function blocks can create new devices, interfaces, and device groups in the neops CMS — the foundation for device discovery, inventory automation, and dynamic topology management.
Why create entities from a function block?
Entity creation from within a function block removes the manual onboarding bottleneck: a discovery step scans a subnet, queries a CMDB, or parses a spreadsheet and feeds new entities directly into the neops data model — programmatically, repeatably, and idempotently.
Common use cases:
- Subnet discovery — ICMP/SNMP sweep that registers every responding device.
- CMDB synchronization — reconcile an external device list with neops.
- Interface enumeration — parse
show interfacesand create anInterfaceentity for each port.
Entity wrappers
The SDK provides three wrapper classes in neops_worker_sdk.workflow.entities:
| Wrapper | Extends | Use case |
|---|---|---|
Device |
DeviceTypeDto |
Create a new device in the CMS |
Interface |
InterfaceTypeDto |
Create a new interface on an existing or new device |
DeviceGroup |
DeviceGroupTypeDto |
Create a new logical group |
Each wrapper inherits every field from the underlying DTO and adds two fields:
| Field | Type | Purpose |
|---|---|---|
skip_if_identifier_exists |
bool |
Skip creation when a matching entity already exists |
identifier_fields |
Sequence[DeviceField\|InterfaceField\|GroupField] |
Fields used to detect duplicates |
An id is auto-generated at instantiation via a cryptographically random
temporary value, usable immediately for cross-entity references (e.g., linking
a new Interface to its parent Device).
Creating a device
This function block discovers devices on a subnet and registers them in the
CMS by appending new Device instances to the context:
class DiscoverDevices(FunctionBlock[DiscoverParams, DiscoverResult]):
async def run(self, params: DiscoverParams, context: WorkflowContext) -> FunctionBlockResult[DiscoverResult]:
discovered_ips = await self._scan_subnet(params.subnet)
for ip in discovered_ips:
new_device = Device(
hostname=f"discovered-{ip.replace('.', '-')}",
ip=ip,
skip_if_identifier_exists=True,
identifier_fields=[DeviceField.ip],
)
if context.devices is not None:
context.devices.append(new_device)
self.logger.info(f"Discovered {len(discovered_ips)} devices on {params.subnet}")
return FunctionBlockResult(
message=f"Discovered {len(discovered_ips)} devices.",
success=True,
data=DiscoverResult(discovered_count=len(discovered_ips)),
)
async def _scan_subnet(self, subnet: str) -> list[str]:
"""Placeholder: in practice, use icmplib or SNMP discovery."""
del subnet
return ["10.0.1.1", "10.0.1.2", "10.0.1.3"]
async def acquire(self, params: DiscoverParams) -> FunctionBlockAcquireResult:
del params
return FunctionBlockAcquireResult(message="No resources required.", success=True, acquires=None)
Key observations:
Device(...)creates a wrapper with standard DTO fields plus creation options.context.devices.append(new_device)adds the entity to the context; the diffing engine detects it as new at the end of execution.- The temporary ID on
new_device.idis valid for cross-references within the same execution (e.g., creating anInterfacethat belongs to this device).
Deduplication with skip_if_identifier_exists
When skip_if_identifier_exists=True, the CMS checks whether an entity with
matching identifier field values already exists before inserting. If a match is
found, the creation is silently skipped.
This makes the function block idempotent with respect to entity creation. Running the same discovery twice against the same subnet does not produce duplicate devices — exactly the behavior you want for scheduled or retried workflows.
Identifier fields are mandatory when skipping
Setting skip_if_identifier_exists=True without specifying
identifier_fields raises a ValidationError at instantiation. The SDK
enforces this constraint via a Pydantic model validator.
Identifier fields
Identifier fields tell the CMS which combination of fields constitutes a "unique" entity. Available options:
| Entity | Enum | Values |
|---|---|---|
| Device | DeviceField |
hostname, ip |
| Interface | InterfaceField |
name |
| DeviceGroup | GroupField |
name |
These enums are validated against actual DTO model fields at import time — if the upstream API renames a field, the SDK fails fast rather than silently ignoring the mismatch. You can combine multiple fields for compound uniqueness:
Device(
hostname="core-rtr-01",
ip="10.0.0.1",
skip_if_identifier_exists=True,
identifier_fields=[DeviceField.hostname, DeviceField.ip],
)
The CMS considers the entity a duplicate only if all listed fields match an existing record. Field order is preserved in the generated DTO.
The diffing engine
You never call a "create entity" API yourself. Instead, the SDK tracks changes automatically:
flowchart LR
A["Context snapshot<br/>(deep copy at init)"] --> D["Diffing engine"]
B["Function block<br/>modifies context"] --> C["Current state<br/>(after execution)"]
C --> D
D --> E["CREATE ops"]
D --> F["PATCH ops"]
D --> G["DELETE ops"]
- At init, the SDK takes a deep copy of all entity lists (the snapshot).
- Your function block mutates the live lists: appending, modifying, or removing.
- After
run()returns,compute_db_updates()diffs current state vs snapshot.
The diff produces three operation types:
| Condition | Operation | DTO |
|---|---|---|
| Entity in current state but not in snapshot | CREATE | EntityCreateDto |
| Entity in both, with changed fields | PATCH | EntityPatchDto |
| Entity in snapshot but not in current state | DELETE | EntityDeleteDto |
Read-only and computed fields (id, created_at, updated_at, lock states,
permission fields) are excluded from diffing automatically.
Combine creation and modification
You can create new entities and modify existing ones in the same function block execution. The diffing engine handles mixed operations in a single pass.
Next: Pure and Idempotent Function Blocks — understand how safety semantics affect workflow retry and rollback behavior.