~verterok/charms/xenial/conn-check/focal

« back to all changes in this revision

Viewing changes to hooks/charmhelpers/core/hookenv.py

  • Committer: Guillermo Gonzalez
  • Date: 2023-06-29 16:33:24 UTC
  • Revision ID: guillermo.gonzalez@canonical.com-20230629163324-03vq3m9qtvu6f6or
update charmhelpers

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Copyright 2014-2015 Canonical Limited.
 
1
# Copyright 2013-2021 Canonical Limited.
2
2
#
3
3
# Licensed under the Apache License, Version 2.0 (the "License");
4
4
# you may not use this file except in compliance with the License.
13
13
# limitations under the License.
14
14
 
15
15
"Interactions with the Juju environment"
16
 
# Copyright 2013 Canonical Ltd.
17
16
#
18
17
# Authors:
19
18
#  Charm Helpers Developers <juju@lists.ubuntu.com>
20
19
 
21
 
from __future__ import print_function
22
20
import copy
23
21
from distutils.version import LooseVersion
 
22
from enum import Enum
24
23
from functools import wraps
 
24
from collections import namedtuple, UserDict
25
25
import glob
26
26
import os
27
27
import json
28
28
import yaml
 
29
import re
29
30
import subprocess
30
31
import sys
31
32
import errno
32
33
import tempfile
33
34
from subprocess import CalledProcessError
34
35
 
35
 
import six
36
 
if not six.PY3:
37
 
    from UserDict import UserDict
38
 
else:
39
 
    from collections import UserDict
 
36
from charmhelpers import deprecate
 
37
 
40
38
 
41
39
CRITICAL = "CRITICAL"
42
40
ERROR = "ERROR"
43
41
WARNING = "WARNING"
44
42
INFO = "INFO"
45
43
DEBUG = "DEBUG"
 
44
TRACE = "TRACE"
46
45
MARKER = object()
 
46
SH_MAX_ARG = 131071
 
47
 
 
48
 
 
49
RANGE_WARNING = ('Passing NO_PROXY string that includes a cidr. '
 
50
                 'This may not be compatible with software you are '
 
51
                 'running in your shell.')
 
52
 
 
53
 
 
54
class WORKLOAD_STATES(Enum):
 
55
    ACTIVE = 'active'
 
56
    BLOCKED = 'blocked'
 
57
    MAINTENANCE = 'maintenance'
 
58
    WAITING = 'waiting'
 
59
 
47
60
 
48
61
cache = {}
49
62
 
64
77
    @wraps(func)
65
78
    def wrapper(*args, **kwargs):
66
79
        global cache
67
 
        key = str((func, args, kwargs))
 
80
        key = json.dumps((func, args, kwargs), sort_keys=True, default=str)
68
81
        try:
69
82
            return cache[key]
70
83
        except KeyError:
92
105
    command = ['juju-log']
93
106
    if level:
94
107
        command += ['-l', level]
95
 
    if not isinstance(message, six.string_types):
 
108
    if not isinstance(message, str):
96
109
        message = repr(message)
97
 
    command += [message]
 
110
    command += [message[:SH_MAX_ARG]]
98
111
    # Missing juju-log should not cause failures in unit tests
99
112
    # Send log output to stderr
100
113
    try:
109
122
            raise
110
123
 
111
124
 
 
125
def function_log(message):
 
126
    """Write a function progress message"""
 
127
    command = ['function-log']
 
128
    if not isinstance(message, str):
 
129
        message = repr(message)
 
130
    command += [message[:SH_MAX_ARG]]
 
131
    # Missing function-log should not cause failures in unit tests
 
132
    # Send function_log output to stderr
 
133
    try:
 
134
        subprocess.call(command)
 
135
    except OSError as e:
 
136
        if e.errno == errno.ENOENT:
 
137
            message = "function-log: {}".format(message)
 
138
            print(message, file=sys.stderr)
 
139
        else:
 
140
            raise
 
141
 
 
142
 
112
143
class Serializable(UserDict):
113
144
    """Wrapper, an object that can be serialized to yaml or json"""
114
145
 
187
218
        raise ValueError('Must specify neither or both of relation_name and service_or_unit')
188
219
 
189
220
 
 
221
def departing_unit():
 
222
    """The departing unit for the current relation hook.
 
223
 
 
224
    Available since juju 2.8.
 
225
 
 
226
    :returns: the departing unit, or None if the information isn't available.
 
227
    :rtype: Optional[str]
 
228
    """
 
229
    return os.environ.get('JUJU_DEPARTING_UNIT', None)
 
230
 
 
231
 
190
232
def local_unit():
191
233
    """Local unit ID"""
192
234
    return os.environ['JUJU_UNIT_NAME']
197
239
    return os.environ.get('JUJU_REMOTE_UNIT', None)
198
240
 
199
241
 
 
242
def application_name():
 
243
    """
 
244
    The name of the deployed application this unit belongs to.
 
245
    """
 
246
    return local_unit().split('/')[0]
 
247
 
 
248
 
200
249
def service_name():
201
 
    """The name service group this unit belongs to"""
202
 
    return local_unit().split('/')[0]
 
250
    """
 
251
    .. deprecated:: 0.19.1
 
252
       Alias for :func:`application_name`.
 
253
    """
 
254
    return application_name()
 
255
 
 
256
 
 
257
def model_name():
 
258
    """
 
259
    Name of the model that this unit is deployed in.
 
260
    """
 
261
    return os.environ['JUJU_MODEL_NAME']
 
262
 
 
263
 
 
264
def model_uuid():
 
265
    """
 
266
    UUID of the model that this unit is deployed in.
 
267
    """
 
268
    return os.environ['JUJU_MODEL_UUID']
 
269
 
 
270
 
 
271
def principal_unit():
 
272
    """Returns the principal unit of this unit, otherwise None"""
 
273
    # Juju 2.2 and above provides JUJU_PRINCIPAL_UNIT
 
274
    principal_unit = os.environ.get('JUJU_PRINCIPAL_UNIT', None)
 
275
    # If it's empty, then this unit is the principal
 
276
    if principal_unit == '':
 
277
        return os.environ['JUJU_UNIT_NAME']
 
278
    elif principal_unit is not None:
 
279
        return principal_unit
 
280
    # For Juju 2.1 and below, let's try work out the principle unit by
 
281
    # the various charms' metadata.yaml.
 
282
    for reltype in relation_types():
 
283
        for rid in relation_ids(reltype):
 
284
            for unit in related_units(rid):
 
285
                md = _metadata_unit(unit)
 
286
                if not md:
 
287
                    continue
 
288
                subordinate = md.pop('subordinate', None)
 
289
                if not subordinate:
 
290
                    return unit
 
291
    return None
203
292
 
204
293
 
205
294
@cached
263
352
        self.implicit_save = True
264
353
        self._prev_dict = None
265
354
        self.path = os.path.join(charm_dir(), Config.CONFIG_FILE_NAME)
266
 
        if os.path.exists(self.path):
 
355
        if os.path.exists(self.path) and os.stat(self.path).st_size:
267
356
            self.load_previous()
268
357
        atexit(self._implicit_save)
269
358
 
283
372
        """
284
373
        self.path = path or self.path
285
374
        with open(self.path) as f:
286
 
            self._prev_dict = json.load(f)
 
375
            try:
 
376
                self._prev_dict = json.load(f)
 
377
            except ValueError as e:
 
378
                log('Found but was unable to parse previous config data, '
 
379
                    'ignoring which will report all values as changed - {}'
 
380
                    .format(str(e)), level=ERROR)
 
381
                return
287
382
        for k, v in copy.deepcopy(self._prev_dict).items():
288
383
            if k not in self:
289
384
                self[k] = v
319
414
 
320
415
        """
321
416
        with open(self.path, 'w') as f:
 
417
            os.fchmod(f.fileno(), 0o600)
322
418
            json.dump(self, f)
323
419
 
324
420
    def _implicit_save(self):
326
422
            self.save()
327
423
 
328
424
 
329
 
@cached
 
425
_cache_config = None
 
426
 
 
427
 
330
428
def config(scope=None):
331
 
    """Juju charm configuration"""
332
 
    config_cmd_line = ['config-get']
333
 
    if scope is not None:
334
 
        config_cmd_line.append(scope)
335
 
    else:
336
 
        config_cmd_line.append('--all')
337
 
    config_cmd_line.append('--format=json')
 
429
    """
 
430
    Get the juju charm configuration (scope==None) or individual key,
 
431
    (scope=str).  The returned value is a Python data structure loaded as
 
432
    JSON from the Juju config command.
 
433
 
 
434
    :param scope: If set, return the value for the specified key.
 
435
    :type scope: Optional[str]
 
436
    :returns: Either the whole config as a Config, or a key from it.
 
437
    :rtype: Any
 
438
    """
 
439
    global _cache_config
 
440
    config_cmd_line = ['config-get', '--all', '--format=json']
338
441
    try:
339
 
        config_data = json.loads(
340
 
            subprocess.check_output(config_cmd_line).decode('UTF-8'))
 
442
        if _cache_config is None:
 
443
            config_data = json.loads(
 
444
                subprocess.check_output(config_cmd_line).decode('UTF-8'))
 
445
            _cache_config = Config(config_data)
341
446
        if scope is not None:
342
 
            return config_data
343
 
        return Config(config_data)
344
 
    except ValueError:
 
447
            return _cache_config.get(scope)
 
448
        return _cache_config
 
449
    except (json.decoder.JSONDecodeError, UnicodeDecodeError) as e:
 
450
        log('Unable to parse output from config-get: config_cmd_line="{}" '
 
451
            'message="{}"'
 
452
            .format(config_cmd_line, str(e)), level=ERROR)
345
453
        return None
346
454
 
347
455
 
348
456
@cached
349
 
def relation_get(attribute=None, unit=None, rid=None):
 
