3acc10e86f6dab54c495f34a4e1ef7fe0a4eab53
[osm/N2VC.git] / n2vc / k8s_juju_conn.py
1 # Copyright 2019 Canonical Ltd.
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 import concurrent
16 from .exceptions import NotImplemented
17
18 import juju
19 # from juju.bundle import BundleHandler
20 from juju.controller import Controller
21 from juju.model import Model
22 from juju.errors import JujuAPIError, JujuError
23
24 import logging
25
26 from n2vc.k8s_conn import K8sConnector
27
28 import os
29 # import re
30 # import ssl
31 import subprocess
32 # from .vnf import N2VC
33
34 import uuid
35 import yaml
36
37
38 class K8sJujuConnector(K8sConnector):
39
40 def __init__(
41 self,
42 fs: object,
43 db: object,
44 kubectl_command: str = '/usr/bin/kubectl',
45 juju_command: str = '/usr/bin/juju',
46 log=None,
47 on_update_db=None,
48 ):
49 """
50
51 :param kubectl_command: path to kubectl executable
52 :param helm_command: path to helm executable
53 :param fs: file system for kubernetes and helm configuration
54 :param log: logger
55 """
56
57 # parent class
58 K8sConnector.__init__(
59 self,
60 db,
61 log=log,
62 on_update_db=on_update_db,
63 )
64
65 self.fs = fs
66 self.info('Initializing K8S Juju connector')
67
68 self.authenticated = False
69 self.models = {}
70 self.log = logging.getLogger(__name__)
71
72 self.juju_secret = ""
73
74 self.info('K8S Juju connector initialized')
75
76 """Initialization"""
77 async def init_env(
78 self,
79 k8s_creds: str,
80 namespace: str = 'kube-system',
81 reuse_cluster_uuid: str = None,
82 ) -> (str, bool):
83 """Initialize a Kubernetes environment
84
85 :param k8s_creds dict: A dictionary containing the Kubernetes cluster
86 configuration
87 :param namespace str: The Kubernetes namespace to initialize
88
89 :return: UUID of the k8s context or raises an exception
90 """
91
92 """Bootstrapping
93
94 Bootstrapping cannot be done, by design, through the API. We need to
95 use the CLI tools.
96 """
97 # TODO: The path may change
98 jujudir = "/usr/local/bin"
99
100 self.k8scli = "{}/juju".format(jujudir)
101
102 """
103 WIP: Workflow
104
105 1. Has the environment already been bootstrapped?
106 - Check the database to see if we have a record for this env
107
108 2. If this is a new env, create it
109 - Add the k8s cloud to Juju
110 - Bootstrap
111 - Record it in the database
112
113 3. Connect to the Juju controller for this cloud
114
115 """
116 # cluster_uuid = reuse_cluster_uuid
117 # if not cluster_uuid:
118 # cluster_uuid = str(uuid4())
119
120 ##################################################
121 # TODO: Pull info from db based on the namespace #
122 ##################################################
123
124 if not reuse_cluster_uuid:
125 # This is a new cluster, so bootstrap it
126
127 cluster_uuid = str(uuid.uuid4())
128
129 # Add k8s cloud to Juju (unless it's microk8s)
130
131 # Convert to a dict
132 # k8s_creds = yaml.safe_load(k8s_creds)
133
134 # Does the kubeconfig contain microk8s?
135 microk8s = self.is_microk8s_by_credentials(k8s_creds)
136
137 if not microk8s:
138 # Name the new k8s cloud
139 k8s_cloud = "{}-k8s".format(namespace)
140
141 print("Adding k8s cloud {}".format(k8s_cloud))
142 await self.add_k8s(k8s_cloud, k8s_creds)
143
144 # Bootstrap Juju controller
145 print("Bootstrapping...")
146 await self.bootstrap(k8s_cloud, cluster_uuid)
147 print("Bootstrap done.")
148 else:
149 # k8s_cloud = 'microk8s-test'
150 k8s_cloud = "{}-k8s".format(namespace)
151
152 await self.add_k8s(k8s_cloud, k8s_creds)
153
154 await self.bootstrap(k8s_cloud, cluster_uuid)
155
156 # Get the controller information
157
158 # Parse ~/.local/share/juju/controllers.yaml
159 # controllers.testing.api-endpoints|ca-cert|uuid
160 print("Getting controller endpoints")
161 with open(os.path.expanduser(
162 "~/.local/share/juju/controllers.yaml"
163 )) as f:
164 controllers = yaml.load(f, Loader=yaml.Loader)
165 controller = controllers['controllers'][cluster_uuid]
166 endpoints = controller['api-endpoints']
167 self.juju_endpoint = endpoints[0]
168 self.juju_ca_cert = controller['ca-cert']
169
170 # Parse ~/.local/share/juju/accounts
171 # controllers.testing.user|password
172 print("Getting accounts")
173 with open(os.path.expanduser(
174 "~/.local/share/juju/accounts.yaml"
175 )) as f:
176 controllers = yaml.load(f, Loader=yaml.Loader)
177 controller = controllers['controllers'][cluster_uuid]
178
179 self.juju_user = controller['user']
180 self.juju_secret = controller['password']
181
182 print("user: {}".format(self.juju_user))
183 print("secret: {}".format(self.juju_secret))
184 print("endpoint: {}".format(self.juju_endpoint))
185 print("ca-cert: {}".format(self.juju_ca_cert))
186
187 # raise Exception("EOL")
188
189 self.juju_public_key = None
190
191 config = {
192 'endpoint': self.juju_endpoint,
193 'username': self.juju_user,
194 'secret': self.juju_secret,
195 'cacert': self.juju_ca_cert,
196 'namespace': namespace,
197 'microk8s': microk8s,
198 }
199
200 # Store the cluster configuration so it
201 # can be used for subsequent calls
202 print("Setting config")
203 await self.set_config(cluster_uuid, config)
204
205 else:
206 # This is an existing cluster, so get its config
207 cluster_uuid = reuse_cluster_uuid
208
209 config = self.get_config(cluster_uuid)
210
211 self.juju_endpoint = config['endpoint']
212 self.juju_user = config['username']
213 self.juju_secret = config['secret']
214 self.juju_ca_cert = config['cacert']
215 self.juju_public_key = None
216
217 # Login to the k8s cluster
218 if not self.authenticated:
219 await self.login(cluster_uuid)
220
221 # We're creating a new cluster
222 print("Getting model {}".format(self.get_namespace(cluster_uuid), cluster_uuid=cluster_uuid))
223 model = await self.get_model(
224 self.get_namespace(cluster_uuid),
225 cluster_uuid=cluster_uuid
226 )
227
228 # Disconnect from the model
229 if model and model.is_connected():
230 await model.disconnect()
231
232 return cluster_uuid, True
233
234 """Repo Management"""
235 async def repo_add(
236 self,
237 name: str,
238 url: str,
239 type: str = "charm",
240 ):
241 raise NotImplemented()
242
243 async def repo_list(self):
244 raise NotImplemented()
245
246 async def repo_remove(
247 self,
248 name: str,
249 ):
250 raise NotImplemented()
251
252 """Reset"""
253 async def reset(
254 self,
255 cluster_uuid: str,
256 force: bool = False,
257 uninstall_sw: bool = False
258 ) -> bool:
259 """Reset a cluster
260
261 Resets the Kubernetes cluster by removing the model that represents it.
262
263 :param cluster_uuid str: The UUID of the cluster to reset
264 :return: Returns True if successful or raises an exception.
265 """
266
267 try:
268 if not self.authenticated:
269 await self.login(cluster_uuid)
270
271 if self.controller.is_connected():
272 # Destroy the model
273 namespace = self.get_namespace(cluster_uuid)
274 if await self.has_model(namespace):
275 print("[reset] Destroying model")
276 await self.controller.destroy_model(
277 namespace,
278 destroy_storage=True
279 )
280
281 # Disconnect from the controller
282 print("[reset] Disconnecting controller")
283 await self.controller.disconnect()
284
285 # Destroy the controller (via CLI)
286 print("[reset] Destroying controller")
287 await self.destroy_controller(cluster_uuid)
288
289 """Remove the k8s cloud
290
291 Only remove the k8s cloud if it's not a microk8s cloud,
292 since microk8s is a built-in cloud type.
293 """
294 # microk8s = self.is_microk8s_by_cluster_uuid(cluster_uuid)
295 # if not microk8s:
296 print("[reset] Removing k8s cloud")
297 namespace = self.get_namespace(cluster_uuid)
298 k8s_cloud = "{}-k8s".format(namespace)
299 await self.remove_cloud(k8s_cloud)
300
301 except Exception as ex:
302 print("Caught exception during reset: {}".format(ex))
303
304 """Deployment"""
305
306 async def install(
307 self,
308 cluster_uuid: str,
309 kdu_model: str,
310 atomic: bool = True,
311 timeout: float = 300,
312 params: dict = None,
313 db_dict: dict = None
314 ) -> bool:
315 """Install a bundle
316
317 :param cluster_uuid str: The UUID of the cluster to install to
318 :param kdu_model str: The name or path of a bundle to install
319 :param atomic bool: If set, waits until the model is active and resets
320 the cluster on failure.
321 :param timeout int: The time, in seconds, to wait for the install
322 to finish
323 :param params dict: Key-value pairs of instantiation parameters
324
325 :return: If successful, returns ?
326 """
327
328 if not self.authenticated:
329 print("[install] Logging in to the controller")
330 await self.login(cluster_uuid)
331
332 ##
333 # Get or create the model, based on the namespace the cluster was
334 # instantiated with.
335 namespace = self.get_namespace(cluster_uuid)
336 namespace = "gitlab-demo"
337 self.log.debug("Checking for model named {}".format(namespace))
338 model = await self.get_model(namespace, cluster_uuid=cluster_uuid)
339 if not model:
340 # Create the new model
341 self.log.debug("Adding model: {}".format(namespace))
342 model = await self.add_model(namespace, cluster_uuid=cluster_uuid)
343
344 if model:
345 # TODO: Instantiation parameters
346
347 """
348 "Juju bundle that models the KDU, in any of the following ways:
349 - <juju-repo>/<juju-bundle>
350 - <juju-bundle folder under k8s_models folder in the package>
351 - <juju-bundle tgz file (w/ or w/o extension) under k8s_models folder in the package>
352 - <URL_where_to_fetch_juju_bundle>
353 """
354
355 bundle = kdu_model
356 if kdu_model.startswith("cs:"):
357 bundle = kdu_model
358 elif kdu_model.startswith("http"):
359 # Download the file
360 pass
361 else:
362 # Local file
363
364 # if kdu_model.endswith(".tar.gz") or kdu_model.endswith(".tgz")
365 # Uncompress temporarily
366 # bundle = <uncompressed file>
367 pass
368
369 if not bundle:
370 # Raise named exception that the bundle could not be found
371 raise Exception()
372
373 print("[install] deploying {}".format(bundle))
374 await model.deploy(bundle)
375
376 # Get the application
377 if atomic:
378 # applications = model.applications
379 print("[install] Applications: {}".format(model.applications))
380 for name in model.applications:
381 print("[install] Waiting for {} to settle".format(name))
382 application = model.applications[name]
383 try:
384 # It's not enough to wait for all units to be active;
385 # the application status needs to be active as well.
386 print("Waiting for all units to be active...")
387 await model.block_until(
388 lambda: all(
389 unit.agent_status == 'idle'
390 and application.status in ['active', 'unknown']
391 and unit.workload_status in [
392 'active', 'unknown'
393 ] for unit in application.units
394 ),
395 timeout=timeout
396 )
397 print("All units active.")
398
399 except concurrent.futures._base.TimeoutError:
400 print("[install] Timeout exceeded; resetting cluster")
401 await self.reset(cluster_uuid)
402 return False
403
404 # Wait for the application to be active
405 if model.is_connected():
406 print("[install] Disconnecting model")
407 await model.disconnect()
408
409 return True
410 raise Exception("Unable to install")
411
412 async def instances_list(
413 self,
414 cluster_uuid: str
415 ) -> list:
416 """
417 returns a list of deployed releases in a cluster
418
419 :param cluster_uuid: the cluster
420 :return:
421 """
422 return []
423
424 async def upgrade(
425 self,
426 cluster_uuid: str,
427 kdu_instance: str,
428 kdu_model: str = None,
429 params: dict = None,
430 ) -> str:
431 """Upgrade a model
432
433 :param cluster_uuid str: The UUID of the cluster to upgrade
434 :param kdu_instance str: The unique name of the KDU instance
435 :param kdu_model str: The name or path of the bundle to upgrade to
436 :param params dict: Key-value pairs of instantiation parameters
437
438 :return: If successful, reference to the new revision number of the
439 KDU instance.
440 """
441
442 # TODO: Loop through the bundle and upgrade each charm individually
443
444 """
445 The API doesn't have a concept of bundle upgrades, because there are
446 many possible changes: charm revision, disk, number of units, etc.
447
448 As such, we are only supporting a limited subset of upgrades. We'll
449 upgrade the charm revision but leave storage and scale untouched.
450
451 Scale changes should happen through OSM constructs, and changes to
452 storage would require a redeployment of the service, at least in this
453 initial release.
454 """
455 namespace = self.get_namespace(cluster_uuid)
456 model = await self.get_model(namespace, cluster_uuid=cluster_uuid)
457
458 with open(kdu_model, 'r') as f:
459 bundle = yaml.safe_load(f)
460
461 """
462 {
463 'description': 'Test bundle',
464 'bundle': 'kubernetes',
465 'applications': {
466 'mariadb-k8s': {
467 'charm': 'cs:~charmed-osm/mariadb-k8s-20',
468 'scale': 1,
469 'options': {
470 'password': 'manopw',
471 'root_password': 'osm4u',
472 'user': 'mano'
473 },
474 'series': 'kubernetes'
475 }
476 }
477 }
478 """
479 # TODO: This should be returned in an agreed-upon format
480 for name in bundle['applications']:
481 print(model.applications)
482 application = model.applications[name]
483 print(application)
484
485 path = bundle['applications'][name]['charm']
486
487 try:
488 await application.upgrade_charm(switch=path)
489 except juju.errors.JujuError as ex:
490 if 'already running charm' in str(ex):
491 # We're already running this version
492 pass
493
494 await model.disconnect()
495
496 return True
497 raise NotImplemented()
498
499 """Rollback"""
500 async def rollback(
501 self,
502 cluster_uuid: str,
503 kdu_instance: str,
504 revision: int = 0,
505 ) -> str:
506 """Rollback a model
507
508 :param cluster_uuid str: The UUID of the cluster to rollback
509 :param kdu_instance str: The unique name of the KDU instance
510 :param revision int: The revision to revert to. If omitted, rolls back
511 the previous upgrade.
512
513 :return: If successful, returns the revision of active KDU instance,
514 or raises an exception
515 """
516 raise NotImplemented()
517
518 """Deletion"""
519 async def uninstall(
520 self,
521 cluster_uuid: str,
522 kdu_instance: str,
523 ) -> bool:
524 """Uninstall a KDU instance
525
526 :param cluster_uuid str: The UUID of the cluster to uninstall
527 :param kdu_instance str: The unique name of the KDU instance
528
529 :return: Returns True if successful, or raises an exception
530 """
531 removed = False
532
533 # Remove an application from the model
534 model = await self.get_model(self.get_namespace(cluster_uuid), cluster_uuid=cluster_uuid)
535
536 if model:
537 # Get the application
538 if kdu_instance not in model.applications:
539 # TODO: Raise a named exception
540 raise Exception("Application not found.")
541
542 application = model.applications[kdu_instance]
543
544 # Destroy the application
545 await application.destroy()
546
547 # TODO: Verify removal
548
549 removed = True
550 return removed
551
552 """Introspection"""
553 async def inspect_kdu(
554 self,
555 kdu_model: str,
556 ) -> dict:
557 """Inspect a KDU
558
559 Inspects a bundle and returns a dictionary of config parameters and
560 their default values.
561
562 :param kdu_model str: The name or path of the bundle to inspect.
563
564 :return: If successful, returns a dictionary of available parameters
565 and their default values.
566 """
567
568 kdu = {}
569 with open(kdu_model, 'r') as f:
570 bundle = yaml.safe_load(f)
571
572 """
573 {
574 'description': 'Test bundle',
575 'bundle': 'kubernetes',
576 'applications': {
577 'mariadb-k8s': {
578 'charm': 'cs:~charmed-osm/mariadb-k8s-20',
579 'scale': 1,
580 'options': {
581 'password': 'manopw',
582 'root_password': 'osm4u',
583 'user': 'mano'
584 },
585 'series': 'kubernetes'
586 }
587 }
588 }
589 """
590 # TODO: This should be returned in an agreed-upon format
591 kdu = bundle['applications']
592
593 return kdu
594
595 async def help_kdu(
596 self,
597 kdu_model: str,
598 ) -> str:
599 """View the README
600
601 If available, returns the README of the bundle.
602
603 :param kdu_model str: The name or path of a bundle
604
605 :return: If found, returns the contents of the README.
606 """
607 readme = None
608
609 files = ['README', 'README.txt', 'README.md']
610 path = os.path.dirname(kdu_model)
611 for file in os.listdir(path):
612 if file in files:
613 with open(file, 'r') as f:
614 readme = f.read()
615 break
616
617 return readme
618
619 async def status_kdu(
620 self,
621 cluster_uuid: str,
622 kdu_instance: str,
623 ) -> dict:
624 """Get the status of the KDU
625
626 Get the current status of the KDU instance.
627
628 :param cluster_uuid str: The UUID of the cluster
629 :param kdu_instance str: The unique id of the KDU instance
630
631 :return: Returns a dictionary containing namespace, state, resources,
632 and deployment_time.
633 """
634 status = {}
635
636 model = await self.get_model(self.get_namespace(cluster_uuid), cluster_uuid=cluster_uuid)
637
638 # model = await self.get_model_by_uuid(cluster_uuid)
639 if model:
640 model_status = await model.get_status()
641 status = model_status.applications
642
643 for name in model_status.applications:
644 application = model_status.applications[name]
645 status[name] = {
646 'status': application['status']['status']
647 }
648
649 if model.is_connected():
650 await model.disconnect()
651
652 return status
653
654 # Private methods
655 async def add_k8s(
656 self,
657 cloud_name: str,
658 credentials: str,
659 ) -> bool:
660 """Add a k8s cloud to Juju
661
662 Adds a Kubernetes cloud to Juju, so it can be bootstrapped with a
663 Juju Controller.
664
665 :param cloud_name str: The name of the cloud to add.
666 :param credentials dict: A dictionary representing the output of
667 `kubectl config view --raw`.
668
669 :returns: True if successful, otherwise raises an exception.
670 """
671 cmd = [self.k8scli, "add-k8s", "--local", cloud_name]
672
673 p = subprocess.run(
674 cmd,
675 stdout=subprocess.PIPE,
676 stderr=subprocess.PIPE,
677 # input=yaml.dump(credentials, Dumper=yaml.Dumper).encode("utf-8"),
678 input=credentials.encode("utf-8"),
679 # encoding='ascii'
680 )
681 retcode = p.returncode
682 print("add-k8s return code: {}".format(retcode))
683
684 if retcode > 0:
685 raise Exception(p.stderr)
686 return True
687
688 async def add_model(
689 self,
690 model_name: str,
691 cluster_uuid: str,
692 ) -> juju.model.Model:
693 """Adds a model to the controller
694
695 Adds a new model to the Juju controller
696
697 :param model_name str: The name of the model to add.
698 :returns: The juju.model.Model object of the new model upon success or
699 raises an exception.
700 """
701 if not self.authenticated:
702 await self.login(cluster_uuid)
703
704 self.log.debug("Adding model '{}' to cluster_uuid '{}'".format(model_name, cluster_uuid))
705 model = await self.controller.add_model(
706 model_name,
707 config={'authorized-keys': self.juju_public_key}
708 )
709 return model
710
711 async def bootstrap(
712 self,
713 cloud_name: str,
714 cluster_uuid: str
715 ) -> bool:
716 """Bootstrap a Kubernetes controller
717
718 Bootstrap a Juju controller inside the Kubernetes cluster
719
720 :param cloud_name str: The name of the cloud.
721 :param cluster_uuid str: The UUID of the cluster to bootstrap.
722 :returns: True upon success or raises an exception.
723 """
724 cmd = [self.k8scli, "bootstrap", cloud_name, cluster_uuid]
725 print("Bootstrapping controller {} in cloud {}".format(
726 cluster_uuid, cloud_name
727 ))
728
729 p = subprocess.run(
730 cmd,
731 stdout=subprocess.PIPE,
732 stderr=subprocess.PIPE,
733 # encoding='ascii'
734 )
735 retcode = p.returncode
736
737 if retcode > 0:
738 #
739 if b'already exists' not in p.stderr:
740 raise Exception(p.stderr)
741
742 return True
743
744 async def destroy_controller(
745 self,
746 cluster_uuid: str
747 ) -> bool:
748 """Destroy a Kubernetes controller
749
750 Destroy an existing Kubernetes controller.
751
752 :param cluster_uuid str: The UUID of the cluster to bootstrap.
753 :returns: True upon success or raises an exception.
754 """
755 cmd = [
756 self.k8scli,
757 "destroy-controller",
758 "--destroy-all-models",
759 "--destroy-storage",
760 "-y",
761 cluster_uuid
762 ]
763
764 p = subprocess.run(
765 cmd,
766 stdout=subprocess.PIPE,
767 stderr=subprocess.PIPE,
768 # encoding='ascii'
769 )
770 retcode = p.returncode
771
772 if retcode > 0:
773 #
774 if 'already exists' not in p.stderr:
775 raise Exception(p.stderr)
776
777 def get_config(
778 self,
779 cluster_uuid: str,
780 ) -> dict:
781 """Get the cluster configuration
782
783 Gets the configuration of the cluster
784
785 :param cluster_uuid str: The UUID of the cluster.
786 :return: A dict upon success, or raises an exception.
787 """
788 cluster_config = "{}/{}.yaml".format(self.fs.path, cluster_uuid)
789 if os.path.exists(cluster_config):
790 with open(cluster_config, 'r') as f:
791 config = yaml.safe_load(f.read())
792 return config
793 else:
794 raise Exception(
795 "Unable to locate configuration for cluster {}".format(
796 cluster_uuid
797 )
798 )
799
800 async def get_model(
801 self,
802 model_name: str,
803 cluster_uuid: str,
804 ) -> juju.model.Model:
805 """Get a model from the Juju Controller.
806
807 Note: Model objects returned must call disconnected() before it goes
808 out of scope.
809
810 :param model_name str: The name of the model to get
811 :return The juju.model.Model object if found, or None.
812 """
813 if not self.authenticated:
814 await self.login(cluster_uuid)
815
816 model = None
817 models = await self.controller.list_models()
818 self.log.debug(models)
819 if model_name in models:
820 self.log.debug("Found model: {}".format(model_name))
821 model = await self.controller.get_model(
822 model_name
823 )
824 return model
825
826 def get_namespace(
827 self,
828 cluster_uuid: str,
829 ) -> str:
830 """Get the namespace UUID
831 Gets the namespace's unique name
832
833 :param cluster_uuid str: The UUID of the cluster
834 :returns: The namespace UUID, or raises an exception
835 """
836 config = self.get_config(cluster_uuid)
837
838 # Make sure the name is in the config
839 if 'namespace' not in config:
840 raise Exception("Namespace not found.")
841
842 # TODO: We want to make sure this is unique to the cluster, in case
843 # the cluster is being reused.
844 # Consider pre/appending the cluster id to the namespace string
845 return config['namespace']
846
847 async def has_model(
848 self,
849 model_name: str
850 ) -> bool:
851 """Check if a model exists in the controller
852
853 Checks to see if a model exists in the connected Juju controller.
854
855 :param model_name str: The name of the model
856 :return: A boolean indicating if the model exists
857 """
858 models = await self.controller.list_models()
859
860 if model_name in models:
861 return True
862 return False
863
864 def is_microk8s_by_cluster_uuid(
865 self,
866 cluster_uuid: str,
867 ) -> bool:
868 """Check if a cluster is micro8s
869
870 Checks if a cluster is running microk8s
871
872 :param cluster_uuid str: The UUID of the cluster
873 :returns: A boolean if the cluster is running microk8s
874 """
875 config = self.get_config(cluster_uuid)
876 return config['microk8s']
877
878 def is_microk8s_by_credentials(
879 self,
880 credentials: str,
881 ) -> bool:
882 """Check if a cluster is micro8s
883
884 Checks if a cluster is running microk8s
885
886 :param credentials dict: A dictionary containing the k8s credentials
887 :returns: A boolean if the cluster is running microk8s
888 """
889 creds = yaml.safe_load(credentials)
890 if creds:
891 for context in creds['contexts']:
892 if 'microk8s' in context['name']:
893 return True
894
895 return False
896
897 async def login(self, cluster_uuid):
898 """Login to the Juju controller."""
899
900 if self.authenticated:
901 return
902
903 self.connecting = True
904
905 # Test: Make sure we have the credentials loaded
906 config = self.get_config(cluster_uuid)
907
908 self.juju_endpoint = config['endpoint']
909 self.juju_user = config['username']
910 self.juju_secret = config['secret']
911 self.juju_ca_cert = config['cacert']
912 self.juju_public_key = None
913
914 self.controller = Controller()
915
916 if self.juju_secret:
917 self.log.debug(
918 "Connecting to controller... ws://{} as {}/{}".format(
919 self.juju_endpoint,
920 self.juju_user,
921 self.juju_secret,
922 )
923 )
924 try:
925 await self.controller.connect(
926 endpoint=self.juju_endpoint,
927 username=self.juju_user,
928 password=self.juju_secret,
929 cacert=self.juju_ca_cert,
930 )
931 self.authenticated = True
932 self.log.debug("JujuApi: Logged into controller")
933 except Exception as ex:
934 print(ex)
935 self.log.debug("Caught exception: {}".format(ex))
936 pass
937 else:
938 self.log.fatal("VCA credentials not configured.")
939 self.authenticated = False
940
941 async def logout(self):
942 """Logout of the Juju controller."""
943 print("[logout]")
944 if not self.authenticated:
945 return False
946
947 for model in self.models:
948 print("Logging out of model {}".format(model))
949 await self.models[model].disconnect()
950
951 if self.controller:
952 self.log.debug("Disconnecting controller {}".format(
953 self.controller
954 ))
955 await self.controller.disconnect()
956 self.controller = None
957
958 self.authenticated = False
959
960 async def remove_cloud(
961 self,
962 cloud_name: str,
963 ) -> bool:
964 """Remove a k8s cloud from Juju
965
966 Removes a Kubernetes cloud from Juju.
967
968 :param cloud_name str: The name of the cloud to add.
969
970 :returns: True if successful, otherwise raises an exception.
971 """
972
973 # Remove the bootstrapped controller
974 cmd = [self.k8scli, "remove-k8s", "--client", cloud_name]
975 p = subprocess.run(
976 cmd,
977 stdout=subprocess.PIPE,
978 stderr=subprocess.PIPE,
979 # encoding='ascii'
980 )
981 retcode = p.returncode
982
983 if retcode > 0:
984 raise Exception(p.stderr)
985
986 # Remove the cloud from the local config
987 cmd = [self.k8scli, "remove-cloud", "--client", cloud_name]
988 p = subprocess.run(
989 cmd,
990 stdout=subprocess.PIPE,
991 stderr=subprocess.PIPE,
992 # encoding='ascii'
993 )
994 retcode = p.returncode
995
996 if retcode > 0:
997 raise Exception(p.stderr)
998
999
1000 return True
1001
1002 async def set_config(
1003 self,
1004 cluster_uuid: str,
1005 config: dict,
1006 ) -> bool:
1007 """Save the cluster configuration
1008
1009 Saves the cluster information to the file store
1010
1011 :param cluster_uuid str: The UUID of the cluster
1012 :param config dict: A dictionary containing the cluster configuration
1013 :returns: Boolean upon success or raises an exception.
1014 """
1015
1016 cluster_config = "{}/{}.yaml".format(self.fs.path, cluster_uuid)
1017 if not os.path.exists(cluster_config):
1018 print("Writing config to {}".format(cluster_config))
1019 with open(cluster_config, 'w') as f:
1020 f.write(yaml.dump(config, Dumper=yaml.Dumper))
1021
1022 return True