Full Juju Charm support
[osm/SO.git] / common / python / rift / mano / utils / juju_api.py
index 81d54a9..3f25c2a 100755 (executable)
@@ -150,7 +150,10 @@ class JujuApi(object):
         else:
             self.log = logging.getLogger(__name__)
 
-        self.log.debug('JujuApi instantiated')
+        # Quiet websocket traffic
+        logging.getLogger('websockets.protocol').setLevel(logging.INFO)
+
+        self.log.debug('JujuApi: instantiated')
 
         self.server = server
         self.port = port
@@ -172,18 +175,72 @@ class JujuApi(object):
         """Close any open connections."""
         yield self.logout()
 
+    async def add_relation(self, a, b, via=None):
+        """
+        Add a relation between two application endpoints.
+
+        :param a An application endpoint
+        :param b An application endpoint
+        :param via The egress subnet(s) for outbound traffic, e.g., (192.168.0.0/16,10.0.0.0/8)
+        """
+        if not self.authenticated:
+            await self.login()
+
+        m = await self.get_model()
+        try:
+            m.add_relation(a, b, via)
+        finally:
+            await m.disconnect()
+
     async def apply_config(self, config, application):
         """Apply a configuration to the application."""
+        self.log.debug("JujuApi: Applying configuration to {}.".format(
+            application
+        ))
         return await self.set_config(application=application, config=config)
 
-    async def deploy_application(self, charm, name="", path=""):
-        """Deploy an application."""
+    async def deploy_application(self, charm, name="", path="", specs={}):
+        """
+        Deploy an application.
+
+        Deploy an application to a container or a machine already provisioned
+        by the OSM Resource Orchestrator (requires the Juju public ssh key
+        installed on the new machine via cloud-init).
+
+        :param str charm: The name of the charm
+        :param str name: The name of the application, if different than the charm
+        :param str path: The path to the charm
+        :param dict machine: A dictionary identifying the machine to manage via Juju
+            Examples::
+
+                deploy_application(..., specs={'host': '10.0.0.4', 'user': 'ubuntu'})
+        """
         if not self.authenticated:
             await self.login()
 
+        # Check that the charm is valid and exists.
+        if charm is None:
+            return None
+
         app = await self.get_application(name)
         if app is None:
+
+            # Check for specific machine placement
+            to = None
+            if all(k in specs for k in ['hostname', 'username']):
+                machine = await self.model.add_machine(spec='ssh:%@%'.format(
+                    specs['host'],
+                    specs['user'],
+                ))
+                to = machine.id
+
             # TODO: Handle the error if the charm isn't found.
