Skip to content

Implement policy-aware deep merge semantics for nested attributes in state=merged #386

Description

@gmicol

Summary

The Orchestrator framework does not consistently perform true merge semantics for nested attributes when state=merged. Direct nested NDBaseModel fields are partially supported, but lists, dictionaries, lists of NDNestedModel, and lists of dictionaries are treated as atomic values. When a user supplies only part of a nested collection, the framework can replace the entire existing nested collection instead of merging the requested subset.

This can be misleading and destructive for users who expect state=merged to preserve unspecified nested configuration.

Current Behavior

NDStateMachine uses exclude_unset=True for state=merged diffs, then calls NDConfigCollection.merge(), which delegates to NDBaseModel.merge().

NDBaseModel.merge():

  • only applies fields explicitly set by the proposed model
  • recursively merges only when both current and proposed values are NDBaseModel
  • otherwise assigns the proposed value directly

The diff helper issubset() recursively compares dictionaries, but lists require full-list equality with the same length. This means a proposed list containing one nested item is not treated as a subset of an existing list containing multiple items.

Affected examples include:

  • FabricUpdateGroupModel.install_image_data.install_package_names
  • FabricUpdateGroupModel.update_report_checks
  • LocalUserModel.security_domains
  • interface trunk VLAN mapping lists
  • fabric models with nested list/dict collections

Problem

For state=merged, users reasonably expect omitted nested properties to remain unchanged. Today that is only reliable for direct nested model fields. Collection-like nested fields are replaced as a single property.

Example problem shape:

state: merged
config:
  - name: existing_object
    nested_collection:
      - key: one_item_to_update
        value: new_value

If the controller already has other entries in nested_collection, the framework may send only the proposed list and drop the omitted entries.

Suggested Fix

Implement a policy-aware deep merge engine shared by model diff and merge logic.

Recommended design:

  1. Add merge policy metadata to NDBaseModel, for example:
merge_policies: ClassVar[dict[str, MergePolicy]] = {
    "security_domains": MergePolicy.list_keyed("name"),
    "update_report_checks": MergePolicy.list_keyed("report_check_name"),
    "install_package_names": MergePolicy.list_union(),
}
  1. Supported policies should include:

    • replace: current default, safest fallback
    • model_deep: recursive model merge
    • dict_deep: recursive dictionary merge
    • list_replace: atomic list replacement
    • list_union: merge primitive list values without duplicates
    • list_keyed: merge list entries by identifier/key
    • possibly list_model_identifier: use nested model get_identifier_value() where available
  2. Align diff behavior with merge behavior:

    • get_diff(..., exclude_unset=True) should use the same policy tree as merge()
    • a proposed keyed-list item that already matches one existing item should be no_diff
    • a proposed keyed-list item that differs should be changed
    • omitted list entries should not force replacement under state=merged
  3. Cache merge plans per model class:

    • inspect Pydantic fields once
    • avoid repeated full serialization where possible
    • validate the merged model once after applying changes
  4. Make module generation require explicit merge policy decisions for nested collection fields.

Alternatives Considered

Option A: Generic recursive merge for all dicts/lists

This is easy to implement but risky. Lists do not have universal semantics: some are sets, some are ordered lists, some are keyed collections, and some must be replaced atomically. A generic list merge could silently produce invalid ND payloads.

Option B: JSON Merge Patch style semantics

This gives a known merge model for dictionaries, but JSON Merge Patch treats arrays as replacement values. That does not solve keyed nested collections without extra policy metadata.

Option C: Policy-aware merge engine

This is the recommended approach. It is more work, but it keeps default behavior safe and lets each model declare the intended semantics for nested collections.

Risks

  • Changing merge behavior globally can alter idempotency and payloads across many modules.
  • Keyed list merge requires reliable identifiers for nested entries.
  • Some ND APIs treat list order as meaningful.
  • Some APIs require full nested payloads even for partial updates.
  • Deep merge can add runtime cost if implemented with repeated dumps/validation.
  • Diff and merge must be updated together; changing only merge can still produce false changes.

Test Plan

  • Add model-level tests for direct nested models, nested dicts, primitive lists, keyed lists of NDNestedModel, and lists of dicts.
  • Add state-machine tests showing state=merged preserves unspecified nested list entries.
  • Add negative tests for fields declared as replace.
  • Add module-specific regression tests for at least:
    • local user security domains
    • fabric update group install image/report checks
    • trunk VLAN mapping entries
  • Add performance benchmarks for large configs and nested lists before enabling broad defaults.

Acceptance Criteria

  • state=merged preserves unspecified nested values for fields declared mergeable.
  • Existing replace semantics remain available and are the default for ambiguous fields.
  • Diff and merge use the same policy rules.
  • Runtime impact is measured and acceptable for large module configs.
  • Future modules have a documented way to declare nested merge behavior.

Metadata

Metadata

Labels

2.0.0Release 2.0.0nac01NaC ND release 0.0.1

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions