Modified Inventory loader to handle module install or upgrade based on flag 62/141962/7
authorsourabh_sourabh <sourabh.sourabh@est.tech>
Wed, 27 Aug 2025 10:44:10 +0000 (11:44 +0100)
committerSourabh Sourabh <sourabh.sourabh@est.tech>
Tue, 2 Sep 2025 14:42:34 +0000 (14:42 +0000)
Issue-ID: CPS-2970
Change-Id: I5b8b0aa2db3fcad6da2a5dd774720f22855523cd
Signed-off-by: sourabh_sourabh <sourabh.sourabh@est.tech>
cps-ncmp-service/src/main/java/org/onap/cps/ncmp/init/InventoryModelLoader.java
cps-ncmp-service/src/main/resources/models/dmi-registry@2025-07-22.yang [new file with mode: 0644]
cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/init/InventoryModelLoaderSpec.groovy
cps-service/src/main/java/org/onap/cps/init/AbstractModelLoader.java
cps-service/src/test/groovy/org/onap/cps/init/AbstractModelLoaderSpec.groovy
docker-compose/docker-compose.yml
integration-test/src/test/groovy/org/onap/cps/integration/base/CpsIntegrationSpecBase.groovy
integration-test/src/test/groovy/org/onap/cps/integration/functional/ncmp/inventory/ModuleUpgradeServiceIntegrationSpec.groovy [new file with mode: 0644]
integration-test/src/test/resources/data/inventory/dmi-registry@2025-07-22.yang [new file with mode: 0644]