457
def relation_get(attribute=None, unit=None, rid=None, app=None):
350
458
    """Get relation information"""
351
459
    _args = ['relation-get', '--format=json']
 
460
    if app is not None:
 
461
        if unit is not None:
 
462
            raise ValueError("Cannot use both 'unit' and 'app'")
 
463
        _args.append('--app')
352
464
    if rid:
353
465
        _args.append('-r')
354
466
        _args.append(rid)
355
467
    _args.append(attribute or '-')
356
 
    if unit:
357
 
        _args.append(unit)
 
468
    # unit or application name
 
469
    if unit or app:
 
470
        _args.append(unit or app)
358
471
    try:
359
472
        return json.loads(subprocess.check_output(_args).decode('UTF-8'))
360
473
    except ValueError:
365
478
        raise
366
479
 
367
480
 
368
 
def relation_set(relation_id=None, relation_settings=None, **kwargs):
 
481
@cached
 
482
def _relation_set_accepts_file():
 
483
    """Return True if the juju relation-set command accepts a file.
 
484
 
 
485
    Cache the result as it won't change during the execution of a hook, and
 
486
    thus we can make relation_set() more efficient by only checking for the
 
487
    first relation_set() call.
 
488
 
 
489
    :returns: True if relation_set accepts a file.
 
490
    :rtype: bool
 
491
    :raises: subprocess.CalledProcessError if the check fails.
 
492
    """
 
493
    return "--file" in subprocess.check_output(
 
494
        ["relation-set", "--help"], universal_newlines=True)
 
495
 
 
496
 
 
497
def relation_set(relation_id=None, relation_settings=None, app=False, **kwargs):
369
498
    """Set relation information for the current unit"""
370
499
    relation_settings = relation_settings if relation_settings else {}
371
500
    relation_cmd_line = ['relation-set']
372
 
    accepts_file = "--file" in subprocess.check_output(
373
 
        relation_cmd_line + ["--help"], universal_newlines=True)
 
501
    if app:
 
502
        relation_cmd_line.append('--app')
374
503
    if relation_id is not None:
375
504
        relation_cmd_line.extend(('-r', relation_id))
376
505
    settings = relation_settings.copy()
380
509
        # sites pass in things like dicts or numbers.
381
510
        if value is not None:
382
511
            settings[key] = "{}".format(value)
383
 
    if accepts_file:
 
512
    if _relation_set_accepts_file():
384
513
        # --file was introduced in Juju 1.23.2. Use it by default if
385
514
        # available, since otherwise we'll break if the relation data is
386
515
        # too big. Ideally we should tell relation-set to read the data from
435
564
        subprocess.check_output(units_cmd_line).decode('UTF-8')) or []
436
565
 
437
566
 
 
567
def expected_peer_units():
 
568
    """Get a generator for units we expect to join peer relation based on
 
569
    goal-state.
 
570
 
 
571
    The local unit is excluded from the result to make it easy to gauge
 
572
    completion of all peers joining the relation with existing hook tools.
 
573
 
 
574
    Example usage:
 
575
    log('peer {} of {} joined peer relation'
 
576
        .format(len(related_units()),
 
577
                len(list(expected_peer_units()))))
 
578
 
 
579
    This function will raise NotImplementedError if used with juju versions
 
580
    without goal-state support.
 
581
 
 
582
    :returns: iterator
 
583
    :rtype: types.GeneratorType
 
584
    :raises: NotImplementedError
 
585
    """
 
586
    if not has_juju_version("2.4.0"):
 
587
        # goal-state first appeared in 2.4.0.
 
588
        raise NotImplementedError("goal-state")
 
589
    _goal_state = goal_state()
 
590
    return (key for key in _goal_state['units']
 
591
            if '/' in key and key != local_unit())
 
592
 
 
593
 
 
594
def expected_related_units(reltype=None):
 
595
    """Get a generator for units we expect to join relation based on
 
596
    goal-state.
 
597
 
 
598
    Note that you can not use this function for the peer relation, take a look
 
599
    at expected_peer_units() for that.
 
600
 
 
601
    This function will raise KeyError if you request information for a
 
602
    relation type for which juju goal-state does not have information.  It will
 
603
    raise NotImplementedError if used with juju versions without goal-state
 
604
    support.
 
605
 
 
606
    Example usage:
 
607
    log('participant {} of {} joined relation {}'
 
608
        .format(len(related_units()),
 
609
                len(list(expected_related_units())),
 
610
                relation_type()))
 
611
 
 
612
    :param reltype: Relation type to list data for, default is to list data for
 
613
                    the relation type we are currently executing a hook for.
 
614
    :type reltype: str
 
615
    :returns: iterator
 
616
    :rtype: types.GeneratorType
 
617
    :raises: KeyError, NotImplementedError
 
618
    """
 
619
    if not has_juju_version("2.4.4"):
 
620
        # goal-state existed in 2.4.0, but did not list individual units to
 
621
        # join a relation in 2.4.1 through 2.4.3. (LP: #1794739)
 
