| # 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. |
| |
| |
| """ ModelAccessor |
| |
| A class for abstracting access to models. Used to get any djangoisms out |
| of the synchronizer code base. |
| |
| This module will import all models into this module's global scope, so doing |
| a "from modelaccessor import *" from a calling module ought to import all |
| models into the calling module's scope. |
| """ |
| |
| import functools |
| import importlib |
| import os |
| import signal |
| import sys |
| from threading import Timer |
| from loadmodels import ModelLoadClient |
| |
| from xosconfig import Config |
| from multistructlog import create_logger |
| from xosutil.autodiscover_version import autodiscover_version_of_main |
| |
| log = create_logger(Config().get("logging")) |
| |
| after_reactor_exit_code = None |
| orig_sigint = None |
| model_accessor = None |
| |
| |
| class ModelAccessor(object): |
| def __init__(self): |
| self.all_model_classes = self.get_all_model_classes() |
| |
| def __getattr__(self, name): |
| """ Wrapper for getattr to facilitate retrieval of classes """ |
| has_model_class = self.__getattribute__("has_model_class") |
| get_model_class = self.__getattribute__("get_model_class") |
| if has_model_class(name): |
| return get_model_class(name) |
| |
| # Default behaviour |
| return self.__getattribute__(name) |
| |
| def get_all_model_classes(self): |
| """ Build a dictionary of all model class names """ |
| raise Exception("Not Implemented") |
| |
| def get_model_class(self, name): |
| """ Given a class name, return that model class """ |
| return self.all_model_classes[name] |
| |
| def has_model_class(self, name): |
| """ Given a class name, return that model class """ |
| return name in self.all_model_classes |
| |
| def fetch_pending(self, main_objs, deletion=False): |
| """ Execute the default fetch_pending query """ |
| raise Exception("Not Implemented") |
| |
| def fetch_policies(self, main_objs, deletion=False): |
| """ Execute the default fetch_pending query """ |
| raise Exception("Not Implemented") |
| |
| def reset_queries(self): |
| """ Reset any state between passes of synchronizer. For django, to |
| limit memory consumption of cached queries. |
| """ |
| pass |
| |
| def connection_close(self): |
| """ Close any active database connection. For django, to limit memory |
| consumption. |
| """ |
| pass |
| |
| def check_db_connection_okay(self): |
| """ Checks to make sure the db connection is okay """ |
| pass |
| |
| def obj_exists(self, o): |
| """ Return True if the object exists in the data model """ |
| raise Exception("Not Implemented") |
| |
| def obj_in_list(self, o, olist): |
| """ Return True if o is the same as one of the objects in olist """ |
| raise Exception("Not Implemented") |
| |
| def now(self): |
| """ Return the current time for timestamping purposes """ |
| raise Exception("Not Implemented") |
| |
| def is_type(self, obj, name): |
| """ returns True is obj is of model type "name" """ |
| raise Exception("Not Implemented") |
| |
| def is_instance(self, obj, name): |
| """ returns True if obj is of model type "name" or is a descendant """ |
| raise Exception("Not Implemented") |
| |
| def get_content_type_id(self, obj): |
| raise Exception("Not Implemented") |
| |
| def journal_object(self, o, operation, msg=None, timestamp=None): |
| pass |
| |
| def create_obj(self, cls, **kwargs): |
| raise Exception("Not Implemented") |
| |
| |
| def import_models_to_globals(): |
| # add all models to globals |
| for (k, v) in model_accessor.all_model_classes.items(): |
| globals()[k] = v |
| |
| # xosbase doesn't exist from the synchronizer's perspective, so fake out |
| # ModelLink. |
| if "ModelLink" not in globals(): |
| |
| class ModelLink: |
| def __init__(self, dest, via, into=None): |
| self.dest = dest |
| self.via = via |
| self.into = into |
| |
| globals()["ModelLink"] = ModelLink |
| |
| |
| def keep_trying(client, reactor): |
| # Keep checking the connection to wait for it to become unavailable. |
| # Then reconnect. The strategy is to send NoOp operations, one per second, until eventually a NoOp throws an |
| # exception. This will indicate the server has reset. When that happens, we force the client to reconnect, and |
| # it will download a new API from the server. |
| |
| from xosapi.xos_grpc_client import Empty |
| |
| try: |
| client.utility.NoOp(Empty()) |
| except Exception as e: |
| # If we caught an exception, then the API has become unavailable. |
| # So reconnect. |
| |
| log.exception("exception in NoOp", e=e) |
| log.info("restarting synchronizer") |
| |
| os.execv(sys.executable, ["python"] + sys.argv) |
| return |
| |
| reactor.callLater(1, functools.partial(keep_trying, client, reactor)) |
| |
| def unload_models(client, reactor, version): |
| # This function is called by a timer until it succeeds. |
| log.info("unload_models initiated by timer") |
| |
| try: |
| result = ModelLoadClient(client).unload_models( |
| Config.get("name"), |
| version=version, |
| cleanup_behavior=ModelLoadClient.AUTOMATICALLY_CLEAN) |
| |
| log.debug("Unload response", result=result) |
| |
| if result.status in [result.SUCCESS, result.SUCCESS_NOTHING_CHANGED]: |
| log.info("Models successfully unloaded. Exiting with status", code=0) |
| sys.exit(0) |
| |
| if result.status == result.TRYAGAIN: |
| log.info("TRYAGAIN received. Expect to try again in 30 seconds.") |
| |
| except Exception as e: |
| # If the synchronizer is operational, then assume the ORM's restart_on_disconnect will deal with the |
| # connection being lost. |
| log.exception("Error while unloading. Expect to try again in 30 seconds.") |
| |
| Timer(30, functools.partial(unload_models, client, reactor, version)).start() |
| |
| def exit_while_inside_reactor(reactor, code): |
| """ Calling sys.exit() while inside reactor ends up trapped by reactor. |
| |
| So what we'll do is set a flag indicating we want to exit, then stop reactor, then return |
| """ |
| global after_reactor_exit_code |
| |
| reactor.stop() |
| signal.signal(signal.SIGINT, orig_sigint) |
| after_reactor_exit_code = code |
| |
| |
| def grpcapi_reconnect(client, reactor): |
| global model_accessor |
| |
| # Make sure to try to load models before trying to initialize the ORM. It might be the ORM is broken because it |
| # is waiting on our models. |
| |
| if Config.get("models_dir"): |
| version = autodiscover_version_of_main(max_parent_depth=0) or "unknown" |
| log.info("Service version is %s" % version) |
| try: |
| if Config.get("desired_state") == "load": |
| ModelLoadClient(client).upload_models( |
| Config.get("name"), Config.get("models_dir"), version=version |
| ) |
| elif Config.get("desired_state") == "unload": |
| # Try for an easy unload. If there's no dirty models, then unload will succeed without |
| # requiring us to setup the synchronizer. |
| log.info("Trying for an easy unload_models") |
| result = ModelLoadClient(client).unload_models( |
| Config.get("name"), |
| version=version, |
| cleanup_behavior=1) # FIXME: hardcoded value for automatic delete |
| if result.status in [result.SUCCESS, result.SUCCESS_NOTHING_CHANGED]: |
| log.info("Models successfully unloaded. Synchronizer exiting") |
| exit_while_inside_reactor(reactor, 0) |
| return |
| |
| # We couldn't unload the easy way, so we'll have to do it the hard way. Fall through and |
| # setup the synchronizer. |
| else: |
| log.error("Misconfigured", desired_state=Config.get("desired_state")) |
| exit_while_inside_reactor(reactor, -1) |
| return |
| except Exception as e: # TODO: narrow exception scope |
| if ( |
| hasattr(e, "code") |
| and callable(e.code) |
| and hasattr(e.code(), "name") |
| and (e.code().name) == "UNAVAILABLE" |
| ): |
| # We need to make sure we force a reconnection, as it's possible that we will end up downloading a |
| # new xos API. |
| log.info("grpc unavailable during loadmodels. Force a reconnect") |
| client.connected = False |
| client.connect() |
| return |
| log.exception("failed to onboard models") |
| # If it's some other error, then we don't need to force a reconnect. Just try the LoadModels() again. |
| reactor.callLater(10, functools.partial(grpcapi_reconnect, client, reactor)) |
| return |
| |
| # If the ORM is broken, then wait for the orm to become available. |
| |
| if not getattr(client, "xos_orm", None): |
| log.warning("No xos_orm. Will keep trying...") |
| reactor.callLater(1, functools.partial(keep_trying, client, reactor)) |
| return |
| |
| # this will prevent updated timestamps from being automatically updated |
| client.xos_orm.caller_kind = "synchronizer" |
| |
| client.xos_orm.restart_on_disconnect = True |
| |
| from apiaccessor import CoreApiModelAccessor |
| |
| model_accessor = CoreApiModelAccessor(orm=client.xos_orm) |
| |
| # If required_models is set, then check to make sure the required_models |
| # are present. If not, then the synchronizer needs to go to sleep until |
| # the models show up. |
| |
| required_models = Config.get("required_models") |
| if required_models: |
| required_models = [x.strip() for x in required_models] |
| |
| missing = [] |
| found = [] |
| for model in required_models: |
| if model_accessor.has_model_class(model): |
| found.append(model) |
| else: |
| missing.append(model) |
| |
| log.info("required_models, found:", models=", ".join(found)) |
| if missing: |
| log.warning("required_models: missing", models=", ".join(missing)) |
| # We're missing a required model. Give up and wait for the connection |
| # to reconnect, and hope our missing model has shown up. |
| reactor.callLater(1, functools.partial(keep_trying, client, reactor)) |
| return |
| |
| # import all models to global space |
| import_models_to_globals() |
| |
| # Synchronizer framework isn't ready to embrace reactor yet... |
| reactor.stop() |
| |
| # Restore the sigint handler |
| signal.signal(signal.SIGINT, orig_sigint) |
| |
| # Check to see if we still want to unload |
| if Config.get("desired_state") == "unload": |
| Timer(30, functools.partial(unload_models, client, reactor, version)).start() |
| |
| |
| def config_accessor_grpcapi(): |
| global orig_sigint |
| |
| log.info("Connecting to the gRPC API") |
| |
| grpcapi_endpoint = Config.get("accessor.endpoint") |
| grpcapi_username = Config.get("accessor.username") |
| grpcapi_password = Config.get("accessor.password") |
| |
| # if password starts with "@", then retreive the password from a file |
| if grpcapi_password.startswith("@"): |
| fn = grpcapi_password[1:] |
| if not os.path.exists(fn): |
| raise Exception("%s does not exist" % fn) |
| grpcapi_password = open(fn).readline().strip() |
| |
| from xosapi.xos_grpc_client import SecureClient |
| from twisted.internet import reactor |
| |
| grpcapi_client = SecureClient( |
| endpoint=grpcapi_endpoint, username=grpcapi_username, password=grpcapi_password |
| ) |
| grpcapi_client.set_reconnect_callback( |
| functools.partial(grpcapi_reconnect, grpcapi_client, reactor) |
| ) |
| grpcapi_client.start() |
| |
| # Start reactor. This will cause the client to connect and then execute |
| # grpcapi_callback(). |
| |
| # Reactor will take over SIGINT during reactor.run(), but does not return it when reactor.stop() is called. |
| |
| orig_sigint = signal.getsignal(signal.SIGINT) |
| |
| # Start reactor. This will cause the client to connect and then execute |
| # grpcapi_callback(). |
| |
| reactor.run() |
| |
| # Catch if we wanted to stop while inside of a reactor callback |
| if after_reactor_exit_code is not None: |
| log.info("exiting with status", code=after_reactor_exit_code) |
| sys.exit(after_reactor_exit_code) |
| |
| |
| def config_accessor_mock(): |
| global model_accessor |
| |
| # the mock model accessor always gets built to a temporary location |
| if not "/tmp/mock_modelaccessor" in sys.path: |
| sys.path.append("/tmp/mock_modelaccessor") |
| |
| from mock_modelaccessor import model_accessor as mock_model_accessor |
| |
| model_accessor = mock_model_accessor |
| |
| # mock_model_accessor doesn't have an all_model_classes field, so make one. |
| import mock_modelaccessor as mma |
| |
| all_model_classes = {} |
| for k in dir(mma): |
| v = getattr(mma, k) |
| if hasattr(v, "leaf_model_name"): |
| all_model_classes[k] = v |
| |
| model_accessor.all_model_classes = all_model_classes |
| |
| import_models_to_globals() |
| |
| |
| def config_accessor(): |
| accessor_kind = Config.get("accessor.kind") |
| |
| if accessor_kind == "testframework": |
| config_accessor_mock() |
| elif accessor_kind == "grpcapi": |
| config_accessor_grpcapi() |
| else: |
| raise Exception("Unknown accessor kind %s" % accessor_kind) |
| |
| # now import any wrappers that the synchronizer needs to add to the ORM |
| if Config.get("wrappers"): |
| for wrapper_name in Config.get("wrappers"): |
| importlib.import_module(wrapper_name) |
| |
| |
| config_accessor() |