index 514d9b8..51ba3ec 100644 (file)
@@ -1,6 +1,6 @@
 /*
  *  ============LICENSE_START=======================================================
- *  Copyright (C) 2023-2024 Nordix Foundation
+ *  Copyright (C) 2023-2025 OpenInfra Foundation Europe. All rights reserved.
  *  ================================================================================
  *  Licensed under the Apache License, Version 2.0 (the "License");
  *  you may not use this file except in compliance with the License.
@@ -31,6 +31,7 @@ import org.onap.cps.api.CpsDataspaceService;
 import org.onap.cps.api.CpsModuleService;
 import org.onap.cps.init.AbstractModelLoader;
 import org.onap.cps.ncmp.utils.events.NcmpInventoryModelOnboardingFinishedEvent;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.stereotype.Service;
 
@@ -39,9 +40,12 @@ import org.springframework.stereotype.Service;
 public class InventoryModelLoader extends AbstractModelLoader {
 
     private final ApplicationEventPublisher applicationEventPublisher;
-    private static final String NEW_MODEL_FILE_NAME = "dmi-registry@2024-02-23.yang";
-    private static final String NEW_SCHEMA_SET_NAME = "dmi-registry-2024-02-23";
-    private static final String REGISTRY_DATANODE_NAME = "dmi-registry";
+    private static final String PREVIOUS_SCHEMA_SET_NAME = "dmi-registry-2024-02-23";
+    private static final String NEW_INVENTORY_SCHEMA_SET_NAME = "dmi-registry-2025-07-22";
+    private static final String INVENTORY_YANG_MODULE_NAME = "dmi-registry";
+
+    @Value("${ncmp.inventory.model.upgrade.r20250722.enabled:false}")
+    private boolean newRevisionEnabled;
 
     public InventoryModelLoader(final CpsDataspaceService cpsDataspaceService,
                                 final CpsModuleService cpsModuleService,
@@ -54,19 +58,35 @@ public class InventoryModelLoader extends AbstractModelLoader {
 
     @Override
     public void onboardOrUpgradeModel() {
-        updateInventoryModel();
-        log.info("Inventory Model updated successfully");
+        final String schemaToInstall = newRevisionEnabled ? NEW_INVENTORY_SCHEMA_SET_NAME : PREVIOUS_SCHEMA_SET_NAME;
+        if (newRevisionEnabled) {
+            if (doesAnchorExist(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR)) {
+                final String moduleRevision = getModuleRevision(schemaToInstall);
+                if (isModuleRevisionInstalled(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR, INVENTORY_YANG_MODULE_NAME,
+                        moduleRevision)) {
+                    log.info("Revision {} is already installed.", moduleRevision);
+                } else {
+                    upgradeInventoryModel();
+                    performInventoryDataMigration();
+                }
+            } else {
+                installInventoryModel(schemaToInstall);
+            }
+        } else {
+            installInventoryModel(schemaToInstall);
+        }
         applicationEventPublisher.publishEvent(new NcmpInventoryModelOnboardingFinishedEvent(this));
     }
 
-    private void updateInventoryModel() {
+    private void installInventoryModel(final String schemaSetName) {
         createDataspace(NCMP_DATASPACE_NAME);
         createDataspace(NFP_OPERATIONAL_DATASTORE_DATASPACE_NAME);
-        createSchemaSet(NCMP_DATASPACE_NAME, NEW_SCHEMA_SET_NAME, NEW_MODEL_FILE_NAME);
-        createAnchor(NCMP_DATASPACE_NAME, NEW_SCHEMA_SET_NAME, NCMP_DMI_REGISTRY_ANCHOR);
-        updateAnchorSchemaSet(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR, NEW_SCHEMA_SET_NAME);
-        createTopLevelDataNode(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR, REGISTRY_DATANODE_NAME);
+        final String yangFileName = toYangFileName(schemaSetName);
+        createSchemaSet(NCMP_DATASPACE_NAME, schemaSetName, yangFileName);
+        createAnchor(NCMP_DATASPACE_NAME, schemaSetName, NCMP_DMI_REGISTRY_ANCHOR);
+        createTopLevelDataNode(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR, INVENTORY_YANG_MODULE_NAME);
         deleteOldButNotThePreviousSchemaSets();
+        log.info("Inventory model {} installed successfully,", schemaSetName);
     }
 
     private void deleteOldButNotThePreviousSchemaSets() {
@@ -75,4 +95,27 @@ public class InventoryModelLoader extends AbstractModelLoader {
         deleteUnusedSchemaSets(NFP_OPERATIONAL_DATASTORE_DATASPACE_NAME);
     }
 
+    private void upgradeInventoryModel() {
+        final String yangFileName = toYangFileName(NEW_INVENTORY_SCHEMA_SET_NAME);
+        createSchemaSet(NCMP_DATASPACE_NAME, NEW_INVENTORY_SCHEMA_SET_NAME, yangFileName);
+        cpsAnchorService.updateAnchorSchemaSet(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR,
+                NEW_INVENTORY_SCHEMA_SET_NAME);
+        log.info("Inventory upgraded successfully to model {}", NEW_INVENTORY_SCHEMA_SET_NAME);
+    }
+
+    private void performInventoryDataMigration() {
+        // TODO further implementation is pending
+        //1. Load all the cm handles (in batch)
+        //2. Copy the state and known properties
+        log.info("Inventory module data migration is completed successfully.");
+    }
+
+    private static String toYangFileName(final String schemaSetName) {
+        return INVENTORY_YANG_MODULE_NAME + "@" + getModuleRevision(schemaSetName) + ".yang";
+    }
+
+    private static String getModuleRevision(final String schemaSetName) {
+        // Extract the revision part ( for example: 2024-02-23)
+        return schemaSetName.substring(INVENTORY_YANG_MODULE_NAME.length() + 1);
+    }
 }
diff --git a/cps-ncmp-service/src/main/resources/models/dmi-registry@2025-07-22.yang b/cps-ncmp-service/src/main/resources/models/dmi-registry@2025-07-22.yang
new file mode 100644 (file)
index 0000000..4405c14
--- /dev/null
@@ -0,0 +1,162 @@
+module dmi-registry {
+
+  yang-version 1.1;
+
+  namespace "org:onap:cps:ncmp";
+
+  prefix dmi-reg;
+
+  contact "toine.siebelink@est.tech";
+
+  revision "2025-07-22" {
+    description
+    "Added dmi-registry.cm-handles.dmi-properties (string) to replace (now deprecated) dmi-registry.cm-handles.additional-properties (list).
+     Added dmi-registry.cm-handles.cm-handle-state to replace (now deprecated) and dmi-registry.cm-handles.state.cm-handle-state";
+  }
+
+  revision "2024-02-23" {
+      description
+      "Added data-producer-identifier";
+    }
+
+  revision "2023-11-27" {
+    description
+    "Added alternate-id";
+  }
+
+  revision "2023-08-23" {
+    description
+    "Added module-set-tag";
+  }
+
+  revision "2022-05-10" {
+    description
+    "Added data-sync-enabled, sync-state with state, last-sync-time, data-store-sync-state with operational and running syncstate";
+  }
+
+  revision "2022-02-10" {
+    description
+    "Added state, lock-reason, lock-reason-details to aid with cmHandle sync and timestamp to aid with retry/timeout scenarios";
+  }
+
+  revision "2021-12-13" {
+    description
+    "Added new list of public-properties and additional-properties for a Cm-Handle which are exposed to clients of the NCMP interface";
+  }
+
+  revision "2021-10-20" {
+    description
+    "Added dmi-data-service-name & dmi-model-service-name to allow separate DMI instances for each responsibility";
+  }
+
+  revision "2021-05-20" {
+    description
+    "Initial Version";
+  }
+
+  grouping LockReason {
+    leaf reason {
+      type string;
+    }
+    leaf details {
+      type string;
+    }
+  }
+
+  grouping SyncState {
+   leaf sync-state {
+     type string;
+   }
+   leaf last-sync-time {
+     type string;
+   }
+  }
+
+  grouping Datastores {
+    container operational {
+      uses SyncState;
+    }
+    container running {
+      uses SyncState;
+    }
+  }
+
+  container dmi-registry {
+    list cm-handles {
+      key "id";
+      leaf id {
+        type string;
+      }
+      leaf dmi-service-name {
+        type string;
+      }
+      leaf dmi-data-service-name {
+        type string;
+      }
+      leaf dmi-model-service-name {
+        type string;
+      }
+      leaf module-set-tag {
+        type string;
+      }
+      leaf alternate-id {
+        type string;
+      }
+      leaf data-producer-identifier {
+        type string;
+      }
+      leaf dmi-properties {
+        type string;
+      }
+      leaf cm-handle-state {
+        type string;
+      }
+
+      list additional-properties {
+        key "name";
+        leaf name {
+          type string;
+        }
+        leaf value {
+          type string;
+        }
+        status deprecated; // Replaced by dmi-properties
+      }
+
+      list public-properties {
+        key "name";
+        leaf name {
+          type string;
+        }
+        leaf value {
+          type string;
+        }
+      }
+
+      container state {
+        leaf cm-handle-state {
+          type string;
+          status deprecated;
+        }
+
+        container lock-reason {
+          uses LockReason;
+        }
+
+        leaf last-update-time {
+          type string;
+        }
+
+        leaf data-sync-enabled {
+          type boolean;
+          default "false";
+        }
+
+        container datastores {
+          uses Datastores;
+        }
+      }
+    }
+  }
+}
+
index dc6ec41..dfc7c10 100644 (file)
@@ -1,6 +1,6 @@
 /*
  *  ============LICENSE_START=======================================================
- *  Copyright (C) 2023-2024 Nordix Foundation
+ *  Copyright (C) 2023-2025 OpenInfra Foundation Europe. All rights reserved.
  *  ================================================================================
  *  Licensed under the Apache License, Version 2.0 (the "License");
  *  you may not use this file except in compliance with the License.
@@ -27,7 +27,9 @@ import org.onap.cps.api.CpsAnchorService
 import org.onap.cps.api.CpsDataService
 import org.onap.cps.api.CpsDataspaceService
 import org.onap.cps.api.CpsModuleService
+import org.onap.cps.api.exceptions.AnchorNotFoundException
 import org.onap.cps.api.model.Dataspace
+import org.onap.cps.api.model.ModuleDefinition
 import org.slf4j.LoggerFactory
 import org.springframework.boot.context.event.ApplicationStartedEvent
 import org.springframework.context.ApplicationEventPublisher
@@ -48,12 +50,15 @@ class InventoryModelLoaderSpec extends Specification {
 
     def applicationContext = new AnnotationConfigApplicationContext()
 
-    def expectedYangResourceToContentMap
+    def expectedPreviousYangResourceToContentMap
+    def expectedNewYangResourceToContentMap
     def logger = (Logger) LoggerFactory.getLogger(objectUnderTest.class)
     def loggingListAppender
 
     void setup() {
-        expectedYangResourceToContentMap = objectUnderTest.mapYangResourcesToContent('dmi-registry@2024-02-23.yang')
+        expectedPreviousYangResourceToContentMap = objectUnderTest.mapYangResourcesToContent('dmi-registry@2024-02-23.yang')
+        expectedNewYangResourceToContentMap = objectUnderTest.mapYangResourcesToContent('dmi-registry@2025-07-22.yang')
+        objectUnderTest.newRevisionEnabled = true
         logger.setLevel(Level.DEBUG)
         loggingListAppender = new ListAppender()
         logger.addAppender(loggingListAppender)
@@ -68,17 +73,50 @@ class InventoryModelLoaderSpec extends Specification {
 
     def 'Onboard subscription model via application ready event.'() {
         given: 'dataspace is ready for use'
+            objectUnderTest.newRevisionEnabled = false
             mockCpsAdminService.getDataspace(NCMP_DATASPACE_NAME) >> new Dataspace('')
         when: 'the application is started'
             objectUnderTest.onApplicationEvent(Mock(ApplicationStartedEvent))
         then: 'the module service is used to create the new schema set from the correct resource'
-            1 * mockCpsModuleService.createSchemaSet(NCMP_DATASPACE_NAME, 'dmi-registry-2024-02-23', expectedYangResourceToContentMap)
-        and: 'the admin service is used to update the anchor'
-            1 * mockCpsAnchorService.updateAnchorSchemaSet(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR, 'dmi-registry-2024-02-23')
+            1 * mockCpsModuleService.createSchemaSet(NCMP_DATASPACE_NAME, 'dmi-registry-2024-02-23', expectedPreviousYangResourceToContentMap)
         and: 'No schema sets are being removed by the module service (yet)'
             0 * mockCpsModuleService.deleteSchemaSet(NCMP_DATASPACE_NAME, _, _)
         and: 'application event publisher is called once'
             1 * mockApplicationEventPublisher.publishEvent(_)
     }
 
+    def 'Install new model revision'() {
+        given: 'the anchor does not exist'
+            mockCpsAnchorService.getAnchor(_, _) >> { throw new AnchorNotFoundException('', '') }
+        when: 'the inventory model loader is triggered'
+            objectUnderTest.onboardOrUpgradeModel()
+        then: 'a new schema set for the 2025-07-22 revision is installed'
+            1 * mockCpsModuleService.createSchemaSet(NCMP_DATASPACE_NAME, 'dmi-registry-2025-07-22', expectedNewYangResourceToContentMap)
+    }
+
+    def 'Upgrade model revision'() {
+        given: 'the anchor exists and new module revision is not installed'
+            mockCpsAnchorService.getAnchor(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR) >> {}
+            mockCpsModuleService.getModuleDefinitionsByAnchorAndModule(_, _, _, _) >> Collections.emptyList()
+        when: 'the inventory model loader is triggered'
+            objectUnderTest.onboardOrUpgradeModel()
+        then: 'the new schema set for the 2025-07-22 revision is created'
+            1 * mockCpsModuleService.createSchemaSet(NCMP_DATASPACE_NAME, 'dmi-registry-2025-07-22', expectedNewYangResourceToContentMap)
+        and: 'the anchor is updated to point to the new schema set'
+            1 * mockCpsAnchorService.updateAnchorSchemaSet(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR, 'dmi-registry-2025-07-22')
+        and: 'log messages confirm successful upgrade'
+            assert loggingListAppender.list.any { it.message.contains("Inventory upgraded successfully") }
+    }
+
+    def 'Skip upgrade model revision when new revision already installed'() {
+        given: 'the anchor exists and the new model revision is already installed'
+            mockCpsAnchorService.getAnchor(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR) >> {}
+            mockCpsModuleService.getModuleDefinitionsByAnchorAndModule(_, _, _, _) >> [new ModuleDefinition('', '', '')]
+        when: 'the inventory model loader is triggered'
+            objectUnderTest.onboardOrUpgradeModel()
+        then: 'no new schema set is created'
+            0 * mockCpsModuleService.createSchemaSet(_, _, _)
+        and: 'a log message confirms the revision is already installed'
+            assert loggingListAppender.list.any { it.message.contains("already installed") }
+    }
 }
index df068c6..a87c36e 100644 (file)
@@ -1,6 +1,6 @@
 /*
  *  ============LICENSE_START=======================================================
- *  Copyright (C) 2023-2025 Nordix Foundation
+ *  Copyright (C) 2023-2025 OpenInfra Foundation Europe. All rights reserved.
  *  Modifications Copyright (C) 2024 TechMahindra Ltd.
  *  ================================================================================
  *  Licensed under the Apache License, Version 2.0 (the "License");
@@ -25,6 +25,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
 import java.time.OffsetDateTime;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
 import lombok.RequiredArgsConstructor;
@@ -34,8 +35,10 @@ import org.onap.cps.api.CpsDataService;
 import org.onap.cps.api.CpsDataspaceService;
 import org.onap.cps.api.CpsModuleService;
 import org.onap.cps.api.exceptions.AlreadyDefinedException;
+import org.onap.cps.api.exceptions.AnchorNotFoundException;
 import org.onap.cps.api.exceptions.DuplicatedYangResourceException;
 import org.onap.cps.api.exceptions.ModelOnboardingException;
+import org.onap.cps.api.model.ModuleDefinition;
 import org.onap.cps.api.parameters.CascadeDeleteAllowed;
 import org.onap.cps.utils.JsonObjectMapper;
 import org.springframework.boot.SpringApplication;
@@ -47,7 +50,7 @@ public abstract class AbstractModelLoader implements ModelLoader {
 
     protected final CpsDataspaceService cpsDataspaceService;
     private final CpsModuleService cpsModuleService;
-    private final CpsAnchorService cpsAnchorService;
+    protected final CpsAnchorService cpsAnchorService;
     protected final CpsDataService cpsDataService;
 
     private final JsonObjectMapper jsonObjectMapper = new JsonObjectMapper(new ObjectMapper());
@@ -117,6 +120,23 @@ public abstract class AbstractModelLoader implements ModelLoader {
         }
     }
 
+    /**
+     * Checks whether the specified anchor exists within the given dataspace.
+     *
+     * @param dataspaceName the name of the dataspace
+     * @param anchorName    the name of the anchor within the dataspace
+     * @return {@code true} if the anchor exists, {@code false} otherwise
+     */
+    public boolean doesAnchorExist(final String dataspaceName, final String anchorName) {
+        try {
+            cpsAnchorService.getAnchor(dataspaceName, anchorName);
+            return true;
+        } catch (final AnchorNotFoundException anchorNotFoundException) {
+            log.debug("Anchor '{}' not found in dataspace '{}'", anchorName, dataspaceName);
+            return false;
+        }
+    }
+
     /**
      * Create initial top level data node.
      * @param dataspaceName dataspace name
@@ -184,6 +204,17 @@ public abstract class AbstractModelLoader implements ModelLoader {
         }
     }
 
+    /**
+     * Checks if the specified revision of a module is installed.
+     */
+    protected boolean isModuleRevisionInstalled(final String dataspaceName, final String anchorName,
+                                                final String moduleName, final String moduleRevision) {
+        final Collection<ModuleDefinition> moduleDefinitions =
+                cpsModuleService.getModuleDefinitionsByAnchorAndModule(dataspaceName, anchorName, moduleName,
+                        moduleRevision);
+        return !moduleDefinitions.isEmpty();
+    }
+
     private void exitApplication(final ApplicationStartedEvent applicationStartedEvent) {
         SpringApplication.exit(applicationStartedEvent.getApplicationContext(), () -> EXIT_CODE_ON_ERROR);
     }
