[CORD-3100] Refactoring and documenting the ONOS Service
Change-Id: Ida83935798a2a99b538f6ccdc55cc26f3148ffe3
diff --git a/.gitignore b/.gitignore
index 723ef36..1163cb2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
-.idea
\ No newline at end of file
+.idea
+*.pyc
\ No newline at end of file
diff --git a/Dockerfile.synchronizer b/Dockerfile.synchronizer
index 6aa3dc3..2027b65 100644
--- a/Dockerfile.synchronizer
+++ b/Dockerfile.synchronizer
@@ -12,6 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+# docker build -t xosproject/onos-synchronizer:candidate -f Dockerfile.synchronizer .
+
# xosproject/onos-synchronizer
FROM xosproject/xos-synchronizer-base:candidate
@@ -51,4 +53,3 @@
org.opencord.component.xos.vcs-ref=$org_opencord_component_xos_vcs_ref
CMD ["/usr/bin/python", "/opt/xos/synchronizers/onos/onos-synchronizer.py"]
-
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..082d1e0
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,46 @@
+# ONOS Service
+
+This service is intended to manage ONOSes.
+
+It can:
+
+- push global configuration to ONOS
+- install applications
+- push application specific configuration
+
+## Models
+
+This service is composed by two models:
+
+- `ONOSService` responsible to hold urls and authentication informations
+- `ONOSApp` represents and ONOS application and track its dependencies (this extend the `ServiceInstance` model)
+
+This service uses `ServiceAttributes` and `ServiceInstanceAttributes`
+to hold the configuration details.
+
+For more informations about the models, please refer to the
+[xproto](https://github.com/opencord/onos-service/blob/master/xos/synchronizer/models/onos.xproto) definition
+
+## Synchronization workflow
+
+### ONOSService
+
+Anytime an `ONOSService` model is created/updated, the synchronizer checks
+for the corresponding `ServiceAttribute`s and if any are found it pushes the configuration
+to ONOS
+
+### ONOSServiceInstance
+
+Anytime an `ONOSServiceInstance` model is created/updated, the synchronizer checks
+for the corresponding `ServiceInstanceAttribute`s and if any are found:
+
+- checks for the application dependencies
+- if they are not matched defer the synchronization
+- if they are matched
+- it pushes the configuration to ONOS
+- it installs/activates the application in ONOS
+
+> ONOS Applications can be activated (if they already present in the container),
+> in that case you just need to provide the `app_id`, or they can be installed from a remote `.oar`,
+> in which case you just need to provide an `url`
+
\ No newline at end of file
diff --git a/samples/apps.yaml b/samples/apps.yaml
new file mode 100644
index 0000000..d923788
--- /dev/null
+++ b/samples/apps.yaml
@@ -0,0 +1,69 @@
+# Copyright 2017-present Open Networking Foundation
+#
+# 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.
+
+# curl -H "xos-username: admin@opencord.org" -H "xos-password: letmein" -X POST --data-binary @apps.yaml http://192.168.99.100:30007/run
+
+tosca_definitions_version: tosca_simple_yaml_1_0
+imports:
+ - custom_types/onosapp.yaml
+ - custom_types/onosservice.yaml
+ - custom_types/serviceinstanceattribute.yaml
+description: Configures fabric switches and related ports
+topology_template:
+ node_templates:
+ service#onos:
+ type: tosca.nodes.ONOSService
+ properties:
+ name: onos
+ must-exist: true
+
+
+ # Local app
+ dhcp:
+ type: tosca.nodes.ONOSApp
+ properties:
+ name: dhcp
+ app_id: org.onosproject.dhcp
+ requirements:
+ - owner:
+ node: service#onos
+ relationship: tosca.relationships.BelongsToOne
+
+ # Remote app
+ cord-config:
+ type: tosca.nodes.ONOSApp
+ properties:
+ name: cord-config
+ url: https://oss.sonatype.org/content/repositories/public/org/opencord/cord-config/1.4.0-SNAPSHOT/cord-config-1.4.0-20180604.071543-275.oar
+ version: 1.4.0.SNAPSHOT
+ requirements:
+ - owner:
+ node: service#onos
+ relationship: tosca.relationships.BelongsToOne
+
+ # CORD-Configuration
+ cord-config-attr:
+ type: tosca.nodes.ServiceInstanceAttribute
+ properties:
+ name: /onos/v1/network/configuration/apps/org.opencord.olt
+ value: >
+ {
+ "kafka" : {
+ "bootstrapServers" : "cord-kafka-kafka.default.svc.cluster.local:9092"
+ }
+ }
+ requirements:
+ - service_instance:
+ node: cord-config
+ relationship: tosca.relationships.BelongsToOne
\ No newline at end of file
diff --git a/samples/service.yaml b/samples/service.yaml
new file mode 100644
index 0000000..d610cc6
--- /dev/null
+++ b/samples/service.yaml
@@ -0,0 +1,46 @@
+# Copyright 2017-present Open Networking Foundation
+#
+# 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.
+
+# curl -H "xos-username: admin@opencord.org" -H "xos-password: letmein" -X POST --data-binary @service.yaml http://192.168.99.100:30007/run
+
+tosca_definitions_version: tosca_simple_yaml_1_0
+imports:
+ - custom_types/onosservice.yaml
+ - custom_types/serviceattribute.yaml
+description: Configures fabric switches and related ports
+topology_template:
+ node_templates:
+ service#onos:
+ type: tosca.nodes.ONOSService
+ properties:
+ name: onos
+ rest_hostname: onos-fabric-ui
+ rest_port: 8181
+
+
+ # ONOS Configuration
+ driver:
+ type: tosca.nodes.ServiceAttribute
+ properties:
+ name: /onos/v1/network/configuration/devices/of:1234
+ value: >
+ {
+ "basic": {
+ "driver": "mydriver"
+ }
+ }
+ requirements:
+ - service:
+ node: service#onos
+ relationship: tosca.relationships.BelongsToOne
\ No newline at end of file
diff --git a/xos/synchronizer/scripts/dockerip.sh b/xos/nose2-plugins/__init__.py
similarity index 68%
copy from xos/synchronizer/scripts/dockerip.sh
copy to xos/nose2-plugins/__init__.py
index 3618fa6..42722a8 100644
--- a/xos/synchronizer/scripts/dockerip.sh
+++ b/xos/nose2-plugins/__init__.py
@@ -12,14 +12,3 @@
# 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.
-
-
-#!/bin/bash
-
-MODE=`docker inspect --format '{{ .HostConfig.NetworkMode }}' $1 | tr -d '\n' | tr -d '\r'`
-if [[ "$MODE" == "host" ]]; then
- echo -n "127.0.0.1"
-else
- docker inspect --format '{{ .NetworkSettings.IPAddress }}' $1 | tr -d '\n' | tr -d '\r'
-fi
-
diff --git a/xos/nose2-plugins/exclude.py b/xos/nose2-plugins/exclude.py
new file mode 100644
index 0000000..241eadb
--- /dev/null
+++ b/xos/nose2-plugins/exclude.py
@@ -0,0 +1,32 @@
+
+# Copyright 2017-present Open Networking Foundation
+#
+# 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.
+
+import logging
+import os
+
+from nose2.events import Plugin
+
+log = logging.getLogger('nose2.plugins.excludeignoredfiles')
+
+class ExcludeIgnoredFiles(Plugin):
+ commandLineSwitch = (None, 'exclude-ignored-files', 'Exclude that which should be excluded')
+
+ def matchPath(self, event):
+ if event.path.endswith(".py"):
+ text = open(event.path, "r").read()
+ if "test_framework: ignore" in text.lower():
+ log.info("Ignoring %s" % event.path)
+ event.handled = True
+ return False
diff --git a/xos/synchronizer/models/onos.xproto b/xos/synchronizer/models/onos.xproto
index c5cfc56..726702d 100644
--- a/xos/synchronizer/models/onos.xproto
+++ b/xos/synchronizer/models/onos.xproto
@@ -6,17 +6,17 @@
option verbose_name="ONOS Application";
option owner_class_name="ONOSService";
- optional string install_dependencies = 1 [db_index = False, null = True, blank = True];
- optional string dependencies = 2 [db_index = False, null = True, blank = True];
+ optional string app_id = 1 [db_index = False, null = True, blank = False];
+ required string dependencies = 2 [help_text="Comma separated list of required applications", db_index = False, null = True, blank = True];
+ optional string url = 3 [help_text="URL at which the application is available, if it needs to be downloaded", db_index = False, null = True, blank = False];
+ required string version = 4 [db_index = False, null = True, blank = False];
}
message ONOSService (Service){
option verbose_name="ONOS Service";
- optional string rest_hostname = 1 [db_index = False, max_length = 255, null = True, content_type = "stripped", blank = True];
+ required string rest_hostname = 1 [db_index = False, max_length = 255, null = False, content_type = "stripped", blank = False];
required int32 rest_port = 2 [default = 8181, null = False, db_index = False, blank = False];
- required bool no_container = 3 [default = False, null = False, db_index = False, blank = True];
- optional string node_key = 4 [db_index = False, max_length = 1024, null = True, content_type = "stripped", blank = True];
- optional string rest_username = 5 [db_index = False, max_length = 255, null = True, content_type = "stripped", blank = True, default="karaf"];
- optional string rest_password = 6 [db_index = False, max_length = 255, null = True, content_type = "stripped", blank = True, default="karaf"];
+ required string rest_username = 3 [db_index = False, max_length = 255, null = False, content_type = "stripped", blank = False, default="karaf"];
+ required string rest_password = 4 [db_index = False, max_length = 255, null = False, content_type = "stripped", blank = False, default="karaf"];
}
diff --git a/xos/synchronizer/onos-ext-notifier-1.0-SNAPSHOT.oar b/xos/synchronizer/onos-ext-notifier-1.0-SNAPSHOT.oar
deleted file mode 100644
index 23c6fcd..0000000
--- a/xos/synchronizer/onos-ext-notifier-1.0-SNAPSHOT.oar
+++ /dev/null
Binary files differ
diff --git a/xos/synchronizer/onos-ext-volt-event-publisher-1.0-SNAPSHOT.oar b/xos/synchronizer/onos-ext-volt-event-publisher-1.0-SNAPSHOT.oar
deleted file mode 100644
index 244f589..0000000
--- a/xos/synchronizer/onos-ext-volt-event-publisher-1.0-SNAPSHOT.oar
+++ /dev/null
Binary files differ
diff --git a/xos/synchronizer/scripts/dockerip.sh b/xos/synchronizer/steps/helpers.py
similarity index 68%
rename from xos/synchronizer/scripts/dockerip.sh
rename to xos/synchronizer/steps/helpers.py
index 3618fa6..83b9458 100644
--- a/xos/synchronizer/scripts/dockerip.sh
+++ b/xos/synchronizer/steps/helpers.py
@@ -1,4 +1,3 @@
-
# Copyright 2017-present Open Networking Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
@@ -13,13 +12,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from synchronizers.new_base.modelaccessor import Service
-#!/bin/bash
-
-MODE=`docker inspect --format '{{ .HostConfig.NetworkMode }}' $1 | tr -d '\n' | tr -d '\r'`
-if [[ "$MODE" == "host" ]]; then
- echo -n "127.0.0.1"
-else
- docker inspect --format '{{ .NetworkSettings.IPAddress }}' $1 | tr -d '\n' | tr -d '\r'
-fi
-
+class Helpers():
+ @staticmethod
+ def format_url(url):
+ if 'http' in url:
+ return url
+ else:
+ return 'http://%s' % url
\ No newline at end of file
diff --git a/xos/synchronizer/steps/sync_onos_app.py b/xos/synchronizer/steps/sync_onos_app.py
new file mode 100644
index 0000000..6288243
--- /dev/null
+++ b/xos/synchronizer/steps/sync_onos_app.py
@@ -0,0 +1,230 @@
+
+# Copyright 2017-present Open Networking Foundation
+#
+# 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.
+
+import json
+import requests
+from requests.auth import HTTPBasicAuth
+from synchronizers.new_base.syncstep import SyncStep, DeferredException, model_accessor
+from synchronizers.new_base.modelaccessor import ONOSApp, ServiceInstance, ServiceInstanceAttribute
+
+from xosconfig import Config
+from multistructlog import create_logger
+
+from helpers import Helpers
+
+log = create_logger(Config().get('logging'))
+log.info("config file", file=Config().get_config_file())
+
+class SyncONOSApp(SyncStep):
+ provides = [ONOSApp]
+ observes = [ONOSApp, ServiceInstanceAttribute]
+
+ def get_service_instance_attribute(self, o):
+ # NOTE this method is defined in the core convenience methods for service_instances
+ svc = ServiceInstance.objects.get(id=o.id)
+ return svc.serviceinstanceattribute_dict
+
+ def check_app_dependencies(self, deps):
+ """
+ Check if all the dependencies required by this application are installed
+ :param deps: comma separated list of application names
+ :return: bool
+ """
+ for dep in [x.strip() for x in str(deps).split(',') if x is not ""]:
+ try:
+ app = ONOSApp.objects.get(app_id=dep)
+ if not app.backend_code == 1:
+ # backend_code == 1 means that the app has been pushed
+ return False
+ except IndexError, e:
+ return False
+ return True
+
+ def add_config(self, o):
+ log.info("Adding config %s" % o.name, model=o.tologdict())
+ # getting onos url and auth
+ onos_url = "%s:%s" % (Helpers.format_url(o.service_instance.leaf_model.owner.leaf_model.rest_hostname), o.service_instance.leaf_model.owner.leaf_model.rest_port)
+ onos_basic_auth = HTTPBasicAuth(o.service_instance.leaf_model.owner.leaf_model.rest_username, o.service_instance.leaf_model.owner.leaf_model.rest_password)
+
+ # push configs (if any)
+ url = o.name
+ if url[0] == "/":
+ # strip initial /
+ url = url[1:]
+
+ url = '%s/%s' % (onos_url, url)
+ value = json.loads(o.value)
+ request = requests.post(url, json=value, auth=onos_basic_auth)
+
+ if request.status_code != 200:
+ log.error("Request failed", response=request.text)
+ raise Exception("Failed to add config %s in ONOS: %s" % (url, request.text))
+
+ def activate_app(self, o, onos_url, onos_basic_auth):
+ log.info("Activating app %s" % o.app_id)
+ url = '%s/onos/v1/applications/%s/active' % (onos_url, o.app_id)
+ request = requests.post(url, auth=onos_basic_auth)
+
+ if request.status_code != 200:
+ log.error("Request failed", response=request.text)
+ raise Exception("Failed to add application %s to ONOS: %s" % (url, request.text))
+
+ url = '%s/onos/v1/applications/%s' % (onos_url, o.app_id)
+ request = requests.get(url, auth=onos_basic_auth)
+
+ if request.status_code != 200:
+ log.error("Request failed", response=request.text)
+ raise Exception("Failed to read application %s from ONOS: %s" % (url, request.text))
+ else:
+ o.version = request.json()["version"]
+
+ def check_app_installed(self, o, onos_url, onos_basic_auth):
+ url = '%s/onos/v1/applications/%s' % (onos_url, o.app_id)
+ request = requests.get(url, auth=onos_basic_auth)
+
+ if request.status_code == 200:
+ if "version" in request.json() and o.version == request.json()["version"]:
+ return True
+ else:
+ # uninstall the application
+ self.uninstall_app(o, onos_url, onos_basic_auth)
+ return False
+ if request.status_code == 404:
+ # app is not installed at all
+ return False
+ else:
+ log.error("Request failed", response=request.text)
+ raise Exception("Failed to read application %s from ONOS: %s" % (url, request.text))
+
+ def install_app(self, o, onos_url, onos_basic_auth):
+ log.info("Installing app from url %s" % o.url)
+
+ # check is the already installed app is the correct version (if it has no app_id is not installed)
+ is_installed = False
+ if o.app_id and o.app_id is not None:
+ is_installed = self.check_app_installed(o, onos_url, onos_basic_auth)
+
+ if is_installed:
+ # if the app is already installed we don't need to do anything
+ return
+
+ if not o.version or o.version is None:
+ # TODO move this validation in the model.py (if the url is there version must there and app_id must not)
+ raise Exception('You need to specify a version')
+ data = {
+ 'activate': True,
+ 'url': o.url
+ }
+ url = '%s/onos/v1/applications' % onos_url
+ request = requests.post(url, json=data, auth=onos_basic_auth)
+
+ if request.status_code != 200:
+ log.error("Request failed", response=request.text)
+ raise Exception("Failed to add application %s to ONOS: %s" % (url, request.text))
+
+ o.app_id = request.json()["name"]
+
+ url = '%s/onos/v1/applications/%s' % (onos_url, o.app_id)
+ request = requests.get(url, auth=onos_basic_auth)
+
+ if request.status_code != 200:
+ log.error("Request failed", response=request.text)
+ raise Exception("Failed to read application %s from ONOS: %s" % (url, request.text))
+ else:
+ if o.version != request.json()["version"]:
+ raise Exception("The version of %s you installed (%s) is not the same you requested (%s)" % (o.app_id, request.json()["version"], o.version))
+
+ def sync_record(self, o):
+ log.info("Sync'ing", model=o.tologdict())
+ if hasattr(o, 'service_instance'):
+ # this is a ServiceInstanceAttribute model just push the config
+ if 'ONOSApp' in o.service_instance.leaf_model.class_names:
+ return self.add_config(o)
+ return # if it's not an ONOSApp do nothing
+
+ if not self.check_app_dependencies(o.dependencies):
+ raise DeferredException('Deferring installation of ONOSApp with id %s as dependencies are not met' % o.id)
+
+ # getting onos url and auth
+ onos_url = "%s:%s" % (Helpers.format_url(o.owner.leaf_model.rest_hostname), o.owner.leaf_model.rest_port)
+ onos_basic_auth = HTTPBasicAuth(o.owner.leaf_model.rest_username, o.owner.leaf_model.rest_password)
+
+ # activate app (bundled in onos)
+ if not o.url or o.url is None:
+ self.activate_app(o, onos_url, onos_basic_auth)
+ # install an app from a remote source
+ if o.url and o.url is not None:
+ self.install_app(o, onos_url, onos_basic_auth)
+
+ def delete_config(self, o):
+ log.info("Deleting config %s" % o.name)
+ # getting onos url and auth
+ onos_app = o.service_instance.leaf_model
+ onos_url = "%s:%s" % (Helpers.format_url(onos_app.owner.leaf_model.rest_hostname), onos_app.owner.leaf_model.rest_port)
+ onos_basic_auth = HTTPBasicAuth(onos_app.owner.leaf_model.rest_username, onos_app.owner.leaf_model.rest_password)
+
+ url = o.name
+ if url[0] == "/":
+ # strip initial /
+ url = url[1:]
+
+ url = '%s/%s' % (onos_url, url)
+ request = requests.delete(url, auth=onos_basic_auth)
+
+ if request.status_code != 204:
+ log.error("Request failed", response=request.text)
+ raise Exception("Failed to remove config %s from ONOS: %s" % (url, request.text))
+
+ def uninstall_app(self,o, onos_url, onos_basic_auth):
+ log.info("Uninstalling app %s" % o.app_id)
+ url = '%s/onos/v1/applications/%s' % (onos_url, o.app_id)
+
+ request = requests.delete(url, auth=onos_basic_auth)
+
+ if request.status_code != 204:
+ log.error("Request failed", response=request.text)
+ raise Exception("Failed to delete application %s from ONOS: %s" % (url, request.text))
+
+ def deactivate_app(self, o, onos_url, onos_basic_auth):
+ log.info("Deactivating app %s" % o.app_id)
+ url = '%s/onos/v1/applications/%s/active' % (onos_url, o.app_id)
+
+ request = requests.delete(url, auth=onos_basic_auth)
+
+ if request.status_code != 204:
+ log.error("Request failed", response=request.text)
+ raise Exception("Failed to deactivate application %s from ONOS: %s" % (url, request.text))
+
+ def delete_record(self, o):
+
+ if hasattr(o, 'service_instance'):
+ # this is a ServiceInstanceAttribute model
+ if 'ONOSApp' in o.service_instance.leaf_model.class_names:
+ return self.delete_config(o)
+ return # if it's not related to an ONOSApp do nothing
+
+ # NOTE if it is an ONOSApp we don't care about the ServiceInstanceAttribute
+ # as the reaper will delete it
+
+ # getting onos url and auth
+ onos_url = "%s:%s" % (Helpers.format_url(o.owner.leaf_model.rest_hostname), o.owner.leaf_model.rest_port)
+ onos_basic_auth = HTTPBasicAuth(o.owner.leaf_model.rest_username, o.owner.leaf_model.rest_password)
+
+ # deactivate an app (bundled in onos)
+ if not o.url or o.url is None:
+ self.deactivate_app(o, onos_url, onos_basic_auth)
+ # uninstall an app from a remote source, only if it has been activated before
+ if o.url and o.url is not None:
+ self.uninstall_app(o, onos_url, onos_basic_auth)
diff --git a/xos/synchronizer/steps/sync_onos_service.py b/xos/synchronizer/steps/sync_onos_service.py
new file mode 100644
index 0000000..010fc33
--- /dev/null
+++ b/xos/synchronizer/steps/sync_onos_service.py
@@ -0,0 +1,89 @@
+
+# Copyright 2017-present Open Networking Foundation
+#
+# 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.
+
+import json
+import requests
+from requests.auth import HTTPBasicAuth
+from synchronizers.new_base.syncstep import SyncStep, model_accessor
+from synchronizers.new_base.modelaccessor import ONOSService, Service, ServiceAttribute
+
+from xosconfig import Config
+from multistructlog import create_logger
+
+from helpers import Helpers
+
+log = create_logger(Config().get('logging'))
+
+class SyncONOSService(SyncStep):
+ provides = [ONOSService]
+ observes = [ONOSService, ServiceAttribute]
+
+ def get_service_attribute(self, o):
+ # NOTE this method is defined in the core convenience methods for services
+ svc = Service.objects.get(id=o.id)
+ return svc.serviceattribute_dict
+
+ def sync_record(self, o):
+ if hasattr(o, 'service'):
+ # this is a ServiceAttribute model
+ if 'ONOSService' in o.service.leaf_model.class_names:
+ print "sync ONOSService Attribute", o.service.leaf_model
+ return self.sync_record(o.service.leaf_model)
+ return # if it's not related to an ONOSService do nothing
+
+ onos_url = "%s:%s" % (Helpers.format_url(o.rest_hostname), o.rest_port)
+ onos_basic_auth = HTTPBasicAuth(o.rest_username, o.rest_password)
+
+ configs = self.get_service_attribute(o)
+ for url, value in configs.iteritems():
+
+ if url[0] == "/":
+ # strip initial /
+ url = url[1:]
+
+ url = '%s/%s' % (onos_url, url)
+ value = json.loads(value)
+ request = requests.post(url, json=value, auth=onos_basic_auth)
+
+ if request.status_code != 200:
+ log.error("Request failed", response=request.text)
+ raise Exception("Failed to add config %s in ONOS" % url)
+
+ def delete_record(self, o):
+
+ if hasattr(o, 'service'):
+ # this is a ServiceAttribute model
+ if 'ONOSService' in o.service.leaf_model.class_names:
+ print "sync ONOSService Attribute", o.service.leaf_model
+
+ log.info("Deleting config %s" % o.name)
+ # getting onos url and auth
+ onos_service = o.service.leaf_model
+ onos_url = "%s:%s" % (
+ Helpers.format_url(onos_service.rest_hostname), onos_service.rest_port)
+ onos_basic_auth = HTTPBasicAuth(onos_service.rest_username,
+ onos_service.rest_password)
+
+ url = o.name
+ if url[0] == "/":
+ # strip initial /
+ url = url[1:]
+
+ url = '%s/%s' % (onos_url, url)
+ request = requests.delete(url, auth=onos_basic_auth)
+
+ if request.status_code != 204:
+ log.error("Request failed", response=request.text)
+ raise Exception("Failed to remove config %s from ONOS: %s" % (url, request.text))
diff --git a/xos/synchronizer/steps/sync_onosapp.py b/xos/synchronizer/steps/sync_onosapp.py
deleted file mode 100644
index 759dc67..0000000
--- a/xos/synchronizer/steps/sync_onosapp.py
+++ /dev/null
@@ -1,255 +0,0 @@
-
-# Copyright 2017-present Open Networking Foundation
-#
-# 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.
-
-
-import hashlib
-import os
-import socket
-import sys
-import base64
-import time
-import re
-import json
-from collections import OrderedDict
-from xosconfig import Config
-from synchronizers.new_base.syncstep import DeferredException
-from synchronizers.new_base.ansible_helper import run_template
-from synchronizers.new_base.SyncInstanceUsingAnsible import SyncInstanceUsingAnsible
-from synchronizers.new_base.modelaccessor import *
-from xos.logger import Logger, logging
-
-logger = Logger(level=logging.INFO)
-
-
-class SyncONOSApp(SyncInstanceUsingAnsible):
- provides=[ONOSApp]
- observes=ONOSApp
- requested_interval=0
- template_name = "sync_onosapp.yaml"
-
- def __init__(self, *args, **kwargs):
- super(SyncONOSApp, self).__init__(*args, **kwargs)
-
- def get_instance(self, o):
- # We assume the ONOS service owns a slice, so pick one of the instances
- # inside that slice to sync to.
-
- serv = self.get_onos_service(o)
-
- if serv.no_container:
- raise Exception("get_instance() was called on a service that was marked no_container")
-
- if serv.slices.exists():
- slice = serv.slices.all()[0]
- if slice.instances.exists():
- return slice.instances.all()[0]
-
- return None
-
- def get_onos_service(self, o):
- return o.owner.leaf_model
-
- def is_no_container(self, o):
- return self.get_onos_service(o).no_container
-
- def skip_ansible_fields(self, o):
- return self.is_no_container(o)
-
- def get_files_dir(self, o):
- step_dir = Config.get("steps_dir")
-
- return os.path.join(step_dir, "..", "files", str(self.get_onos_service(o).id), o.name)
-
- def get_cluster_configuration(self, o):
- instance = self.get_instance(o)
- if not instance:
- raise Exception("No instance for ONOS App")
- node_ips = [socket.gethostbyname(instance.node.name)]
-
- ipPrefix = ".".join(node_ips[0].split(".")[:3]) + ".*"
- result = '{ "nodes": ['
- result = result + ",".join(['{ "ip": "%s"}' % ip for ip in node_ips])
- result = result + '], "ipPrefix": "%s"}' % ipPrefix
- return result
-
- def get_dynamic_parameter_value(self, o, param):
- instance = self.get_instance(o)
- if not instance:
- raise Exception("No instance for ONOS App")
- if param == 'rabbit_host':
- return instance.controller.rabbit_host
- if param == 'rabbit_user':
- return instance.controller.rabbit_user
- if param == 'rabbit_password':
- return instance.controller.rabbit_password
- if param == 'keystone_tenant_id':
- cslice = ControllerSlice.objects.get(slice=instance.slice)
- if not cslice:
- raise Exception("Controller slice object for %s does not exist" % instance.slice.name)
- return cslice.tenant_id
- if param == 'keystone_user_id':
- cuser = ControllerUser.objects.get(user=instance.creator)
- if not cuser:
- raise Exception("Controller user object for %s does not exist" % instance.creator)
- return cuser.kuser_id
-
- def write_configs(self, o):
- if hasattr(o, "create_attr"):
- # new API doesn't let us setattr for things that don't already exist
- o.create_attr("config_fns")
- o.create_attr("rest_configs")
- o.create_attr("component_configs")
- o.create_attr("files_dir")
- o.create_attr("node_key_fn")
- o.create_attr("early_rest_configs")
-
- o.config_fns = []
- o.rest_configs = []
- o.component_configs = []
- o.files_dir = self.get_files_dir(o)
-
- if not os.path.exists(o.files_dir):
- os.makedirs(o.files_dir)
-
- # Combine the service attributes with the tenant attributes. Tenant
- # attribute can override service attributes.
- attrs = o.owner.serviceattribute_dict
- attrs.update(o.tenantattribute_dict)
-
- # Check to see if we're waiting on autoconfig
- if (attrs.get("autogenerate") in ["vtn-network-cfg",]) and \
- (not attrs.get("rest_onos/v1/network/configuration/")):
- raise DeferredException("Network configuration is not populated yet")
-
- ordered_attrs = attrs.keys()
-
- onos = self.get_onos_service(o)
- if onos.node_key:
- file(os.path.join(o.files_dir, "node_key"),"w").write(onos.node_key)
- o.node_key_fn="node_key"
- else:
- o.node_key_fn=None
-
- o.early_rest_configs=[]
- if ("cordvtn" in o.dependencies) and (not self.is_no_container(o)):
- # For VTN, since it's running in a docker host container, we need
- # to make sure it configures the cluster using the right ip addresses.
- # NOTE: rest_onos/v1/cluster/configuration/ will reboot the cluster and
- # must go first.
- name="rest_onos/v1/cluster/configuration/"
- value= self.get_cluster_configuration(o)
- fn = name[5:].replace("/","_")
- endpoint = name[5:]
- file(os.path.join(o.files_dir, fn),"w").write(" " +value)
- o.early_rest_configs.append( {"endpoint": endpoint, "fn": fn} )
-
- for name in attrs.keys():
- value = attrs[name]
- if name.startswith("config_"):
- fn = name[7:] # .replace("_json",".json")
- o.config_fns.append(fn)
- file(os.path.join(o.files_dir, fn),"w").write(value)
- if name.startswith("rest_"):
- fn = name[5:].replace("/","_")
- endpoint = name[5:]
- # Ansible goes out of it's way to make our life difficult. If
- # 'lookup' sees a file that it thinks contains json, then it'll
- # insist on parsing and return a json object. We just want
- # a string, so prepend a space and then strip the space off
- # later.
- file(os.path.join(o.files_dir, fn),"w").write(" " +value)
- o.rest_configs.append( {"endpoint": endpoint, "fn": fn} )
- if name.startswith("component_config"):
- components = json.loads(value,object_pairs_hook=OrderedDict)
- for component in components.keys():
- config = components[component]
- for key in config.keys():
- config_val = config[key]
- found = re.findall('<(.+?)>',config_val)
- for x in found:
- #Get value corresponding to that string
- val = self.get_dynamic_parameter_value(o, x)
- if val:
- config_val = re.sub('<'+x+'>', val, config_val)
- #TODO: else raise an exception?
- o.component_configs.append( {"component": component, "config_params": "'{\""+key+"\":\""+config_val+"\"}'"} )
-
- def prepare_record(self, o):
- self.write_configs(o)
-
- def get_extra_attributes_common(self, o):
- fields = {}
-
- # These are attributes that are not dependent on Instance. For example,
- # REST API stuff.
-
- onos = self.get_onos_service(o)
-
- fields["files_dir"] = o.files_dir
- fields["appname"] = o.name
- fields["rest_configs"] = o.rest_configs
- fields["component_configs"] = o.component_configs
- fields["rest_hostname"] = onos.rest_hostname
- fields["rest_username"] = onos.rest_username
- fields["rest_password"] = onos.rest_password
- fields["rest_port"] = onos.rest_port
-
- if o.dependencies:
- fields["dependencies"] = [x.strip() for x in o.dependencies.split(",")]
- else:
- fields["dependencies"] = []
-
- if o.install_dependencies:
- fields["install_dependencies"] = [x.strip() for x in o.install_dependencies.split(",")]
- else:
- fields["install_dependencies"] = []
-
- return fields
-
- def get_extra_attributes_full(self, o):
- instance = self.get_instance(o)
-
- fields = self.get_extra_attributes_common(o)
-
- fields["config_fns"] = o.config_fns
- fields["early_rest_configs"] = o.early_rest_configs
- fields["node_key_fn"] = o.node_key_fn
-
- if (instance.isolation=="container"):
- fields["ONOS_container"] = "%s-%s" % (instance.slice.name, str(instance.id))
- else:
- fields["ONOS_container"] = "ONOS"
- return fields
-
- def get_extra_attributes(self, o):
- if self.is_no_container(o):
- return self.get_extra_attributes_common(o)
- else:
- return self.get_extra_attributes_full(o)
-
- def sync_fields(self, o, fields):
- # the super causes the playbook to be run
- super(SyncONOSApp, self).sync_fields(o, fields)
-
- def run_playbook(self, o, fields):
- if self.is_no_container(o):
- # There is no machine to SSH to, so use the synchronizer's
- # run_template method directly.
- run_template("sync_onosapp_nocontainer.yaml", fields, object=o)
- else:
- super(SyncONOSApp, self).run_playbook(o, fields)
-
- def delete_record(self, m):
- pass
diff --git a/xos/synchronizer/steps/sync_onosapp.yaml b/xos/synchronizer/steps/sync_onosapp.yaml
deleted file mode 100644
index e732814..0000000
--- a/xos/synchronizer/steps/sync_onosapp.yaml
+++ /dev/null
@@ -1,188 +0,0 @@
-
-# Copyright 2017-present Open Networking Foundation
-#
-# 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.
-
-
----
-- hosts: {{ instance_name }}
- gather_facts: False
- connection: ssh
- user: {{ username }}
- become: yes
- vars:
- appname: {{ appname }}
- dependencies: {{ dependencies }}
-{% if component_configs %}
- component_configs:
-{% for component_config in component_configs %}
- - component: {{ component_config.component }}
- config_params: {{ component_config.config_params }}
-{% endfor %}
-{% endif %}
-{% if rest_configs %}
- var_rest_configs:
-{% for rest_config in rest_configs %}
- - endpoint: {{ rest_config.endpoint }}
- body: "{{ '{{' }} lookup('file', '{{ files_dir }}/{{ rest_config.fn }}') {{ '}}' }}"
-{% endfor %}
-{% endif %}
-{% if early_rest_configs %}
- var_early_rest_configs:
-{% for early_rest_config in early_rest_configs %}
- - endpoint: {{ early_rest_config.endpoint }}
- body: "{{ '{{' }} lookup('file', '{{ files_dir }}/{{ early_rest_config.fn }}') {{ '}}' }}"
-{% endfor %}
-{% endif %}
-
- tasks:
-
- - name: Get Docker IP
- script: /opt/xos/synchronizers/onos/scripts/dockerip.sh {{ ONOS_container }}
- register: onosaddr
-
- - name: Wait for ONOS to come up
- wait_for:
- host={{ '{{' }} onosaddr.stdout {{ '}}' }}
- port={{ '{{' }} item {{ '}}' }}
- state=present
- with_items:
- - 8101
- - 8181
- - 9876
-
- - name: Config file directory
- file:
- path=/home/ubuntu/{{ appname }}/
- state=directory
-
-{% if node_key_fn %}
- - name: Copy over key
- copy:
- src={{ files_dir }}/{{ node_key_fn }}
- dest=/home/ubuntu/node_key
-
- - name: Copy node key into container
- shell: docker cp /home/ubuntu/node_key {{ ONOS_container }}:/root/node_key
-{% endif %}
-
-{% if config_fns %}
- - name: Copy over configuration files
- copy:
- src={{ files_dir }}/{{ '{{' }} item {{ '}}' }}
- dest=/home/ubuntu/{{ appname }}/{{ '{{' }} item {{ '}}' }}
- with_items:
- {% for config_fn in config_fns %}
- - {{ config_fn }}
- {% endfor %}
-
- - name: Make sure config directory exists
- shell: docker exec {{ ONOS_container }} mkdir -p /root/onos/config/
- sudo: yes
-
- - name: Copy config files into container
- shell: docker cp {{ appname }}/{{ '{{' }} item {{ '}}' }} {{ ONOS_container }}:/root/onos/config/
- sudo: yes
- with_items:
- {% for config_fn in config_fns %}
- - {{ config_fn }}
- {% endfor %}
-{% endif %}
-
- # Don't know how to check for this condition, just wait
- - name: Wait for ONOS to install the apps
- wait_for: timeout=15
-
-{% if early_rest_configs %}
- - name: Add ONOS early configuration values
- uri:
- url: http://{{ '{{' }} onosaddr.stdout {{ '}}' }}:8181/{{ '{{' }} item.endpoint {{ '}}' }}
- body: "{{ '{{' }} item.body {{ '}}' }}"
- body_format: json
- method: POST
- user: karaf
- password: karaf
- with_items: "var_early_rest_configs"
-
- # Don't know how to check for this condition, just wait
- - name: Wait for ONOS to restart
- wait_for: timeout=15
-{% endif %}
-
-{% if install_dependencies %}
- - name: Install app file directory
- file:
- path=/home/ubuntu/{{ appname }}/apps/
- state=directory
-
- - name: Copy over app install files to ONOS host
- copy:
- src=/opt/xos/synchronizers/onos/{{ '{{' }} item {{ '}}' }}
- dest=/home/ubuntu/{{ appname }}/apps/{{ '{{' }} item {{ '}}' }}
- with_items:
- {% for install_app in install_dependencies %}
- - {{ install_app }}
- {% endfor %}
-
- - name: POST onos-app install command
- command: >
- curl -XPOST -HContent-Type:application/octet-stream -u karaf:karaf --data-binary @/home/ubuntu/{{ appname }}/apps/{{ '{{' }} item {{ '}}' }} http://{{ '{{' }} onosaddr.stdout {{ '}}' }}:8181/onos/v1/applications
- with_items:
- {% for dependency in install_dependencies %}
- - {{ dependency }}
- {% endfor %}
-{% endif %}
-
-{% if dependencies %}
- - name: Add dependencies to ONOS
- uri:
- url: http://{{ '{{' }} onosaddr.stdout {{ '}}' }}:8181/onos/v1/applications/{{ '{{' }} item {{ '}}' }}/active
- method: POST
- user: karaf
- password: karaf
- with_items:
- {% for dependency in dependencies %}
- - {{ dependency }}
- {% endfor %}
-{% endif %}
-
-{% if component_configs %}
- - name: Add ONOS component configuration values
- command: >
- curl -XPOST -HContent-Type:application/json -u karaf:karaf -d {{ '{{' }} item.config_params | to_json {{ '}}' }} http://{{ '{{' }} onosaddr.stdout {{ '}}' }}:8181/onos/v1/configuration/{{
- '{{' }} item.component {{ '}}' }}
- with_items: "component_configs"
-
-# uri:
-# url: http://{{ '{{' }} onosaddr.stdout {{ '}}' }}:8181/onos/v1/configuration/{{ '{{' }} item.component {{ '}}' }} #http://localhost:8181/onos/v1/configuration/
-# body: "{{ '{{' }} item.config_params | to_json {{ '}}' }}"
-# body_format: json
-# method: POST
-# user: karaf
-# password: karaf
-# with_items: "component_configs"
-{% endif %}
-
-{% if rest_configs %}
-# Do this after services have been activated, or it will cause an exception.
-# vOLT will re-read its net config; vbng may not.
- - name: Add ONOS configuration values
- uri:
- url: http://{{ '{{' }} onosaddr.stdout {{ '}}' }}:8181/{{ '{{' }} item.endpoint {{ '}}' }} #http://localhost:8181/onos/v1/network/configuration/
- body: "{{ '{{' }} item.body {{ '}}' }}"
- body_format: json
- method: POST
- user: karaf
- password: karaf
- with_items: "var_rest_configs"
-{% endif %}
diff --git a/xos/synchronizer/steps/sync_onosapp_nocontainer.yaml b/xos/synchronizer/steps/sync_onosapp_nocontainer.yaml
deleted file mode 100644
index 3c572d7..0000000
--- a/xos/synchronizer/steps/sync_onosapp_nocontainer.yaml
+++ /dev/null
@@ -1,97 +0,0 @@
-
-# Copyright 2017-present Open Networking Foundation
-#
-# 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.
-
-
----
-- hosts: 127.0.0.1
- connection: local
- vars:
- appname: {{ appname }}
- dependencies: {{ dependencies }}
-{% if component_configs %}
- component_configs:
-{% for component_config in component_configs %}
- - component: {{ component_config.component }}
- config_params: {{ component_config.config_params }}
-{% endfor %}
-{% endif %}
-{% if rest_configs %}
- var_rest_configs:
-{% for rest_config in rest_configs %}
- - endpoint: {{ rest_config.endpoint }}
- body: "{{ '{{' }} lookup('file', '{{ files_dir }}/{{ rest_config.fn }}') {{ '}}' }}"
-{% endfor %}
-{% endif %}
- rest_hostname: {{ rest_hostname }}
- rest_port: {{ rest_port }}
-
- tasks:
-{% if install_dependencies %}
- - name: Install ONOS apps by URL
- uri:
- url: "http://{{ rest_hostname }}:{{ rest_port }}/{{ '{{' }} item.endpoint {{ '}}' }}"
- body: "{{ '{{' }} item.body | to_json {{ '}}' }}"
- user: {{ rest_username }}
- password: {{ rest_password }}
- body_format: json
- method: POST
- status_code: 200, 409
- with_items:
- {% for install_dependency in install_dependencies %}
- - endpoint: "onos/v1/applications"
- body:
- url: "{{ install_dependency }}"
- activate: "true"
- {% endfor %}
-{% endif %}
-
-{% if dependencies %}
- - name: Add dependencies to ONOS
- uri:
- url: http://{{ rest_hostname }}:{{ rest_port }}/onos/v1/applications/{{ '{{' }} item {{ '}}' }}/active
- method: POST
- user: karaf
- password: karaf
- with_items:
- {% for dependency in dependencies %}
- - {{ dependency }}
- {% endfor %}
-{% endif %}
-
-{% if component_configs %}
- - name: Add ONOS component configuration values
- uri:
- url: http://{{ rest_hostname }}:{{ rest_port }}/onos/v1/configuration/{{ '{{' }} item.component {{ '}}' }} #http://localhost:8181/onos/v1/configuration/
- body: "{{ '{{' }} item.config_params {{ '}}' }}"
- body_format: json
- method: POST
- user: karaf
- password: karaf
- with_items: "{{ '{{' }} component_configs {{ '}}' }}"
-{% endif %}
-
-{% if rest_configs %}
-# Do this after services have been activated, or it will cause an exception.
-# vOLT will re-read its net config; vbng may not.
- - name: Add ONOS configuration values
- uri:
- url: http://{{ rest_hostname }}:{{ rest_port }}/{{ '{{' }} item.endpoint {{ '}}' }} #http://localhost:8181/onos/v1/network/configuration/
- body: "{{ '{{' }} item.body {{ '}}' }}"
- body_format: json
- method: POST
- user: karaf
- password: karaf
- with_items: "{{ '{{' }} var_rest_configs {{ '}}' }}"
-{% endif %}
diff --git a/xos/synchronizer/steps/sync_onosservice.py b/xos/synchronizer/steps/sync_onosservice.py
deleted file mode 100644
index 35b34b2..0000000
--- a/xos/synchronizer/steps/sync_onosservice.py
+++ /dev/null
@@ -1,78 +0,0 @@
-
-# Copyright 2017-present Open Networking Foundation
-#
-# 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.
-
-
-import hashlib
-import os
-import socket
-import sys
-import base64
-import time
-from synchronizers.new_base.SyncInstanceUsingAnsible import SyncInstanceUsingAnsible
-from synchronizers.new_base.modelaccessor import *
-from xos.logger import Logger, logging
-
-logger = Logger(level=logging.INFO)
-
-class SyncONOSService(SyncInstanceUsingAnsible):
- provides=[ONOSService]
- observes=ONOSService
- requested_interval=0
- template_name = "sync_onosservice.yaml"
-
- def __init__(self, *args, **kwargs):
- super(SyncONOSService, self).__init__(*args, **kwargs)
-
- def get_instance(self, o):
- # We assume the ONOS service owns a slice, so pick one of the instances
- # inside that slice to sync to.
-
- serv = o
-
- if serv.slices.exists():
- slice = serv.slices.all()[0]
- if slice.instances.exists():
- return slice.instances.all()[0]
-
- return None
-
- def get_extra_attributes(self, o):
- fields={}
- fields["instance_hostname"] = self.get_instance(o).instance_name.replace("_","-")
- fields["appname"] = o.name
- fields["ONOS_container"] = "ONOS"
- return fields
-
- def sync_record(self, o):
- if o.no_container:
- logger.info("no work to do for onos service, because o.no_container is set",extra=o.tologdict())
- o.save()
- else:
- super(SyncONOSService, self).sync_record(o)
-
- def sync_fields(self, o, fields):
- # the super causes the playbook to be run
- super(SyncONOSService, self).sync_fields(o, fields)
-
- def run_playbook(self, o, fields):
- instance = self.get_instance(o)
- if (instance.isolation=="container"):
- # If the instance is already a container, then we don't need to
- # install ONOS.
- return
- super(SyncONOSService, self).run_playbook(o, fields)
-
- def delete_record(self, m):
- pass
diff --git a/xos/synchronizer/steps/sync_onosservice.yaml b/xos/synchronizer/steps/sync_onosservice.yaml
deleted file mode 100644
index 9a098a0..0000000
--- a/xos/synchronizer/steps/sync_onosservice.yaml
+++ /dev/null
@@ -1,82 +0,0 @@
-
-# Copyright 2017-present Open Networking Foundation
-#
-# 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.
-
-
----
-- hosts: {{ instance_name }}
- gather_facts: False
- connection: ssh
- user: ubuntu
- become: yes
-
- tasks:
-
- - name: Fix /etc/hosts
- lineinfile:
- dest=/etc/hosts
- regexp="127.0.0.1 localhost"
- line="127.0.0.1 localhost {{ instance_hostname }}"
-
- - name: Add repo key
- apt_key:
- keyserver=hkp://pgp.mit.edu:80
- id=58118E89F3A912897C070ADBF76221572C52609D
-
- - name: Install Docker repo
- apt_repository:
- repo="deb https://apt.dockerproject.org/repo ubuntu-trusty main"
- state=present
-
- - name: Install Docker
- apt:
- name={{ '{{' }} item {{ '}}' }}
- state=latest
- update_cache=yes
- with_items:
- - docker-engine
- - python-pip
- - python-httplib2
-
- - name: Install docker-py
- pip:
- name=docker-py
- state=latest
-
- - name: Start ONOS container
- docker:
- docker_api_version: "1.18"
- name: {{ ONOS_container }}
- # was: reloaded
- state: running
- image: onosproject/onos
- ports:
- - "6653:6653"
- - "8101:8101"
- - "8181:8181"
- - "9876:9876"
-
- - name: Get Docker IP
- script: /opt/xos/synchronizers/onos/scripts/dockerip.sh {{ ONOS_container }}
- register: dockerip
-
- - name: Wait for ONOS to come up
- wait_for:
- host={{ '{{' }} dockerip.stdout {{ '}}' }}
- port={{ '{{' }} item {{ '}}' }}
- state=present
- with_items:
- - 8101
- - 8181
- - 9876
diff --git a/xos/synchronizer/steps/test_sync_onos_app.py b/xos/synchronizer/steps/test_sync_onos_app.py
new file mode 100644
index 0000000..4d64783
--- /dev/null
+++ b/xos/synchronizer/steps/test_sync_onos_app.py
@@ -0,0 +1,343 @@
+# Copyright 2017-present Open Networking Foundation
+#
+# 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.
+
+import unittest
+import json
+import functools
+from mock import patch, call, Mock, PropertyMock
+import requests_mock
+
+import os, sys
+
+# Hack to load synchronizer framework
+test_path=os.path.abspath(os.path.dirname(os.path.realpath(__file__)))
+xos_dir=os.path.join(test_path, "../../..")
+if not os.path.exists(os.path.join(test_path, "new_base")):
+ xos_dir=os.path.join(test_path, "../../../../../../orchestration/xos/xos")
+ services_dir = os.path.join(xos_dir, "../../xos_services")
+sys.path.append(xos_dir)
+sys.path.append(os.path.join(xos_dir, 'synchronizers', 'new_base'))
+# END Hack to load synchronizer framework
+
+# generate model from xproto
+def get_models_fn(service_name, xproto_name):
+ name = os.path.join(service_name, "xos", xproto_name)
+ if os.path.exists(os.path.join(services_dir, name)):
+ return name
+ else:
+ name = os.path.join(service_name, "xos", "synchronizer", "models", xproto_name)
+ if os.path.exists(os.path.join(services_dir, name)):
+ return name
+ raise Exception("Unable to find service=%s xproto=%s" % (service_name, xproto_name))
+# END generate model from xproto
+
+def match_none(req):
+ return req.text == None
+
+def match_json(desired, req):
+ if desired!=req.json():
+ raise Exception("Got request %s, but body is not matching" % req.url)
+ return False
+ return True
+
+class TestSyncOnosApp(unittest.TestCase):
+
+ def setUp(self):
+ global DeferredException
+
+ self.sys_path_save = sys.path
+ sys.path.append(xos_dir)
+ sys.path.append(os.path.join(xos_dir, 'synchronizers', 'new_base'))
+
+ # Setting up the config module
+ from xosconfig import Config
+ config = os.path.join(test_path, "../test_config.yaml")
+ Config.clear()
+ Config.init(config, "synchronizer-config-schema.yaml")
+ # END Setting up the config module
+
+ from synchronizers.new_base.mock_modelaccessor_build import build_mock_modelaccessor
+ build_mock_modelaccessor(xos_dir, services_dir, [
+ get_models_fn("onos-service", "onos.xproto")
+ ])
+ import synchronizers.new_base.modelaccessor
+
+ from sync_onos_app import SyncONOSApp, DeferredException, model_accessor
+
+ # import all class names to globals
+ for (k, v) in model_accessor.all_model_classes.items():
+ globals()[k] = v
+
+
+ self.sync_step = SyncONOSApp
+
+ onos = ONOSService()
+ onos.rest_hostname = "onos-url"
+ onos.rest_port = "8181"
+ onos.rest_username = "karaf"
+ onos.rest_password = "karaf"
+
+ self.onos_app = Mock(spec=[
+ 'id',
+ 'name',
+ 'app_id',
+ 'dependencies',
+ 'owner',
+ 'url',
+ 'backend_code',
+ 'version',
+ 'tologdict'
+ ])
+ self.onos_app.id = 1
+ self.onos_app.name = "vrouter"
+ self.onos_app.app_id = "org.onosproject.vrouter"
+ self.onos_app.dependencies = ""
+ self.onos_app.owner.leaf_model = onos
+ self.onos_app.url = None
+ self.onos_app.class_names = "ONOSApp"
+ self.onos_app.tologdict.return_value = ""
+
+ self.si = Mock()
+ self.si.id = 1
+ self.si.leaf_model = self.onos_app
+
+ self.vrouter_app_response = {
+ "name": "org.onosproject.vrouter",
+ "version": "1.13.1",
+ }
+
+ self.onos_app_attribute = Mock(spec=[
+ 'id',
+ 'service_instance',
+ 'name',
+ 'value'
+ ])
+ self.onos_app_attribute.id = 1
+ self.onos_app_attribute.service_instance = self.si
+ self.onos_app_attribute.name = "/onos/v1/network/configuration/apps/org.opencord.olt"
+ self.onos_app_attribute.value = {
+ "kafka" : {
+ "bootstrapServers" : "cord-kafka-kafka.default.svc.cluster.local:9092"
+ }
+ }
+
+ def tearDown(self):
+ self.onos = None
+ sys.path = self.sys_path_save
+
+ @requests_mock.Mocker()
+ def test_defer_app_sync(self, m):
+ self.onos_app.dependencies = "org.onosproject.segmentrouting, org.onosproject.openflow"
+
+ segment_routing = Mock()
+ segment_routing.app_id = "org.onosproject.segmentrouting"
+ segment_routing.backend_code = 1
+
+ openflow = Mock()
+ openflow.app_id = "org.onosproject.openflow"
+ openflow.backend_code = 0
+
+ with patch.object(ONOSApp.objects, "get_items") as app_get, \
+ patch.object(ServiceInstance.objects, "get_items") as mock_si, \
+ self.assertRaises(DeferredException) as e:
+
+ app_get.return_value = [segment_routing, openflow]
+ mock_si.return_value = [self.si]
+ self.sync_step().sync_record(self.onos_app)
+
+ self.assertEqual(e.exception.message, 'Deferring installation of ONOSApp with id 1 as dependencies are not met')
+ self.assertFalse(m.called)
+
+ @requests_mock.Mocker()
+ def test_app_sync_local_app_no_config(self, m):
+ """
+ Activate an application that is already installed in ONOS
+ """
+
+ m.post("http://onos-url:8181/onos/v1/applications/org.onosproject.vrouter/active",
+ status_code=200,
+ additional_matcher=match_none)
+
+ m.get("http://onos-url:8181/onos/v1/applications/org.onosproject.vrouter",
+ status_code=200,
+ json=self.vrouter_app_response)
+
+ self.si.serviceinstanceattribute_dict = {}
+
+ with patch.object(ServiceInstance.objects, "get_items") as mock_si:
+ mock_si.return_value = [self.si]
+ self.sync_step().sync_record(self.onos_app)
+
+ self.assertTrue(m.called)
+ self.assertEqual(m.call_count, 2)
+ self.assertEqual(self.onos_app.version, self.vrouter_app_response["version"])
+
+ @requests_mock.Mocker()
+ def test_app_sync_local_app_with_config(self, m):
+
+ m.post("http://onos-url:8181/onos/v1/applications/org.onosproject.vrouter/active",
+ status_code=200,
+ additional_matcher=match_none)
+
+ m.get("http://onos-url:8181/onos/v1/applications/org.onosproject.vrouter",
+ status_code=200,
+ json=self.vrouter_app_response)
+
+ with patch.object(ServiceInstance.objects, "get_items") as mock_si:
+ mock_si.return_value = [self.si]
+ self.sync_step().sync_record(self.onos_app)
+ self.assertTrue(m.called)
+ self.assertEqual(m.call_count, 2)
+ self.assertEqual(self.onos_app.version, self.vrouter_app_response["version"])
+
+ @requests_mock.Mocker()
+ def test_app_install_remote_app_no_config(self, m):
+ """
+ Install an application that has to be downloaded from a remote source
+ """
+
+ self.onos_app.url = 'http://onf.org/maven/...'
+ self.onos_app.version = "1.13.1"
+ self.onos_app.app_id = None
+
+ expected = {
+ 'activate': True,
+ 'url': self.onos_app.url
+ }
+
+ m.post("/onos/v1/applications",
+ status_code=200,
+ additional_matcher=functools.partial(match_json, expected),
+ json=self.vrouter_app_response)
+
+ m.get("http://onos-url:8181/onos/v1/applications/org.onosproject.vrouter",
+ status_code=200,
+ json=self.vrouter_app_response)
+
+ self.si.serviceinstanceattribute_dict = {}
+
+ with patch.object(ServiceInstance.objects, "get_items") as mock_si:
+ mock_si.return_value = [self.si]
+ self.sync_step().sync_record(self.onos_app)
+ self.assertTrue(m.called)
+ self.assertEqual(m.call_count, 2)
+ self.assertEqual(self.onos_app.app_id, self.vrouter_app_response["name"])
+
+ @requests_mock.Mocker()
+ def test_update_remote_app(self, m):
+ self.onos_app.url = 'http://onf.org/maven/...'
+ self.onos_app.version = "1.14.1"
+
+ expected = {
+ 'activate': True,
+ 'url': self.onos_app.url
+ }
+
+ self.vrouter_app_response_updated = self.vrouter_app_response.copy()
+ self.vrouter_app_response_updated["version"] = "1.14.1"
+
+ m.post("/onos/v1/applications",
+ status_code=200,
+ additional_matcher=functools.partial(match_json, expected),
+ json=self.vrouter_app_response)
+
+
+ m.get("http://onos-url:8181/onos/v1/applications/org.onosproject.vrouter",
+ [
+ {"json": self.vrouter_app_response, "status_code": 200},
+ {"json": self.vrouter_app_response_updated, "status_code": 200}
+ ]
+ )
+
+ m.delete("http://onos-url:8181/onos/v1/applications/org.onosproject.vrouter",
+ status_code=204)
+
+ self.si.serviceinstanceattribute_dict = {}
+
+ with patch.object(ServiceInstance.objects, "get_items") as mock_si:
+ mock_si.return_value = [self.si]
+ self.sync_step().sync_record(self.onos_app)
+ self.assertTrue(m.called)
+ self.assertEqual(m.call_count, 4)
+ self.assertEqual(self.onos_app.app_id, self.vrouter_app_response_updated["name"])
+
+ @requests_mock.Mocker()
+ def test_app_sync_remote_app_no_config_fail_version(self, m):
+ """
+ Activate an application that has to be downloaded from a remote source
+ """
+
+ self.onos_app.url = 'http://onf.org/maven/...'
+ self.onos_app.version = "1.14.2"
+ self.onos_app.app_id = None
+
+ expected = {
+ 'activate': True,
+ 'url': self.onos_app.url
+ }
+
+ m.post("/onos/v1/applications",
+ status_code=200,
+ additional_matcher=functools.partial(match_json, expected),
+ json=self.vrouter_app_response)
+
+ m.get("http://onos-url:8181/onos/v1/applications/org.onosproject.vrouter",
+ status_code=200,
+ json=self.vrouter_app_response)
+
+ self.si.serviceinstanceattribute_dict = {}
+
+ with patch.object(ServiceInstance.objects, "get_items") as mock_si, \
+ self.assertRaises(Exception) as e:
+ mock_si.return_value = [self.si]
+ self.sync_step().sync_record(self.onos_app)
+
+ self.assertTrue(m.called)
+ self.assertEqual(m.call_count, 2)
+ self.assertEqual(self.onos_app.app_id, self.vrouter_app_response["name"])
+ self.assertEqual(e.exception.message, "The version of org.onosproject.vrouter you installed (1.13.1) is not the same you requested (1.14.2)")
+
+ @requests_mock.Mocker()
+ def test_config_delete(self, m):
+ m.delete("http://onos-url:8181%s" % self.onos_app_attribute.name,
+ status_code=204)
+
+ self.sync_step().delete_record(self.onos_app_attribute)
+ self.assertTrue(m.called)
+ self.assertEqual(m.call_count, 1)
+
+ @requests_mock.Mocker()
+ def test_app_deactivate(self, m):
+ m.delete("http://onos-url:8181/onos/v1/applications/org.onosproject.vrouter/active",
+ status_code=204)
+
+ self.sync_step().delete_record(self.onos_app)
+ self.assertTrue(m.called)
+ self.assertEqual(m.call_count, 1)
+
+ @requests_mock.Mocker()
+ def test_app_uninstall(self, m):
+ self.onos_app.url = 'http://onf.org/maven/...'
+ self.onos_app.version = "1.14.2"
+ self.onos_app.backend_code = 1
+
+ m.delete("http://onos-url:8181/onos/v1/applications/org.onosproject.vrouter",
+ status_code=204)
+
+ self.sync_step().delete_record(self.onos_app)
+ self.assertTrue(m.called)
+ self.assertEqual(m.call_count, 1)
+
+
+
diff --git a/xos/synchronizer/steps/test_sync_onos_service.py b/xos/synchronizer/steps/test_sync_onos_service.py
new file mode 100644
index 0000000..314a0c1
--- /dev/null
+++ b/xos/synchronizer/steps/test_sync_onos_service.py
@@ -0,0 +1,198 @@
+# Copyright 2017-present Open Networking Foundation
+#
+# 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.
+
+import unittest
+import json
+import functools
+from mock import patch, call, Mock, PropertyMock
+import requests_mock
+
+import os, sys
+
+# Hack to load synchronizer framework
+test_path=os.path.abspath(os.path.dirname(os.path.realpath(__file__)))
+xos_dir=os.path.join(test_path, "../../..")
+if not os.path.exists(os.path.join(test_path, "new_base")):
+ xos_dir=os.path.join(test_path, "../../../../../../orchestration/xos/xos")
+ services_dir = os.path.join(xos_dir, "../../xos_services")
+sys.path.append(xos_dir)
+sys.path.append(os.path.join(xos_dir, 'synchronizers', 'new_base'))
+# END Hack to load synchronizer framework
+
+# generate model from xproto
+def get_models_fn(service_name, xproto_name):
+ name = os.path.join(service_name, "xos", xproto_name)
+ if os.path.exists(os.path.join(services_dir, name)):
+ return name
+ else:
+ name = os.path.join(service_name, "xos", "synchronizer", "models", xproto_name)
+ if os.path.exists(os.path.join(services_dir, name)):
+ return name
+ raise Exception("Unable to find service=%s xproto=%s" % (service_name, xproto_name))
+# END generate model from xproto
+
+def match_json(desired, req):
+ if desired!=req.json():
+ raise Exception("Got request %s, but body is not matching" % req.url)
+ return False
+ return True
+
+class TestSyncOnosService(unittest.TestCase):
+
+ def setUp(self):
+
+ self.sys_path_save = sys.path
+ sys.path.append(xos_dir)
+ sys.path.append(os.path.join(xos_dir, 'synchronizers', 'new_base'))
+
+ # Setting up the config module
+ from xosconfig import Config
+ config = os.path.join(test_path, "../test_config.yaml")
+ Config.clear()
+ Config.init(config, "synchronizer-config-schema.yaml")
+ # END Setting up the config module
+
+ from synchronizers.new_base.mock_modelaccessor_build import build_mock_modelaccessor
+ build_mock_modelaccessor(xos_dir, services_dir, [
+ get_models_fn("onos-service", "onos.xproto")
+ ])
+ import synchronizers.new_base.modelaccessor
+
+ from sync_onos_service import SyncONOSService, model_accessor
+
+ # import all class names to globals
+ for (k, v) in model_accessor.all_model_classes.items():
+ globals()[k] = v
+
+
+ self.sync_step = SyncONOSService
+
+ self.onos = Mock(spec=[
+ 'id',
+ 'name',
+ "rest_hostname",
+ "rest_port",
+ "rest_username",
+ "rest_password",
+ "class_names"
+ ])
+ self.onos.id = 1
+ self.onos.name = "onos"
+ self.onos.rest_hostname = "onos-url"
+ self.onos.rest_port = "8181"
+ self.onos.rest_username = "karaf"
+ self.onos.rest_password = "karaf"
+ self.onos.class_names = "ONOSService"
+
+ self.service = Mock()
+ self.service.id = 1
+ self.service.serviceattribute_dict = {}
+ self.service.leaf_model = self.onos
+
+ self.onos_service_attribute = Mock(spec=[
+ 'id',
+ 'service',
+ 'name',
+ 'value'
+ ])
+ self.onos_service_attribute.service = self.service
+ self.onos_service_attribute.name = "/onos/v1/network/configuration/apps/org.opencord.olt"
+ self.onos_service_attribute.value = {
+ "kafka": {
+ "bootstrapServers": "cord-kafka-kafka.default.svc.cluster.local:9092"
+ }
+ }
+
+ def tearDown(self):
+ self.onos = None
+ sys.path = self.sys_path_save
+
+ @requests_mock.Mocker()
+ def test_sync_no_service_attributes(self, m):
+ with patch.object(Service.objects, "get_items") as service_mock:
+ service_mock.return_value = [self.service]
+ self.sync_step().sync_record(self.onos)
+ self.assertFalse(m.called)
+
+ @requests_mock.Mocker()
+ def test_sync_service_attributes_from_service(self, m):
+ expected_conf = '{"foo": "bar"}'
+
+ self.service.serviceattribute_dict = {
+ '/onos/v1/network/configuration/apps/org.onosproject.olt': expected_conf,
+ '/onos/v1/network/configuration/apps/org.onosproject.dhcp': expected_conf
+ }
+
+ m.post("http://onos-url:8181/onos/v1/network/configuration/apps/org.onosproject.olt",
+ status_code=200,
+ additional_matcher=functools.partial(match_json, json.loads(expected_conf)))
+
+ m.post("http://onos-url:8181/onos/v1/network/configuration/apps/org.onosproject.dhcp",
+ status_code=200,
+ additional_matcher=functools.partial(match_json, json.loads(expected_conf)))
+
+ with patch.object(Service.objects, "get_items") as service_mock:
+ service_mock.return_value = [self.service]
+ self.sync_step().sync_record(self.onos)
+ self.assertTrue(m.called)
+ self.assertEqual(m.call_count, 2)
+
+ @requests_mock.Mocker()
+ def test_sync_service_attributes_from_attribute(self, m):
+ expected_conf = '{"foo": "bar"}'
+ self.service.serviceattribute_dict = {
+ '/onos/v1/network/configuration/apps/org.onosproject.olt': expected_conf,
+ }
+ m.post("http://onos-url:8181/onos/v1/network/configuration/apps/org.onosproject.olt",
+ status_code=200,
+ additional_matcher=functools.partial(match_json, json.loads(expected_conf)))
+
+ with patch.object(Service.objects, "get_items") as service_mock:
+ service_mock.return_value = [self.service]
+ self.sync_step().sync_record(self.onos_service_attribute)
+
+ self.assertTrue(m.called)
+ self.assertEqual(m.call_count, 1)
+
+ @requests_mock.Mocker()
+ def test_sync_service_attributes_err(self, m):
+ expected_conf = '{"foo": "bar"}'
+
+ self.service.serviceattribute_dict = {
+ '/onos/v1/network/configuration/apps/org.onosproject.olt': expected_conf,
+ }
+
+ m.post("http://onos-url:8181/onos/v1/network/configuration/apps/org.onosproject.olt",
+ status_code=500,
+ text="Mock Error",
+ additional_matcher=functools.partial(match_json, json.loads(expected_conf)))
+
+ with self.assertRaises(Exception) as e, \
+ patch.object(Service.objects, "get_items") as service_mock:
+
+ service_mock.return_value = [self.service]
+ self.sync_step().sync_record(self.onos)
+
+ self.assertTrue(m.called)
+ self.assertEqual(m.call_count, 1)
+ self.assertEqual(e.exception.message, "Failed to add config http://onos-url:8181/onos/v1/network/configuration/apps/org.onosproject.olt in ONOS")
+
+ @requests_mock.Mocker()
+ def test_delete(self, m):
+ m.delete("http://onos-url:8181%s" % self.onos_service_attribute.name,
+ status_code=204)
+
+ self.sync_step().delete_record(self.onos_service_attribute)
+ self.assertTrue(m.called)
+ self.assertEqual(m.call_count, 1)
\ No newline at end of file
diff --git a/xos/synchronizer/supervisor/onos-observer.conf b/xos/synchronizer/supervisor/onos-observer.conf
deleted file mode 100644
index 995644e..0000000
--- a/xos/synchronizer/supervisor/onos-observer.conf
+++ /dev/null
@@ -1,9 +0,0 @@
-[supervisord]
-logfile=/var/log/supervisord.log ; (main log file;default $CWD/supervisord.log)
-pidfile=/var/run/supervisord.pid ; (supervisord pidfile;default supervisord.pid)
-nodaemon=true
-
-[program:synchronizer]
-command=python /opt/xos/synchronizers/onos/onos-synchronizer.py -C /opt/xos/synchronizers/onos/onos_synchronizer_config
-stderr_logfile=/var/log/supervisor/synchronizer.err.log
-stdout_logfile=/var/log/supervisor/synchronizer.out.log
diff --git a/xos/synchronizer/scripts/dockerip.sh b/xos/synchronizer/test_config.yaml
similarity index 67%
copy from xos/synchronizer/scripts/dockerip.sh
copy to xos/synchronizer/test_config.yaml
index 3618fa6..61e06f5 100644
--- a/xos/synchronizer/scripts/dockerip.sh
+++ b/xos/synchronizer/test_config.yaml
@@ -13,13 +13,18 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-
-#!/bin/bash
-
-MODE=`docker inspect --format '{{ .HostConfig.NetworkMode }}' $1 | tr -d '\n' | tr -d '\r'`
-if [[ "$MODE" == "host" ]]; then
- echo -n "127.0.0.1"
-else
- docker inspect --format '{{ .NetworkSettings.IPAddress }}' $1 | tr -d '\n' | tr -d '\r'
-fi
-
+name: onos-service
+accessor:
+ username: xosadmin@opencord.org
+ password: "sample"
+ kind: "testframework"
+logging:
+ version: 1
+ handlers:
+ console:
+ class: logging.StreamHandler
+ loggers:
+ 'multistructlog':
+ handlers:
+ - console
+ level: ERROR
diff --git a/xos/unittest.cfg b/xos/unittest.cfg
new file mode 100644
index 0000000..48ea867
--- /dev/null
+++ b/xos/unittest.cfg
@@ -0,0 +1,4 @@
+[unittest]
+plugins=nose2-plugins.exclude
+code-directories=synchronizer
+ steps