622
        raise NotImplementedError("goal-state relation unit count")
 
623
    reltype = reltype or relation_type()
 
624
    _goal_state = goal_state()
 
625
    return (key for key in _goal_state['relations'][reltype] if '/' in key)
 
626
 
 
627
 
438
628
@cached
439
629
def relation_for_unit(unit=None, rid=None):
440
 
    """Get the json represenation of a unit's relation"""
 
630
    """Get the json representation of a unit's relation"""
441
631
    unit = unit or remote_unit()
442
632
    relation = relation_get(unit=unit, rid=rid)
443
633
    for key in relation:
478
668
        return yaml.safe_load(md)
479
669
 
480
670
 
 
671
def _metadata_unit(unit):
 
672
    """Given the name of a unit (e.g. apache2/0), get the unit charm's
 
673
    metadata.yaml. Very similar to metadata() but allows us to inspect
 
674
    other units. Unit needs to be co-located, such as a subordinate or
 
675
    principal/primary.
 
676
 
 
677
    :returns: metadata.yaml as a python object.
 
678
 
 
679
    """
 
680
    basedir = os.sep.join(charm_dir().split(os.sep)[:-2])
 
681
    unitdir = 'unit-{}'.format(unit.replace(os.sep, '-'))
 
682
    joineddir = os.path.join(basedir, unitdir, 'charm', 'metadata.yaml')
 
683
    if not os.path.exists(joineddir):
 
684
        return None
 
685
    with open(joineddir) as md:
 
686
        return yaml.safe_load(md)
 
687
 
 
688
 
481
689
@cached
482
690
def relation_types():
483
691
    """Get a list of relation types supported by this charm"""
602
810
    return False
603
811
 
604
812
 
 
813
def _port_op(op_name, port, protocol="TCP"):
 
814
    """Open or close a service network port"""
 
815
    _args = [op_name]
 
816
    icmp = protocol.upper() == "ICMP"
 
817
    if icmp:
 
818
        _args.append(protocol)
 
819
    else:
 
820
        _args.append('{}/{}'.format(port, protocol))
 
821
    try:
 
822
        subprocess.check_call(_args)
 
823
    except subprocess.CalledProcessError:
 
824
        # Older Juju pre 2.3 doesn't support ICMP
 
825
        # so treat it as a no-op if it fails.
 
826
        if not icmp:
 
827
            raise
 
828
 
 
829
 
605
830
def open_port(port, protocol="TCP"):
606
831
    """Open a service network port"""
607
 
    _args = ['open-port']
608
 
    _args.append('{}/{}'.format(port, protocol))
609
 
    subprocess.check_call(_args)
 
832
    _port_op('open-port', port, protocol)
610
833
 
611
834
 
612
835
def close_port(port, protocol="TCP"):
613
836
    """Close a service network port"""
614
 
    _args = ['close-port']
615
 
    _args.append('{}/{}'.format(port, protocol))
616
 
    subprocess.check_call(_args)
 
837
    _port_op('close-port', port, protocol)
617
838
 
618
839
 
619
840
def open_ports(start, end, protocol="TCP"):
630
851
    subprocess.check_call(_args)
631
852
 
632
853
 
 
854
def opened_ports():
 
855
    """Get the opened ports
 
856
 
 
857
    *Note that this will only show ports opened in a previous hook*
 
858
 
 
859
    :returns: Opened ports as a list of strings: ``['8080/tcp', '8081-8083/tcp']``
 
860
    """
 
861
    _args = ['opened-ports', '--format=json']
 
862
    return json.loads(subprocess.check_output(_args).decode('UTF-8'))
 
863
 
 
864
 
633
865
@cached
634
866
def unit_get(attribute):
635
867
    """Get the unit ID for the remote unit"""
751
983
        return wrapper
752
984
 
753
985
 
 
986
class NoNetworkBinding(Exception):
 
987
    pass
 
988
 
 
989
 
754
990
def charm_dir():
755
991
    """Return the root directory of the current charm"""
 
992
    d = os.environ.get('JUJU_CHARM_DIR')
 
993
    if d is not None:
 
994
        return d
756
995
    return os.environ.get('CHARM_DIR')
757
996
 
758
997
 
 
998
def cmd_exists(cmd):
 
999
    """Return True if the specified cmd exists in the path"""
 
1000
    return any(
 
1001
        os.access(os.path.join(path, cmd), os.X_OK)
 
1002
        for path in os.environ["PATH"].split(os.pathsep)
 
1003
    )
 
1004
 
 
1005
 
759
1006
@cached
760
1007
def action_get(key=None):
761
 
    """Gets the value of an action parameter, or all key/value param pairs"""
 
1008
    """Gets the value of an action parameter, or all key/value param pairs."""
762
1009
    cmd = ['action-get']
763
1010
    if key is not None:
764
1011
        cmd.append(key)
767
1014
    return action_data
768
1015
 
769
1016
 
 
1017
@cached
 
1018
@deprecate("moved to action_get()", log=log)
 