index c3cb4f2..8692e60 100644 (file)
@@ -1,6 +1,6 @@
 /*
  *  ============LICENSE_START=======================================================
- *  Copyright (C) 2023-2025 Nordix Foundation
+ *  Copyright (C) 2023-2025 OpenInfra Foundation Europe. All rights reserved.
  *  Modification Copyright (C) 2024 TechMahindra Ltd.
  *  ================================================================================
  *  Licensed under the Apache License, Version 2.0 (the "License");
@@ -28,8 +28,10 @@ import org.onap.cps.api.CpsAnchorService
 import org.onap.cps.api.CpsDataService
 import org.onap.cps.api.CpsDataspaceService
 import org.onap.cps.api.CpsModuleService
+import org.onap.cps.api.exceptions.AnchorNotFoundException
 import org.onap.cps.api.exceptions.DuplicatedYangResourceException
 import org.onap.cps.api.exceptions.ModelOnboardingException
+import org.onap.cps.api.model.ModuleDefinition
 import org.onap.cps.api.parameters.CascadeDeleteAllowed
 import org.onap.cps.api.exceptions.AlreadyDefinedException
 import org.slf4j.LoggerFactory
@@ -242,6 +244,38 @@ class AbstractModelLoaderSpec extends Specification {
             assert thrown.details.contains('test message')
     }
 
+    def 'Checking if an anchor exists'() {
+        given: 'the anchor service returns an anchor without throwing an exception'
+            mockCpsAnchorService.getAnchor('my-dataspace', 'my-anchor') >> {}
+        when: 'checking if the anchor exists'
+            def result = objectUnderTest.doesAnchorExist('my-dataspace', 'my-anchor')
+        then: 'the expected boolean value is returned'
+            assert result == true
+    }
+
+    def 'Checking if an anchor exists with unknown anchor'() {
+        given: 'the anchor service throws an exception'
+            def anchorNotFoundException = new AnchorNotFoundException('my-dataspace', 'missing-anchor')
+            mockCpsAnchorService.getAnchor('my-dataspace', 'missing-anchor') >> {throw anchorNotFoundException}
+        when: 'checking if the anchor exists'
+            def result = objectUnderTest.doesAnchorExist('my-dataspace', 'missing-anchor')
+        then: 'the expected boolean value is returned'
+            assert result == false
+    }
+
+    def 'Checking if module revision is installed when: #scenario'() {
+        given: 'the module service returns module definitions'
+            mockCpsModuleService.getModuleDefinitionsByAnchorAndModule('some-dataspace', 'some-anchor', 'some-module', 'my-revision') >> moduleDefinitions
+        when: 'checking if a module revision is installed'
+            def result = objectUnderTest.isModuleRevisionInstalled('some-dataspace', 'some-anchor', 'some-module', 'my-revision')
+        then: 'the result matches expectation'
+            assert result == expectedResult
+        where: 'the following scenarios are used'
+            scenario                         || moduleDefinitions        || expectedResult
+            'Module revision exists'         || [Mock(ModuleDefinition)] || true
+            'Module revision does not exist' || []                       || false
+    }
+
     private void assertLogContains(String message) {
         def logs = loggingListAppender.list.toString()
         assert logs.contains(message)
index e9fd9da..8b55d07 100644 (file)
@@ -74,6 +74,7 @@ services:
       JAVA_TOOL_OPTIONS: "-XX:InitialRAMPercentage=70.0 -XX:MaxRAMPercentage=70.0"
       ### DEBUG: Uncomment next line to enable java debugging
       ### JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005
+      NCMP_INVENTORY_MODEL_UPGRADE_R20250722_ENABLED: 'false'
     restart: on-failure:3
     deploy:
       replicas: 0
index 7de0ad5..d8c1e16 100644 (file)
@@ -131,7 +131,7 @@ abstract class CpsIntegrationSpecBase extends Specification {
     NetworkCmProxyFacade networkCmProxyFacade
 
     @Autowired
-    NetworkCmProxyInventoryFacadeImpl NetworkCmProxyInventoryFacade
+    NetworkCmProxyInventoryFacadeImpl networkCmProxyInventoryFacade
 
     @Autowired
     NetworkCmProxyQueryService networkCmProxyQueryService
diff --git a/integration-test/src/test/groovy/org/onap/cps/integration/functional/ncmp/inventory/ModuleUpgradeServiceIntegrationSpec.groovy b/integration-test/src/test/groovy/org/onap/cps/integration/functional/ncmp/inventory/ModuleUpgradeServiceIntegrationSpec.groovy
new file mode 100644 (file)
index 0000000..104166d
--- /dev/null
@@ -0,0 +1,94 @@
+/*
+ *  ============LICENSE_START=======================================================
+ *  Copyright (C) 2025 OpenInfra Foundation Europe. All rights reserved.
+ *  ================================================================================
+ *  Licensed under the Apache License, Version 2.0 (the 'License');
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an 'AS IS' BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ *  SPDX-License-Identifier: Apache-2.0
+ *  ============LICENSE_END=========================================================
+ */
+
+package org.onap.cps.integration.functional.ncmp.inventory
+
+import org.onap.cps.api.model.DataNode
+import org.onap.cps.api.model.ModuleReference
+import org.onap.cps.api.parameters.FetchDescendantsOption
+import org.onap.cps.integration.base.FunctionalSpecBase
+import org.onap.cps.ncmp.api.inventory.models.*
+import org.onap.cps.ncmp.impl.NetworkCmProxyInventoryFacadeImpl
+import org.onap.cps.ncmp.impl.inventory.models.YangModelCmHandle
+import org.onap.cps.ncmp.impl.utils.YangDataConverter
+
+class ModuleUpgradeServiceIntegrationSpec extends FunctionalSpecBase {
+
+    NetworkCmProxyInventoryFacadeImpl objectUnderTest
+    def cmHandleId = 'ch-1'
+
+    def setup() {
+        objectUnderTest = networkCmProxyInventoryFacade
+    }
+
+    def 'CM Handle registry (inventory) model upgrade poc (incl. backward compatibility)'() {
+        given: 'DMI plugin provides initial modules for the CM handle'
+            dmiDispatcher1.moduleNamesPerCmHandleId[cmHandleId] = ['M1', 'M2']
+        and: 'NCMP already has an existing module reference (old revision)'
+            def existingModule = new ModuleReference(moduleName: "dmi-registry", revision: "2024-02-23")
+            cpsModulePersistenceService.getYangResourceModuleReferences(_, _) >> [existingModule]
+        when: 'A CM-handle is registered'
+            def dmiPluginRegistration = new DmiPluginRegistration(dmiPlugin: DMI1_URL)
+            dmiPluginRegistration.setCreatedCmHandles([new NcmpServiceCmHandle(cmHandleId: cmHandleId, additionalProperties: ['addProp1': 'some-value'])])
+            def dmiPluginRegistrationResponse = objectUnderTest.updateDmiRegistration(dmiPluginRegistration)
+        then: 'The CM-handle registration succeeds'
+            assert dmiPluginRegistrationResponse.createdCmHandles == [CmHandleRegistrationResponse.createSuccessResponse(cmHandleId)]
+        and: 'The CM-handle is initialized with ADVISED state'
+            assert CmHandleState.ADVISED == objectUnderTest.getCmHandleCompositeState(cmHandleId).cmHandleState
+        then:  'The module sync watchdog is invoked for advised CM-handles'
+            moduleSyncWatchdog.moduleSyncAdvisedCmHandles()
+        then: 'After module sync, the CM-handle transitions to READY state'
+            assert CmHandleState.READY == objectUnderTest.getCmHandleCompositeState(cmHandleId).cmHandleState
+        when: 'A new version of the dmi-registry module (upgrade) is available'
+            def newYangContent = readResourceDataFile('inventory/dmi-registry@2025-07-22.yang')
+            def newYangResourceContentPerName = ["dmi-registry@2025-07-22.yang": newYangContent]
+        then: 'The schema set is upgraded with the new module revision'
+            cpsModulePersistenceService.createSchemaSet('NCMP-Admin', 'dmi-registry-2025-07-22', newYangResourceContentPerName)
+            cpsAnchorService.updateAnchorSchemaSet('NCMP-Admin','ncmp-dmi-registry','dmi-registry-2025-07-22')
+        when: 'that state gets updated to a different value'
+            final Collection<DataNode> cmHandleDataNodes = inventoryPersistence.getCmHandleDataNodeByCmHandleId('ch-1', FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS)
+            YangModelCmHandle yangModelCmHandle= YangDataConverter.toYangModelCmHandle(cmHandleDataNodes[0])
+            CompositeState compositeState= yangModelCmHandle.getCompositeState()
+            compositeState.setCmHandleState(CmHandleState.LOCKED)
+        then: 'the CM handle gets saved'
+            inventoryPersistence.saveCmHandleState(cmHandleId, compositeState)
+        and: 'we load the CM handle again'
+            final Collection<DataNode> updatedCmHandleDataNodes = inventoryPersistence.getCmHandleDataNodeByCmHandleId(cmHandleId, FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS)
+            YangModelCmHandle updatedYangModelCmHandle= YangDataConverter.toYangModelCmHandle(updatedCmHandleDataNodes[0])
+        and: 'the state has the new value i.e. load and save worked successfully'
+            assert updatedYangModelCmHandle.getCompositeState().cmHandleState == CmHandleState.LOCKED
+        when: 'The CM-handle additional properties are updated'
+            def dmiPluginRegistrationToUpdate = new DmiPluginRegistration(dmiPlugin: DMI1_URL)
+            dmiPluginRegistrationToUpdate.setUpdatedCmHandles([new NcmpServiceCmHandle(cmHandleId: cmHandleId, additionalProperties: ['addProp1': 'value1','addProp2': 'value2'])])
+            def updatedDmiPluginRegistrationResponse = objectUnderTest.updateDmiRegistration(dmiPluginRegistrationToUpdate)
+        then: 'The update response confirms SUCCESS for the CM-handle'
+            assert updatedDmiPluginRegistrationResponse.updatedCmHandles.size() == 1
+            def updatedHandleResponse = updatedDmiPluginRegistrationResponse.updatedCmHandles[0]
+            assert updatedHandleResponse.cmHandle == cmHandleId
+            assert updatedHandleResponse.status == CmHandleRegistrationResponse.Status.SUCCESS
+        and: 'Reloaded CM-handle contains the new additional properties (backward compatibility preserved)'
+            def updatedCmHandleDataNodesAfterUpdate  = inventoryPersistence.getCmHandleDataNodeByCmHandleId(cmHandleId, FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS)
+            def reloadedCmHandleAfterUpdate= YangDataConverter.toYangModelCmHandle(updatedCmHandleDataNodesAfterUpdate[0])
+            assert reloadedCmHandleAfterUpdate.additionalProperties.collectEntries { [it.name, it.value] } == [addProp1: "value1", addProp2: "value2"]
+        cleanup: 'deregister CM handle'
+            deregisterCmHandle(DMI1_URL, cmHandleId)
+    }
+
+}
diff --git a/integration-test/src/test/resources/data/inventory/dmi-registry@2025-07-22.yang b/integration-test/src/test/resources/data/inventory/dmi-registry@2025-07-22.yang
new file mode 100644 (file)
index 0000000..4405c14
--- /dev/null
@@ -0,0 +1,162 @@
+module dmi-registry {
+
+  yang-version 1.1;
+
+  namespace "org:onap:cps:ncmp";
+
+  prefix dmi-reg;
+
+  contact "toine.siebelink@est.tech";
+
+  revision "2025-07-22" {
+    description
+    "Added dmi-registry.cm-handles.dmi-properties (string) to replace (now deprecated) dmi-registry.cm-handles.additional-properties (list).
+     Added dmi-registry.cm-handles.cm-handle-state to replace (now deprecated) and dmi-registry.cm-handles.state.cm-handle-state";
+  }
+
+  revision "2024-02-23" {
+      description
+      "Added data-producer-identifier";
+    }
+
+  revision "2023-11-27" {
+    description
+    "Added alternate-id";
+  }
+
+  revision "2023-08-23" {
+    description
+    "Added module-set-tag";
+  }
+
+  revision "2022-05-10" {
+    description
+    "Added data-sync-enabled, sync-state with state, last-sync-time, data-store-sync-state with operational and running syncstate";
+  }
+
+  revision "2022-02-10" {
+    description
+    "Added state, lock-reason, lock-reason-details to aid with cmHandle sync and timestamp to aid with retry/timeout scenarios";
+  }
+
+  revision "2021-12-13" {
+    description
+    "Added new list of public-properties and additional-properties for a Cm-Handle which are exposed to clients of the NCMP interface";
+  }
+
+  revision "2021-10-20" {
+    description
+    "Added dmi-data-service-name & dmi-model-service-name to allow separate DMI instances for each responsibility";
+  }
+
+  revision "2021-05-20" {
+    description
+    "Initial Version";
+  }
+
+  grouping LockReason {
+    leaf reason {
+      type string;
+    }
+    leaf details {
+      type string;
+    }
+  }
+
+  grouping SyncState {
+   leaf sync-state {
+     type string;
+   }
+   leaf last-sync-time {
+     type string;
+   }
+  }
+
+  grouping Datastores {
+    container operational {
+      uses SyncState;
+    }
+    container running {
+      uses SyncState;
+    }
+  }
+
+  container dmi-registry {
+    list cm-handles {
+      key "id";
+      leaf id {
+        type string;
+      }
+      leaf dmi-service-name {
+        type string;
+      }
+      leaf dmi-data-service-name {
+        type string;
+      }
+      leaf dmi-model-service-name {
+        type string;
+      }
+      leaf module-set-tag {
+        type string;
+      }
+      leaf alternate-id {
+        type string;
+      }
+      leaf data-producer-identifier {
+        type string;
+      }
+      leaf dmi-properties {
+        type string;
+      }
+      leaf cm-handle-state {
+        type string;
+      }
+
+      list additional-properties {
+        key "name";
+        leaf name {
+          type string;
+        }
+        leaf value {
+          type string;
+        }
+        status deprecated; // Replaced by dmi-properties
+      }
+
+      list public-properties {
+        key "name";
+        leaf name {
+          type string;
+        }
+        leaf value {
+          type string;
+        }
+      }
+
+      container state {
+        leaf cm-handle-state {
+          type string;
+          status deprecated;
+        }
+
+        container lock-reason {
+          uses LockReason;
+        }
+
+        leaf last-update-time {
+          type string;
+        }
+
+        leaf data-sync-enabled {
+          type boolean;
+          default "false";
+        }
+
+        container datastores {
+          uses Datastores;
+        }
+      }
+    }
+  }
+}
+