+            self.log.debug("JujuApi: Deploying charm {} ({}) from {}".format(
+                charm,
+                name,
+                path,
+                to=to,
+            ))
             app = await self.model.deploy(
                 path,
                 application_name=name,
@@ -197,6 +254,7 @@ class JujuApi(object):
         if not self.authenticated:
             await self.login()
 
+        self.log.debug("JujuApi: Waiting for status of action uuid {}".format(uuid))
         action = await self.model.wait_for_action(uuid)
         return action.status
 
@@ -205,14 +263,15 @@ class JujuApi(object):
         if not self.authenticated:
             await self.login()
 
+        self.log.debug("JujuApi: Getting application {}".format(application))
         app = None
-        if self.model:
+        if application and self.model:
             if self.model.applications:
                 if application in self.model.applications:
                     app = self.model.applications[application]
         return app
 
-    async def get_application_status(self, application, status=None):
+    async def get_application_status(self, application):
         """Get the status of an application."""
         if not self.authenticated:
             await self.login()
@@ -221,7 +280,10 @@ class JujuApi(object):
         app = await self.get_application(application)
         if app:
             status = app.status
-
+            self.log.debug("JujuApi: Status of application {} is {}".format(
+                application,
+                str(status),
+            ))
         return status
     get_service_status = get_application_status
 
@@ -235,6 +297,11 @@ class JujuApi(object):
         if app:
             config = await app.get_config()
 
+        self.log.debug("JujuApi: Config of application {} is {}".format(
+            application,
+            str(config),
+        ))
+
         return config
 
     async def get_model(self, name='default'):
@@ -249,6 +316,11 @@ class JujuApi(object):
 
         uuid = await self.get_model_uuid(name)
 
+        self.log.debug("JujuApi: Connecting to model {} ({})".format(
+            model,
+            uuid,
+        ))
+
         await model.connect(
             self.endpoint,
             uuid,
@@ -270,6 +342,11 @@ class JujuApi(object):
         uuid = None
 
         models = await self.controller.get_models()
+
+        self.log.debug("JujuApi: Looking through {} models for model {}".format(
+            len(models.user_models),
+            name,
+        ))
         for model in models.user_models:
             if model.model.name == name:
                 uuid = model.model.uuid
@@ -290,6 +367,7 @@ class JujuApi(object):
             machines = {}
             relations = {}
 
+        self.log.debug("JujuApi: Getting model status")
         status = model_state()
         status.applications = self.model.applications
         status.machines = self.model.machines
@@ -305,6 +383,12 @@ class JujuApi(object):
         status = await self.get_application_status(application)
         if status and status in ['active']:
             state = True
+
+        self.log.debug("JujuApi: Application {} is {} active".format(
+            application,
+            "" if status else "not",
+        ))
+
         return state
     is_service_active = is_application_active
 
@@ -317,6 +401,12 @@ class JujuApi(object):
         status = await self.get_application_status(application)
         if status and status in ['blocked']:
             state = True
+
+        self.log.debug("JujuApi: Application {} is {} blocked".format(
+            application,
+            "" if status else "not",
+        ))
+
         return state
     is_service_blocked = is_application_blocked
 
@@ -329,6 +419,11 @@ class JujuApi(object):
         status = await self.get_application_status(application)
         if status and status in ['active']:
             state = True
+        self.log.debug("JujuApi: Application {} is {} deployed".format(
+            application,
+            "" if status else "not",
+        ))
+
         return state
     is_service_deployed = is_application_deployed
 
@@ -341,6 +436,11 @@ class JujuApi(object):
         status = await self.get_application_status(application)
         if status and status in ['error']:
             state = True
+        self.log.debug("JujuApi: Application {} is {} errored".format(
+            application,
+            "" if status else "not",
+        ))
+
         return state
     is_service_error = is_application_error
 
@@ -353,6 +453,11 @@ class JujuApi(object):
         status = await self.get_application_status(application)
         if status and status in ['maintenance']:
             state = True
+        self.log.debug("JujuApi: Application {} is {} in maintenence".format(
+            application,
+            "" if status else "not",
+        ))
+
         return state
     is_service_maint = is_application_maint
 
@@ -365,6 +470,10 @@ class JujuApi(object):
         status = await self.get_application_status(application)
         if status and status in ['active', 'blocked']:
             state = True
+        self.log.debug("JujuApi: Application {} is {} up".format(
+            application,
+            "" if status else "not",
+        ))
         return state
     is_service_up = is_application_up
 
@@ -375,6 +484,8 @@ class JujuApi(object):
         cacert = None
         self.controller = Controller()
 
+        self.log.debug("JujuApi: Logging into controller")
+
         if self.secret:
             await self.controller.connect(
                 self.endpoint,
@@ -409,15 +520,39 @@ class JujuApi(object):
 
         app = await self.get_application(name)
         if app:
+            self.log.debug("JujuApi: Destroying application {}".format(
+                name,
+            ))
+
             await app.destroy()
 
-    async def resolve_error(self, application=None, status=None):
+    async def remove_relation(self, a, b):
+        """
+        Remove a relation between two application endpoints
+
+        :param a An application endpoint
+        :param b An application endpoint
+        """
+        if not self.authenticated:
+            await self.login()
+
+        m = await self.get_model()
+        try:
+            m.remove_relation(a, b)
+        finally:
+            await m.disconnect()
+
+    async def resolve_error(self, application=None):
         """Resolve units in error state."""
         if not self.authenticated:
             await self.login()
 
         app = await self.get_application(application)
         if app:
+            self.log.debug("JujuApi: Resolving errors for application {}".format(
+                application,
+            ))
+
             for unit in app.units:
                 app.resolved(retry=True)
 
@@ -438,6 +573,11 @@ class JujuApi(object):
             # so use the first unit available.
             unit = app.units[0]
 
+            self.log.debug("JujuApi: Running Action {} against Application {}".format(
+                action_name,
+                application,
+            ))
+
             action = await unit.run_action(action_name, **params)
 
             # Wait for the action to complete
@@ -457,13 +597,28 @@ class JujuApi(object):
 
         app = await self.get_application(application)
         if app:
+            self.log.debug("JujuApi: Setting config for Application {}".format(
+                application,
+            ))
             await app.set_config(config)
 
+            # Verify the config is set
+            newconf = await app.get_config()
+            for key in config:
+                if config[key] != newconf[key]:
+                    self.log.debug("JujuApi: Config not set! Key {} Value {} doesn't match {}".format(key, config[key], newconf[key]))
+
+
     async def set_parameter(self, parameter, value, application=None):
         """Set a config parameter for a service."""
         if not self.authenticated:
             await self.login()
 
+        self.log.debug("JujuApi: Setting {}={} for Application {}".format(
+            parameter,
+            value,
+            application,
+        ))
         return await self.apply_config(
             {parameter: value},
             application=application,
@@ -476,6 +631,11 @@ class JujuApi(object):
 
         app = await self.get_application(name)
         if app:
+            self.log.debug("JujuApi: Waiting {} seconds for Application {}".format(
+                timeout,
+                name,
+            ))
+
             await self.model.block_until(
                 lambda: all(
                     unit.agent_status == 'idle'
@@ -487,11 +647,15 @@ class JujuApi(object):
 
 
 def get_argparser():
-    parser = argparse.ArgumentParser(description='Test Juju')
+    parser = argparse.ArgumentParser(description='Test Driver for Juju API')
+
+    ###################
+    # Authentication  #
+    ###################
     parser.add_argument(
         "-s", "--server",
         default='10.0.202.49',
-        help="Juju controller"
+        help="Juju controller",
     )
     parser.add_argument(
         "-u", "--user",
@@ -508,6 +672,15 @@ def get_argparser():
         default=17070,
         help="Port number, default 17070"
     )
+    parser.add_argument(
+        "-m", "--model",
+        default='default',
+        help="The model to connect to."
+    )
+
+    ##########
+    # Charm  #
+    ##########
     parser.add_argument(
         "-d", "--directory",
         help="Local directory for the charm"
@@ -516,19 +689,39 @@ def get_argparser():
         "--application",
         help="Charm name"
     )
+
+    #############
+    # Placement #
+    #############
+
+    """
+    To deploy to a non-Juju machine, provide the host and
+    credentials for Juju to manually provision (host, username, (password or key?))
+
+    """
     parser.add_argument(
-        "--vnf-ip",
-        help="IP of the VNF to configure"
+        "--proxy",
+        action='store_true',
+        help="Deploy as a proxy charm.",
     )
     parser.add_argument(
-        "-m", "--model",
-        default='default',
-        help="The model to connect to."
+        "--no-proxy",
+        action='store_false',
+        dest='proxy',
+        help="Deploy as a full charm.",
     )
+    parser.set_defaults(proxy=True)
+
+    # Test options?
+    # unit test?
+    #######
+    # VNF #
+    #######
+
     return parser.parse_args()
 
 
-if __name__ == "__main__":
+async def deploy_charm_and_wait():
     args = get_argparser()
 
     # Set logging level to debug so we can see verbose output from the
@@ -540,58 +733,76 @@ if __name__ == "__main__":
     ws_logger = logging.getLogger('websockets.protocol')
     ws_logger.setLevel(logging.INFO)
 
-    endpoint = '%s:%d' % (args.server, int(args.port))
-
-    loop = asyncio.get_event_loop()
-
+    """Here's an example of a coroutine that will deploy a charm and wait until
+    it's ready to be used."""
     api = JujuApi(server=args.server,
                   port=args.port,
                   user=args.user,
                   secret=args.password,
-                  loop=loop,
+                  loop=loop,
                   log=ws_logger,
                   model_name=args.model
                   )
+    print("Logging in...")
+    await api.login()
 
-    juju.loop.run(api.login())
-
-    status = juju.loop.run(api.get_status())
-
-    print('Applications:', list(status.applications.keys()))
-    print('Machines:', list(status.machines.keys()))
+    if api.authenticated:
+        status = await api.get_status()
+        print('Applications:', list(status.applications.keys()))
+        print('Machines:', list(status.machines.keys()))
 
     if args.directory and args.application:
+
         # Deploy the charm
-        charm = os.path.basename(args.directory)
-        juju.loop.run(
-            api.deploy_application(charm,
-                                   name=args.application,
-                                   path=args.directory,
-                                   )
+        charm = os.path.basename(
+            os.path.expanduser(
+                os.path.dirname(args.directory)
+            )
         )
+        await api.deploy_application(charm,
+                                     name=args.application,
+                                     path=args.directory,
+                                     )
+
+        # Wait for the application to fully deploy. This will block until the
+        # agent is in an idle state, and the charm's workload is either
+        # 'active' or 'unknown', meaning it's ready but the author did not
+        # explicitly set a workload state.
+        print("Waiting for application '{}' to deploy...".format(charm))
+        while (True):
+            # Deploy the charm and wait, periodically checking its status
+            await api.wait_for_application(charm, 30)
+
+            error = await api.is_application_error(charm)
+            if error:
+                print("This application is in an error state.")
+                break
 
-        juju.loop.run(api.wait_for_application(charm))
+            blocked = await api.is_application_blocked(charm)
+            if blocked:
+                print("This application is blocked.")
+                break
 
-        # Wait for the service to come up
-        up = juju.loop.run(api.is_application_up(charm))
-        print("Application is {}".format("up" if up else "down"))
+            # An extra check to see if the charm is ready
+            up = await api.is_application_up(charm)
+            print("Application is {}".format("up" if up else "down"))
 
         print("Service {} is deployed".format(args.application))
 
-        ###########################
-        # Execute config on charm #
-        ###########################
-        config = juju.loop.run(api.get_config(args.application))
+        ###################################
+        # Execute config on a proxy charm #
+        ###################################
+        config = await api.get_config(args.application)
         hostname = config['ssh-username']['value']
         rhostname = hostname[::-1]
 
         # Apply the configuration
-        juju.loop.run(api.apply_config(
+        await api.apply_config(
             {'ssh-username': rhostname}, application=args.application
-        ))
+        )
 
         # Get the configuration
-        config = juju.loop.run(api.get_config(args.application))
+        config = await api.get_config(args.application)
 
         # Verify the configuration has been updated
         assert(config['ssh-username']['value'] == rhostname)
@@ -599,13 +810,13 @@ if __name__ == "__main__":
         ####################################
         # Get the status of an application #
         ####################################
-        status = juju.loop.run(api.get_application_status(charm))
+        status = await api.get_application_status(charm)
         print("Application Status: {}".format(status))
 
         ###########################
         # Execute a simple action #
         ###########################
-        result = juju.loop.run(api.run_action(charm, 'get-ssh-public-key'))
+        result = await api.run_action(charm, 'get-ssh-public-key')
         print("Action {} status is {} and returned {}".format(
             result['status'],
             result['action']['tag'],
@@ -615,50 +826,28 @@ if __name__ == "__main__":
         #####################################
         # Execute an action with parameters #
         #####################################
-        result = juju.loop.run(
-            api.run_action(charm, 'run', command='hostname')
-        )
+        result = await api.run_action(charm, 'run', command='hostname')
+
         print("Action {} status is {} and returned {}".format(
             result['status'],
             result['action']['tag'],
             result['action']['results']
         ))
 
-    juju.loop.run(api.logout())
+    print("Logging out...")
+    await api.logout()
+    api = None
 
-    loop.close()
+# get public key in juju controller? that can be pulled without need of a charm deployed and installed to vm via cloud-init
 
-    #     if args.vnf_ip and \
-    #        ('clearwater-aio' in args.directory):
-    #         # Execute config on charm
-    #         api._apply_config({'proxied_ip': args.vnf_ip})
-    #
-    #         while not api._is_service_active():
-    #             time.sleep(10)
-    #
-    #         print ("Service {} is in status {}".
-    #                format(args.service, api._get_service_status()))
-    #
-    #         res = api._execute_action('create-update-user', {'number': '125252352525',
-    #                                                          'password': 'asfsaf'})
-    #
-    #         print ("Action 'creat-update-user response: {}".format(res))
-    #
-    #         status = res['status']
-    #         while status not in [ 'completed', 'failed' ]:
-    #             time.sleep(2)
-    #             status = api._get_action_status(res['action']['tag'])['status']
-    #
-    #             print("Action status: {}".format(status))
-    #
-    #         # This action will fail as the number is non-numeric
-    #         res = api._execute_action('delete-user', {'number': '125252352525asf'})
-    #
-    #         print ("Action 'delete-user response: {}".format(res))
-    #
-    #         status = res['status']
-    #         while status not in [ 'completed', 'failed' ]:
-    #             time.sleep(2)
-    #             status = api._get_action_status(res['action']['tag'])['status']
-    #
-    #             print("Action status: {}".format(status))
+if __name__ == "__main__":
+    # Create a single event loop for running code asyncronously.
+    loop = asyncio.get_event_loop()
+
+    # An initial set of tasks to run
+    tasks = [
+        deploy_charm_and_wait(),
+    ]
+
+    # TODO: optionally run forever and use a Watcher to monitor what's happening
+    loop.run_until_complete(asyncio.wait(tasks))