1019
def function_get(key=None):
 
1020
    """
 
1021
    .. deprecated::
 
1022
    Gets the value of an action parameter, or all key/value param pairs.
 
1023
    """
 
1024
    cmd = ['function-get']
 
1025
    # Fallback for older charms.
 
1026
    if not cmd_exists('function-get'):
 
1027
        cmd = ['action-get']
 
1028
 
 
1029
    if key is not None:
 
1030
        cmd.append(key)
 
1031
    cmd.append('--format=json')
 
1032
    function_data = json.loads(subprocess.check_output(cmd).decode('UTF-8'))
 
1033
    return function_data
 
1034
 
 
1035
 
770
1036
def action_set(values):
771
 
    """Sets the values to be returned after the action finishes"""
 
1037
    """Sets the values to be returned after the action finishes."""
772
1038
    cmd = ['action-set']
773
1039
    for k, v in list(values.items()):
774
1040
        cmd.append('{}={}'.format(k, v))
775
1041
    subprocess.check_call(cmd)
776
1042
 
777
1043
 
 
1044
@deprecate("moved to action_set()", log=log)
 
1045
def function_set(values):
 
1046
    """
 
1047
    .. deprecated::
 
1048
    Sets the values to be returned after the function finishes.
 
1049
    """
 
1050
    cmd = ['function-set']
 
1051
    # Fallback for older charms.
 
1052
    if not cmd_exists('function-get'):
 
1053
        cmd = ['action-set']
 
1054
 
 
1055
    for k, v in list(values.items()):
 
1056
        cmd.append('{}={}'.format(k, v))
 
1057
    subprocess.check_call(cmd)
 
1058
 
 
1059
 
778
1060
def action_fail(message):
779
 
    """Sets the action status to failed and sets the error message.
 
1061
    """
 
1062
    Sets the action status to failed and sets the error message.
780
1063
 
781
 
    The results set by action_set are preserved."""
 
1064
    The results set by action_set are preserved.
 
1065
    """
782
1066
    subprocess.check_call(['action-fail', message])
783
1067
 
784
1068
 
 
1069
@deprecate("moved to action_fail()", log=log)
 
1070
def function_fail(message):
 
1071
    """
 
1072
    .. deprecated::
 
1073
    Sets the function status to failed and sets the error message.
 
1074
 
 
1075
    The results set by function_set are preserved.
 
1076
    """
 
1077
    cmd = ['function-fail']
 
1078
    # Fallback for older charms.
 
1079
    if not cmd_exists('function-fail'):
 
1080
        cmd = ['action-fail']
 
1081
    cmd.append(message)
 
1082
 
 
1083
    subprocess.check_call(cmd)
 
1084
 
 
1085
 
785
1086
def action_name():
786
1087
    """Get the name of the currently executing action."""
787
1088
    return os.environ.get('JUJU_ACTION_NAME')
788
1089
 
789
1090
 
 
1091
def function_name():
 
1092
    """Get the name of the currently executing function."""
 
1093
    return os.environ.get('JUJU_FUNCTION_NAME') or action_name()
 
1094
 
 
1095
 
790
1096
def action_uuid():
791
1097
    """Get the UUID of the currently executing action."""
792
1098
    return os.environ.get('JUJU_ACTION_UUID')
793
1099
 
794
1100
 
 
1101
def function_id():
 
1102
    """Get the ID of the currently executing function."""
 
1103
    return os.environ.get('JUJU_FUNCTION_ID') or action_uuid()
 
1104
 
 
1105
 
795
1106
def action_tag():
796
1107
    """Get the tag for the currently executing action."""
797
1108
    return os.environ.get('JUJU_ACTION_TAG')
798
1109
 
799
1110
 
800
 
def status_set(workload_state, message):
 
1111
def function_tag():
 
1112
    """Get the tag for the currently executing function."""
 
1113
    return os.environ.get('JUJU_FUNCTION_TAG') or action_tag()
 
1114
 
 
1115
 
 
1116
def status_set(workload_state, message, application=False):
801
1117
    """Set the workload state with a message
802
1118
 
803
1119
    Use status-set to set the workload state with a message which is visible
804
1120
    to the user via juju status. If the status-set command is not found then
805
 
    assume this is juju < 1.23 and juju-log the message unstead.
 
1121
    assume this is juju < 1.23 and juju-log the message instead.
806
1122
 
807
 
    workload_state -- valid juju workload state.
808
 
    message        -- status update message
 
1123
    workload_state   -- valid juju workload state. str or WORKLOAD_STATES
 
1124
    message          -- status update message
 
1125
    application      -- Whether this is an application state set
809
1126
    """
810
 
    valid_states = ['maintenance', 'blocked', 'waiting', 'active']
811
 
    if workload_state not in valid_states:
812
 
        raise ValueError(
813
 
            '{!r} is not a valid workload state'.format(workload_state)
814
 
        )
815
 
    cmd = ['status-set', workload_state, message]
 
1127
    bad_state_msg = '{!r} is not a valid workload state'
 
1128
 
 
1129
    if isinstance(workload_state, str):
 
1130
        try:
 
1131
            # Convert string to enum.
 
1132
            workload_state = WORKLOAD_STATES[workload_state.upper()]
 
1133
        except KeyError:
 
1134
            raise ValueError(bad_state_msg.format(workload_state))
 
1135
 
 
1136
    if workload_state not in WORKLOAD_STATES:
 
1137
        raise ValueError(bad_state_msg.format(workload_state))
 
1138
 
 
1139
    cmd = ['status-set']
 
1140
    if application:
 
1141
        cmd.append('--application')
 
1142
    cmd.extend([workload_state.value, message])
816
1143
    try:
817
1144
        ret = subprocess.call(cmd)
818
1145
        if ret == 0:
820
1147
    except OSError as e:
821
1148
        if e.errno != errno.ENOENT:
822
1149
            raise
823
 
    log_message = 'status-set failed: {} {}'.format(workload_state,
 
1150
    log_message = 'status-set failed: {} {}'.format(workload_state.value,
824
1151
                                                    message)
825
1152
    log(log_message, level='INFO')
826
1153
 
874
1201
 
875
1202
 
876
1203
@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
 
1204
@cached
 
1205
def goal_state():
 
1206
    """Juju goal state values"""
 
1207
    cmd = ['goal-state', '--format=json']
 
1208
    return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
 
1209
 
 
1210
 
 
1211
@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
877
1212
def is_leader():
878
1213
    """Does the current unit hold the juju leadership
879
1214
 
967
1302
                                   universal_newlines=True).strip()
968
1303
 
969
1304
 
970
 
@cached
971
1305
def has_juju_version(minimum_version):
972
1306
    """Return True if the Juju version is at least the provided version"""
973
1307
    return LooseVersion(juju_version()) >= LooseVersion(minimum_version)
1027
1361
@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
1028
1362
def network_get_primary_address(binding):
1029
1363
    '''
 
1364
    Deprecated since Juju 2.3; use network_get()
 
1365
 
1030
1366
    Retrieve the primary network address for a named binding
1031
1367
 
1032
1368
    :param binding: string. The name of a relation of extra-binding
1034
1370
    :raise: NotImplementedError if run on Juju < 2.0
1035
1371
    '''
1036
1372
    cmd = ['network-get', '--primary-address', binding]
1037
 
    return subprocess.check_output(cmd).decode('UTF-8').strip()
 
1373
    try:
 
1374
        response = subprocess.check_output(
 
1375
            cmd,
 
1376
            stderr=subprocess.STDOUT).decode('UTF-8').strip()
 
1377
    except CalledProcessError as e:
 
1378
        if 'no network config found for binding' in e.output.decode('UTF-8'):
 
1379
            raise NoNetworkBinding("No network binding for {}"
 
1380
                                   .format(binding))
 
1381
        else:
 
1382
            raise
 
1383
    return response
 
1384
 
 
1385
 
 
1386
def network_get(endpoint, relation_id=None):
 
1387
    """
 
1388
    Retrieve the network details for a relation endpoint
 
1389
 
 
1390
    :param endpoint: string. The name of a relation endpoint
 
1391
    :param relation_id: int. The ID of the relation for the current context.
 
1392
    :return: dict. The loaded YAML output of the network-get query.
 
1393
    :raise: NotImplementedError if request not supported by the Juju version.
 
1394
    """
 
1395
    if not has_juju_version('2.2'):
 
1396
        raise NotImplementedError(juju_version())  # earlier versions require --primary-address
 
1397
    if relation_id and not has_juju_version('2.3'):
 
1398
        raise NotImplementedError  # 2.3 added the -r option
 
1399
 
 
1400
    cmd = ['network-get', endpoint, '--format', 'yaml']
 
1401
    if relation_id:
 
1402
        cmd.append('-r')
 
1403
        cmd.append(relation_id)
 
1404
    response = subprocess.check_output(
 
1405
        cmd,
 
1406
        stderr=subprocess.STDOUT).decode('UTF-8').strip()
 
1407
    return yaml.safe_load(response)
 
1408
 
 
1409
 
 
1410
def add_metric(*args, **kwargs):
 
1411
    """Add metric values. Values may be expressed with keyword arguments. For
 
1412
    metric names containing dashes, these may be expressed as one or more
 
1413
    'key=value' positional arguments. May only be called from the collect-metrics
 
1414
    hook."""
 
1415
    _args = ['add-metric']
 
1416
    _kvpairs = []
 
1417
    _kvpairs.extend(args)
 
1418
    _kvpairs.extend(['{}={}'.format(k, v) for k, v in kwargs.items()])
 
1419
    _args.extend(sorted(_kvpairs))
 
1420
    try:
 
1421
        subprocess.check_call(_args)
 
1422
        return
 
1423
    except EnvironmentError as e:
 
1424
        if e.errno != errno.ENOENT:
 
1425
            raise
 
1426
    log_message = 'add-metric failed: {}'.format(' '.join(_kvpairs))
 
1427
    log(log_message, level='INFO')
 
1428
 
 
1429
 
 
1430
def meter_status():
 
1431
    """Get the meter status, if running in the meter-status-changed hook."""
 
1432
    return os.environ.get('JUJU_METER_STATUS')
 
1433
 
 
1434
 
 
1435
def meter_info():
 
1436
    """Get the meter status information, if running in the meter-status-changed
 
1437
    hook."""
 
1438
    return os.environ.get('JUJU_METER_INFO')
 
1439
 
 
1440
 
 
1441
def iter_units_for_relation_name(relation_name):
 
1442
    """Iterate through all units in a relation
 
1443
 
 
1444
    Generator that iterates through all the units in a relation and yields
 
1445
    a named tuple with rid and unit field names.
 
1446
 
 
1447
    Usage:
 
1448
    data = [(u.rid, u.unit)
 
1449
            for u in iter_units_for_relation_name(relation_name)]
 
1450
 
 
1451
    :param relation_name: string relation name
 
1452
    :yield: Named Tuple with rid and unit field names
 
1453
    """
 
1454
    RelatedUnit = namedtuple('RelatedUnit', 'rid, unit')
 
1455
    for rid in relation_ids(relation_name):
 
1456
        for unit in related_units(rid):
 
1457
            yield RelatedUnit(rid, unit)
 
1458
 
 
1459
 
 
1460
def ingress_address(rid=None, unit=None):
 
1461
    """
 
1462
    Retrieve the ingress-address from a relation when available.
 
1463
    Otherwise, return the private-address.
 
1464
 
 
1465
    When used on the consuming side of the relation (unit is a remote
 
1466
    unit), the ingress-address is the IP address that this unit needs
 
1467
    to use to reach the provided service on the remote unit.
 
1468
 
 
1469
    When used on the providing side of the relation (unit == local_unit()),
 
1470
    the ingress-address is the IP address that is advertised to remote
 
1471
    units on this relation. Remote units need to use this address to
 
1472
    reach the local provided service on this unit.
 
1473
 
 
1474
    Note that charms may document some other method to use in
 
1475
    preference to the ingress_address(), such as an address provided
 
1476
    on a different relation attribute or a service discovery mechanism.
 
1477
    This allows charms to redirect inbound connections to their peers
 
1478
    or different applications such as load balancers.
 
1479
 
 
1480
    Usage:
 
1481
    addresses = [ingress_address(rid=u.rid, unit=u.unit)
 
1482
                 for u in iter_units_for_relation_name(relation_name)]
 
1483
 
 
1484
    :param rid: string relation id
 
1485
    :param unit: string unit name
 
1486
    :side effect: calls relation_get
 
1487
    :return: string IP address
 
1488
    """
 
1489
    settings = relation_get(rid=rid, unit=unit)
 
1490
    return (settings.get('ingress-address') or
 
1491
            settings.get('private-address'))
 
1492
 
 
1493
 
 
1494
def egress_subnets(rid=None, unit=None):
 
1495
    """
 
1496
    Retrieve the egress-subnets from a relation.
 
1497
 
 
1498
    This function is to be used on the providing side of the
 
1499
    relation, and provides the ranges of addresses that client
 
1500
    connections may come from. The result is uninteresting on
 
1501
    the consuming side of a relation (unit == local_unit()).
 
1502
 
 
1503
    Returns a stable list of subnets in CIDR format.
 
1504
    eg. ['192.168.1.0/24', '2001::F00F/128']
 
1505
 
 
1506
    If egress-subnets is not available, falls back to using the published
 
1507
    ingress-address, or finally private-address.
 
1508
 
 
1509
    :param rid: string relation id
 
1510
    :param unit: string unit name
 
1511
    :side effect: calls relation_get
 
1512
    :return: list of subnets in CIDR format. eg. ['192.168.1.0/24', '2001::F00F/128']
 
1513
    """
 
1514
    def _to_range(addr):
 
1515
        if re.search(r'^(?:\d{1,3}\.){3}\d{1,3}$', addr) is not None:
 
1516
            addr += '/32'
 
1517
        elif ':' in addr and '/' not in addr:  # IPv6
 
1518
            addr += '/128'
 
1519
        return addr
 
1520
 
 
1521
    settings = relation_get(rid=rid, unit=unit)
 
1522
    if 'egress-subnets' in settings:
 
1523
        return [n.strip() for n in settings['egress-subnets'].split(',') if n.strip()]
 
1524
    if 'ingress-address' in settings:
 
1525
        return [_to_range(settings['ingress-address'])]
 
1526
    if 'private-address' in settings:
 
1527
        return [_to_range(settings['private-address'])]
 
1528
    return []  # Should never happen
 
1529
 
 
1530
 
 
1531
def unit_doomed(unit=None):
 
1532
    """Determines if the unit is being removed from the model
 
1533
 
 
1534
    Requires Juju 2.4.1.
 
1535
 
 
1536
    :param unit: string unit name, defaults to local_unit
 
1537
    :side effect: calls goal_state
 
1538
    :side effect: calls local_unit
 
1539
    :side effect: calls has_juju_version
 
1540
    :return: True if the unit is being removed, already gone, or never existed
 
1541
    """
 
1542
    if not has_juju_version("2.4.1"):
 
1543
        # We cannot risk blindly returning False for 'we don't know',
 
1544
        # because that could cause data loss; if call sites don't
 
1545
        # need an accurate answer, they likely don't need this helper
 
1546
        # at all.
 
1547
        # goal-state existed in 2.4.0, but did not handle removals
 
1548
        # correctly until 2.4.1.
 
1549
        raise NotImplementedError("is_doomed")
 
1550
    if unit is None:
 
1551
        unit = local_unit()
 
1552
    gs = goal_state()
 
1553
    units = gs.get('units', {})
 
1554
    if unit not in units:
 
1555
        return True
 
1556
    # I don't think 'dead' units ever show up in the goal-state, but
 
1557
    # check anyway in addition to 'dying'.
 
1558
    return units[unit]['status'] in ('dying', 'dead')
 
1559
 
 
1560
 
 
1561
def env_proxy_settings(selected_settings=None):
 
1562
    """Get proxy settings from process environment variables.
 
1563
 
 
1564
    Get charm proxy settings from environment variables that correspond to
 
1565
    juju-http-proxy, juju-https-proxy juju-no-proxy (available as of 2.4.2, see
 
1566
    lp:1782236) and juju-ftp-proxy in a format suitable for passing to an
 
1567
    application that reacts to proxy settings passed as environment variables.
 
1568
    Some applications support lowercase or uppercase notation (e.g. curl), some
 
1569
    support only lowercase (e.g. wget), there are also subjectively rare cases
 
1570
    of only uppercase notation support. no_proxy CIDR and wildcard support also
 
1571
    varies between runtimes and applications as there is no enforced standard.
 
1572
 
 
1573
    Some applications may connect to multiple destinations and expose config
 
1574
    options that would affect only proxy settings for a specific destination
 
1575
    these should be handled in charms in an application-specific manner.
 
1576
 
 
1577
    :param selected_settings: format only a subset of possible settings
 
1578
    :type selected_settings: list
 
1579
    :rtype: Option(None, dict[str, str])
 
1580
    """
 
1581
    SUPPORTED_SETTINGS = {
 
1582
        'http': 'HTTP_PROXY',
 
1583
        'https': 'HTTPS_PROXY',
 
1584
        'no_proxy': 'NO_PROXY',
 
1585
        'ftp': 'FTP_PROXY'
 
1586
    }
 
1587
    if selected_settings is None:
 
1588
        selected_settings = SUPPORTED_SETTINGS
 
1589
 
 
1590
    selected_vars = [v for k, v in SUPPORTED_SETTINGS.items()
 
1591
                     if k in selected_settings]
 
1592
    proxy_settings = {}
 
1593
    for var in selected_vars:
 
1594
        var_val = os.getenv(var)
 
1595
        if var_val:
 
1596
            proxy_settings[var] = var_val
 
1597
            proxy_settings[var.lower()] = var_val
 
1598
        # Now handle juju-prefixed environment variables. The legacy vs new
 
1599
        # environment variable usage is mutually exclusive
 
1600
        charm_var_val = os.getenv('JUJU_CHARM_{}'.format(var))
 
1601
        if charm_var_val:
 
1602
            proxy_settings[var] = charm_var_val
 
1603
            proxy_settings[var.lower()] = charm_var_val
 
1604
    if 'no_proxy' in proxy_settings:
 
1605
        if _contains_range(proxy_settings['no_proxy']):
 
1606
            log(RANGE_WARNING, level=WARNING)
 
1607
    return proxy_settings if proxy_settings else None
 
1608
 
 
1609
 
 
1610
def _contains_range(addresses):
 
1611
    """Check for cidr or wildcard domain in a string.
 
1612
 
 
1613
    Given a string comprising a comma separated list of ip addresses
 
1614
    and domain names, determine whether the string contains IP ranges
 
1615
    or wildcard domains.
 
1616
 
 
1617
    :param addresses: comma separated list of domains and ip addresses.
 
1618
    :type addresses: str
 
1619
    """
 
1620
    return (
 
1621
        # Test for cidr (e.g. 10.20.20.0/24)
 
1622
        "/" in addresses or
 
1623
        # Test for wildcard domains (*.foo.com or .foo.com)
 
1624
        "*" in addresses or
 
1625
        addresses.startswith(".") or
 
1626
        ",." in addresses or
 
1627
        " ." in addresses)
 
1628
 
 
1629
 
 
1630
def is_subordinate():
 
1631
    """Check whether charm is subordinate in unit metadata.
 
1632
 
 
1633
    :returns: True if unit is subordniate, False otherwise.
 
1634
    :rtype: bool
 
1635
    """
 
1636
    return metadata().get('subordinate') is True