├── metadata.yaml ├── reactive └── __init__.py ├── constraints-juju29.txt ├── constraints-juju3.txt ├── requirements.txt ├── tests ├── charm-minimal │ ├── config.yaml │ ├── layer.yaml │ ├── wheelhouse.txt │ ├── reactive │ │ └── minimal.py │ └── metadata.yaml ├── bundles │ ├── overlays │ │ └── local-charm-overlay.yaml.j2 │ └── minimal.yaml ├── charm-minimal-no-venv │ ├── layer.yaml │ └── metadata.yaml └── tests.yaml ├── .gitignore ├── test-requirements.txt ├── Makefile ├── unit_tests ├── __init__.py ├── utils.py └── test_lib_charms_layer_basic.py ├── hooks └── hook.template ├── copyright ├── layer.yaml ├── README.md ├── tox.ini ├── lib └── charms │ └── layer │ ├── __init__.py │ ├── execd.py │ └── basic.py ├── wheelhouse.txt ├── .github └── workflows │ └── main.yml ├── bin └── charm-env └── LICENSE /metadata.yaml: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /reactive/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /constraints-juju29.txt: -------------------------------------------------------------------------------- 1 | juju < 3.0 -------------------------------------------------------------------------------- /constraints-juju3.txt: -------------------------------------------------------------------------------- 1 | juju>3.0.0,<4.0.0 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mock 2 | flake8 3 | pytest 4 | -------------------------------------------------------------------------------- /tests/charm-minimal/config.yaml: -------------------------------------------------------------------------------- 1 | options: {} 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *~ 3 | .ropeproject 4 | .settings 5 | .tox 6 | *.charm -------------------------------------------------------------------------------- /tests/bundles/overlays/local-charm-overlay.yaml.j2: -------------------------------------------------------------------------------- 1 | comment: this bundle overlay intentionally left blank 2 | -------------------------------------------------------------------------------- /tests/charm-minimal-no-venv/layer.yaml: -------------------------------------------------------------------------------- 1 | includes: [layer:charm-minimal] 2 | options: 3 | basic: 4 | use_venv: false 5 | -------------------------------------------------------------------------------- /tests/charm-minimal/layer.yaml: -------------------------------------------------------------------------------- 1 | includes: ['layer:basic'] 2 | options: 3 | basic: 4 | packages: ['libpq-dev'] 5 | repo: https://github.com/juju-solutions/layer-basic.git 6 | -------------------------------------------------------------------------------- /tests/tests.yaml: -------------------------------------------------------------------------------- 1 | charm_name: minimal 2 | gate_bundles: 3 | - minimal 4 | tests: 5 | - zaza.charm_tests.noop.tests.NoopTest 6 | test_options: 7 | force_deploy: 8 | - minimal 9 | -------------------------------------------------------------------------------- /tests/charm-minimal/wheelhouse.txt: -------------------------------------------------------------------------------- 1 | # We add a wheel that needs to be built from source to validate that this works 2 | psycopg2;python_version < '3.8' 3 | psycopg;python_version >= '3.8' 4 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | # Use latest so that layer-basic tests against main branch 2 | git+https://github.com/juju/charm-tools.git#egg=charm-tools 3 | git+https://github.com/openstack-charmers/zaza.git#egg=zaza 4 | -------------------------------------------------------------------------------- /tests/charm-minimal/reactive/minimal.py: -------------------------------------------------------------------------------- 1 | import charmhelpers 2 | import charms.reactive as reactive 3 | 4 | 5 | @reactive.when_not('non-existent-flag') 6 | def status_set(): 7 | charmhelpers.core.hookenv.status_set('active', 'Unit is ready') 8 | -------------------------------------------------------------------------------- /tests/charm-minimal/metadata.yaml: -------------------------------------------------------------------------------- 1 | name: minimal 2 | summary: minimal charm used to functionally test layer-basic 3 | description: reactive charm with some tricky deps to test venv bootstrapping 4 | maintainer: Juju Developers 5 | tags: 6 | - CI 7 | series: 8 | - trusty 9 | - xenial 10 | - bionic 11 | - eoan 12 | - focal 13 | -------------------------------------------------------------------------------- /tests/charm-minimal-no-venv/metadata.yaml: -------------------------------------------------------------------------------- 1 | name: minimal-no-venv 2 | summary: minimal charm used to functionally test layer-basic 3 | description: reactive charm with some tricky deps to test venv bootstrapping 4 | maintainer: Juju Developers 5 | tags: 6 | - CI 7 | series: 8 | - trusty 9 | - xenial 10 | - bionic 11 | - eoan 12 | - focal 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make 2 | 3 | all: lint unit_test 4 | 5 | 6 | .PHONY: clean 7 | clean: 8 | @rm -rf .tox 9 | 10 | .PHONY: apt_prereqs 11 | apt_prereqs: 12 | @# Need tox, but don't install the apt version unless we have to (don't want to conflict with pip) 13 | @which tox >/dev/null || (sudo apt-get install -y python-pip && sudo pip install tox) 14 | 15 | .PHONY: lint 16 | lint: apt_prereqs 17 | @tox --notest 18 | @PATH=.tox/py34/bin:.tox/py35/bin flake8 $(wildcard hooks reactive lib unit_tests tests) 19 | @charm proof 20 | 21 | .PHONY: unit_test 22 | unit_test: apt_prereqs 23 | @echo Starting tests... 24 | tox 25 | -------------------------------------------------------------------------------- /unit_tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Canonical Ltd 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | import sys 14 | 15 | sys.path.append('lib') 16 | -------------------------------------------------------------------------------- /hooks/hook.template: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Load modules from $JUJU_CHARM_DIR/lib 4 | import sys 5 | sys.path.append('lib') 6 | 7 | from charms.layer import basic # noqa 8 | basic.bootstrap_charm_deps() 9 | 10 | from charmhelpers.core import hookenv # noqa 11 | hookenv.atstart(basic.init_config_states) 12 | hookenv.atexit(basic.clear_config_states) 13 | 14 | 15 | # This will load and run the appropriate @hook and other decorated 16 | # handlers from $JUJU_CHARM_DIR/reactive, $JUJU_CHARM_DIR/hooks/reactive, 17 | # and $JUJU_CHARM_DIR/hooks/relations. 18 | # 19 | # See https://jujucharms.com/docs/stable/authors-charm-building 20 | # for more information on this pattern. 21 | from charms.reactive import main # noqa 22 | main() 23 | -------------------------------------------------------------------------------- /copyright: -------------------------------------------------------------------------------- 1 | Format: http://dep.debian.net/deps/dep5/ 2 | 3 | Files: * 4 | Copyright: Copyright 2015-2017, Canonical Ltd., All Rights Reserved. 5 | License: Apache License 2.0 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | . 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | . 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | -------------------------------------------------------------------------------- /tests/bundles/minimal.yaml: -------------------------------------------------------------------------------- 1 | # Disable all bundles that aren't 'latest', currently focal 2 | # See readme for details 3 | applications: 4 | #minimal-trusty: 5 | #series: trusty 6 | #charm: /tmp/charm-builds/minimal 7 | #num_units: 1 8 | #minimal-xenial: 9 | #series: xenial 10 | #charm: /tmp/charm-builds/minimal 11 | #num_units: 1 12 | #minimal-bionic: 13 | #series: bionic 14 | #charm: /tmp/charm-builds/minimal 15 | #num_units: 1 16 | minimal-focal: 17 | series: focal 18 | charm: ../../minimal.charm 19 | num_units: 1 20 | minimal-binary-wheels-focal: 21 | series: focal 22 | charm: ../../minimal-binary-wheels.charm 23 | num_units: 1 24 | #minimal-no-venv-trusty: 25 | #series: trusty 26 | #charm: /tmp/charm-builds/minimal-no-venv 27 | #num_units: 1 28 | #minimal-no-venv-xenial: 29 | #series: xenial 30 | #charm: /tmp/charm-builds/minimal-no-venv 31 | #num_units: 1 32 | #minimal-no-venv-bionic: 33 | #series: bionic 34 | #charm: /tmp/charm-builds/minimal-no-venv 35 | #num_units: 1 36 | minimal-no-venv-focal: 37 | series: focal 38 | charm: ../../minimal-no-venv.charm 39 | num_units: 1 40 | -------------------------------------------------------------------------------- /layer.yaml: -------------------------------------------------------------------------------- 1 | includes: ['layer:options'] 2 | exclude: ['.travis.yml', 'tests', 'tox.ini', 'test-requirements.txt', 'unit_tests'] 3 | defines: 4 | packages: 5 | type: array 6 | default: [] 7 | description: Additional packages to be installed at time of bootstrap. 8 | python_packages: 9 | type: array 10 | default: [] 11 | description: > 12 | Additional Python packages to be installed at time of bootstrap. 13 | These should be in standard Python requirement form. This is useful 14 | if you have Python libraries that your charm code depends on but which 15 | require compilation on the target architecture, and thus can't be 16 | pre-packaged in the wheelhouse. 17 | use_venv: 18 | type: boolean 19 | default: true 20 | description: > 21 | Install charm dependencies (wheelhouse) into a Python virtual environment 22 | to help avoid conflicts with other charms or libraries on the machine. 23 | include_system_packages: 24 | type: boolean 25 | default: false 26 | description: > 27 | If using a virtual environment, allow the venv to see Python packages 28 | installed at the system level. This reduces isolation, but is necessary 29 | to use Python packages installed via apt-get. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | Apache 2.0 License 3 | 4 | This is the base layer for all reactive Charms. It provides all of the standard 5 | Juju hooks and starts the reactive framework when these hooks get executed. It 6 | also bootstraps the [charm-helpers][] and `charms.reactive` libraries, and all 7 | of their dependencies for use by the Charm. 8 | 9 | # Usage 10 | 11 | Go read the [layer-basic documentation][] for more info on how to use this 12 | layer. It is now hosted together with the charms.reactive documentation in order 13 | to reduce the amount of places a charmer needs to search for info. 14 | 15 | # Python Versions, Built charms and what they can run on. 16 | 17 | Due to major backwards incompatibilities between Python 3.10 and previous 18 | versions, there is a compatibility break between Python 3.8 (focal) and earlier 19 | versions of Python. Why Python 3.8 rather than 3.10? Mostly due to all of the 20 | incompatibilities being deprecated and available in Python 3.8, so the ability 21 | of authors to test at that version. 22 | 23 | As the charmhub.io now offers a place to build reactive charms on a per series 24 | ('base') and architecture basis, layer-basic supports *at least* Python 3.5 to 25 | Python 3.10. However, a charm *built* on Python 3.5 won't work on Python 3.8 26 | and vice-versa. The objective going forwards is to build the charm for the 27 | *base* that the charm will run on. 28 | 29 | [charm-helpers]: https://pythonhosted.org/charmhelpers/ 30 | [layer-basic documentation]: https://charmsreactive.readthedocs.io/en/latest/layer-basic.html 31 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist=True 3 | envlist = flake8, py3 4 | skip_missing_interpreters = True 5 | 6 | [testenv] 7 | allowlist_externals = 8 | /bin/bash 9 | deps = 10 | -r{toxinidir}/requirements.txt 11 | commands = /bin/bash -c 'py.test -v' 12 | 13 | [testenv:flake8] 14 | commands = flake8 reactive lib tests unit_tests 15 | 16 | [testenv:func] 17 | basepython = python3 18 | deps = 19 | -c {env:TEST_CONSTRAINTS_FILE:constraints-juju3.txt} 20 | -r{toxinidir}/test-requirements.txt 21 | allowlist_externals = 22 | /bin/bash 23 | /bin/ln 24 | /bin/mkdir 25 | /bin/readlink 26 | /bin/rm 27 | /snap/bin/juju 28 | setenv = 29 | CHARM_LAYERS_DIR=/tmp/charm-builds/_tmp/layers 30 | passenv = 31 | HOME 32 | TEST_* 33 | commands = 34 | /bin/rm -rf /tmp/charm-builds/_tmp /tmp/charm-builds/minimal 35 | /bin/rm -rf /tmp/charm-builds/_tmp /tmp/charm-builds/minimal-binary-wheels 36 | /bin/rm -rf /tmp/charm-builds/_tmp /tmp/charm-builds/minimal-no-venv 37 | /bin/mkdir -p /tmp/charm-builds/_tmp/layers 38 | /bin/bash -c '/bin/ln -sf $(readlink --canonicalize {toxinidir}) /tmp/charm-builds/_tmp/layers/layer-basic' 39 | /bin/bash -c '/bin/ln -sf $(readlink --canonicalize {toxinidir}/tests/charm-minimal) /tmp/charm-builds/_tmp/layers/charm-minimal' 40 | charm-build -F --log-level DEBUG tests/charm-minimal 41 | charm-build -F --log-level DEBUG --binary-wheels -n minimal-binary-wheels tests/charm-minimal 42 | charm-build -F --log-level DEBUG tests/charm-minimal-no-venv 43 | functest-run-suite --keep-model 44 | 45 | 46 | [flake8] 47 | # E741: ambiguous variable name 48 | # W504: # line break after binary operator (have to ignore either this or W503) 49 | ignore = 50 | E741, 51 | W504 52 | -------------------------------------------------------------------------------- /lib/charms/layer/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from importlib import import_module 3 | from pathlib import Path 4 | 5 | 6 | def import_layer_libs(): 7 | """ 8 | Ensure that all layer libraries are imported. 9 | 10 | This makes it possible to do the following: 11 | 12 | from charms import layer 13 | 14 | layer.foo.do_foo_thing() 15 | 16 | Note: This function must be called after bootstrap. 17 | """ 18 | for module_file in Path('lib/charms/layer').glob('*'): 19 | module_name = module_file.stem 20 | if module_name in ('__init__', 'basic', 'execd') or not ( 21 | module_file.suffix == '.py' or module_file.is_dir() 22 | ): 23 | continue 24 | import_module('charms.layer.{}'.format(module_name)) 25 | 26 | 27 | # Terrible hack to support the old terrible interface. 28 | # Try to get people to call layer.options.get() instead so 29 | # that we can remove this garbage. 30 | # Cribbed from https://stackoverfLow.com/a/48100440/4941864 31 | class OptionsBackwardsCompatibilityHack(sys.modules[__name__].__class__): 32 | def __call__(self, section=None, layer_file=None): 33 | if layer_file is None: 34 | return self.get(section=section) 35 | else: 36 | return self.get(section=section, 37 | layer_file=Path(layer_file)) 38 | 39 | 40 | def patch_options_interface(): 41 | from charms.layer import options 42 | if sys.version_info.minor >= 5: 43 | options.__class__ = OptionsBackwardsCompatibilityHack 44 | else: 45 | # Py 3.4 doesn't support changing the __class__, so we have to do it 46 | # another way. The last line is needed because we already have a 47 | # reference that doesn't get updated with sys.modules. 48 | name = options.__name__ 49 | hack = OptionsBackwardsCompatibilityHack(name) 50 | hack.get = options.get 51 | sys.modules[name] = hack 52 | sys.modules[__name__].options = hack 53 | 54 | 55 | try: 56 | patch_options_interface() 57 | except ImportError: 58 | # This may fail if pyyaml hasn't been installed yet. But in that 59 | # case, the bootstrap logic will try it again once it has. 60 | pass 61 | -------------------------------------------------------------------------------- /wheelhouse.txt: -------------------------------------------------------------------------------- 1 | # pip is pinned to <19.0 to avoid https://github.com/pypa/pip/issues/6164 2 | # even with installing setuptools before upgrading pip ends up with pip seeing 3 | # the older setuptools at the system level if include_system_packages is true 4 | pip>=18.1,<19.0;python_version < '3.8' 5 | # https://github.com/juju-solutions/layer-basic/issues/201 6 | pip<22.1;python_version >= '3.8' 7 | # pin Jinja2, PyYAML and MarkupSafe to the last versions supporting python 3.5 8 | # for trusty 9 | Jinja2==2.10;python_version >= '3.0' and python_version <= '3.4' # py3 trusty 10 | Jinja2==2.11;python_version == '2.7' or python_version == '3.5' # py27, py35 11 | Jinja2<3.2;python_version >= '3.6' # py36 and on 12 | 13 | # Cython is required to build PyYAML. To find out the supported versions check 14 | # https://github.com/cython/cython/issues/2800 15 | Cython<3.0.0 16 | 17 | PyYAML==5.2;python_version >= '3.0' and python_version <= '3.4' # py3 trusty 18 | PyYAML<5.4;python_version == '2.7' or python_version <= '3.6' # xenial and bionic 19 | PyYAML<7.0.0;python_version >= '3.7' # >= focal 20 | 21 | pyaml<23.0.0 # See http://pad.lv/2020788 22 | 23 | MarkupSafe<2.0.0;python_version < '3.6' 24 | MarkupSafe<2.1.0;python_version == '3.6' # Just for python 3.6 25 | MarkupSafe<2.2.0;python_version >= '3.7' # newer pythons 26 | 27 | setuptools<42;python_version < '3.8' 28 | # https://github.com/juju-solutions/layer-basic/issues/201 29 | setuptools<62.2.0;python_version >= '3.8' and python_version<'3.12' 30 | setuptools>=62.2.0;python_version >= '3.12' 31 | setuptools-scm<=1.17.0;python_version < '3.8' 32 | # https://github.com/pypa/setuptools_scm/issues/722 33 | setuptools-scm<7;python_version >= '3.8' and python_version < '3.12' 34 | setuptools-scm>=7;python_version >= '3.12' 35 | flit_core<4.0.0;python_version >= '3.8' 36 | flit_scm<=1.7.0;python_version >= '3.8' 37 | charmhelpers>=0.4.0,<2.0.0 38 | charms.reactive>=0.1.0,<2.0.0 39 | wheel<0.34;python_version < '3.8' 40 | wheel<1.0;python_version >= '3.8' 41 | # pin netaddr to avoid pulling importlib-resources 42 | netaddr<=0.7.19 43 | 44 | # https://github.com/python-trio/sniffio/pull/49 45 | anyio<3.7.0;python_version >= '3.8' and python_version < '3.12' 46 | # sniffio is pulled in for anyio, it is not needed otherwise so make it match 47 | sniffio<1.3.1;python_version >= '3.8' and python_version < '3.12' # 1.3.1 requires setuptools>=64 48 | immutables<0.16;python_version < '3.8' # >=0.16 requires setuptools>=42 49 | 50 | 51 | # https://bugs.launchpad.net/charm-gcp-integrator/+bug/2098017 52 | pbr<6.1.1 -------------------------------------------------------------------------------- /unit_tests/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Canonical Ltd 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | """Unit test helpers from https://github.com/openstack/charms.openstack/""" 14 | 15 | import contextlib 16 | import io 17 | import mock 18 | import unittest 19 | 20 | 21 | @contextlib.contextmanager 22 | def patch_open(): 23 | '''Patch open() to allow mocking both open() itself and the file that is 24 | yielded. 25 | 26 | Yields the mock for "open" and "file", respectively.''' 27 | mock_open = mock.MagicMock(spec=open) 28 | mock_file = mock.MagicMock(spec=io.FileIO) 29 | 30 | @contextlib.contextmanager 31 | def stub_open(*args, **kwargs): 32 | mock_open(*args, **kwargs) 33 | yield mock_file 34 | 35 | with mock.patch('builtins.open', stub_open): 36 | yield mock_open, mock_file 37 | 38 | 39 | class BaseTestCase(unittest.TestCase): 40 | 41 | def setUp(self): 42 | self._patches = {} 43 | self._patches_start = {} 44 | 45 | def tearDown(self): 46 | for k, v in self._patches.items(): 47 | v.stop() 48 | setattr(self, k, None) 49 | self._patches = None 50 | self._patches_start = None 51 | 52 | def patch_object(self, obj, attr, return_value=None, name=None, new=None, 53 | **kwargs): 54 | if name is None: 55 | name = attr 56 | if new is not None: 57 | mocked = mock.patch.object(obj, attr, new=new, **kwargs) 58 | else: 59 | mocked = mock.patch.object(obj, attr, **kwargs) 60 | self._patches[name] = mocked 61 | started = mocked.start() 62 | if new is None: 63 | started.return_value = return_value 64 | self._patches_start[name] = started 65 | setattr(self, name, started) 66 | 67 | def patch(self, item, return_value=None, name=None, new=None, **kwargs): 68 | if name is None: 69 | raise RuntimeError("Must pass 'name' to .patch()") 70 | if new is not None: 71 | mocked = mock.patch(item, new=new, **kwargs) 72 | else: 73 | mocked = mock.patch(item, **kwargs) 74 | self._patches[name] = mocked 75 | started = mocked.start() 76 | if new is None: 77 | started.return_value = return_value 78 | self._patches_start[name] = started 79 | setattr(self, name, started) 80 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test Suite 2 | on: [pull_request] 3 | 4 | jobs: 5 | lint: 6 | name: Lint 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python: [3.8, 3.9, "3.10"] 11 | steps: 12 | - name: Check out code 13 | uses: actions/checkout@v4 14 | - name: Setup Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: ${{ matrix.python }} 18 | - name: Install Dependencies 19 | run: | 20 | pip install tox 21 | - name: Run lint 22 | run: tox -e flake8 23 | lint-old-python: 24 | name: Lint on older Python versions 25 | runs-on: ubuntu-20.04 26 | strategy: 27 | matrix: 28 | python: [3.5, 3.6, 3.7] 29 | steps: 30 | - name: Check out code 31 | uses: actions/checkout@v4 32 | - name: Setup Python 33 | uses: actions/setup-python@v5 34 | env: 35 | PIP_TRUSTED_HOST: "pypi.python.org pypi.org files.pythonhosted.org" 36 | with: 37 | python-version: ${{ matrix.python }} 38 | - name: Install Dependencies 39 | run: | 40 | pip install tox 41 | - name: Run lint 42 | run: tox -e flake8 43 | functional-test: 44 | name: Functional test with LXD 45 | runs-on: ubuntu-latest 46 | strategy: 47 | matrix: 48 | juju: ["3", "2.9"] 49 | timeout-minutes: 360 50 | steps: 51 | - name: Check out code 52 | uses: actions/checkout@v4 53 | - name: Setup Python 54 | uses: actions/setup-python@v5 55 | with: 56 | python-version: 3.8 57 | - name: Constrain Tox Environment for juju 2.9 58 | if: ${{ matrix.juju == '2.9' }} 59 | run: | 60 | echo "TEST_CONSTRAINTS_FILE=constraints-juju29.txt" >> $GITHUB_ENV 61 | - name: Constrain Tox Environment for juju 3.x 62 | if: ${{ matrix.juju == '3' }} 63 | run: | 64 | echo "TEST_JUJU3=1" >> $GITHUB_ENV 65 | echo "TEST_CONSTRAINTS_FILE=constraints-juju3.txt" >> $GITHUB_ENV 66 | - name: Install Dependencies 67 | run: | 68 | pip install tox 69 | - name: Setup operator environment 70 | uses: charmed-kubernetes/actions-operator@main 71 | with: 72 | juju-channel: ${{ matrix.juju }}/stable 73 | - name: Run test 74 | run: tox -e func 75 | - name: Show Status 76 | if: ${{ always() }} 77 | run: | 78 | model=$(juju models --format yaml|grep "^- name:.*zaza"|cut -f2 -d/); 79 | juju status -m "$model" 80 | - name: Show Error Logs 81 | if: ${{ always() }} 82 | run: | 83 | model=$(juju models --format yaml|grep "^- name:.*zaza"|cut -f2 -d/); 84 | mkdir tmp 85 | juju debug-log -m "$model" --replay --no-tail --level ERROR | tee tmp/juju-debug-log.txt 86 | juju status 2>&1 | tee tmp/juju-status.txt 87 | juju-crashdump -s -m controller -a debug-layer -a config -o tmp/ 88 | juju-crashdump -s -m $model -a debug-layer -a config -o tmp/ 89 | - name: Upload debug artifacts 90 | if: ${{ always() }} 91 | uses: actions/upload-artifact@v4 92 | with: 93 | name: test-run-artifacts-juju-${{ matrix.juju }} 94 | path: tmp 95 | -------------------------------------------------------------------------------- /bin/charm-env: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | VERSION="1.0.0" 4 | 5 | 6 | find_charm_dirs() { 7 | # Hopefully, $JUJU_CHARM_DIR is set so which venv to use in unambiguous. 8 | if [[ -n "$JUJU_CHARM_DIR" || -n "$CHARM_DIR" ]]; then 9 | if [[ -z "$JUJU_CHARM_DIR" ]]; then 10 | # accept $CHARM_DIR to be more forgiving 11 | export JUJU_CHARM_DIR="$CHARM_DIR" 12 | fi 13 | if [[ -z "$CHARM_DIR" ]]; then 14 | # set CHARM_DIR as well to help with backwards compatibility 15 | export CHARM_DIR="$JUJU_CHARM_DIR" 16 | fi 17 | return 18 | fi 19 | # Try to guess the value for JUJU_CHARM_DIR by looking for a non-subordinate 20 | # (because there's got to be at least one principle) charm directory; 21 | # if there are several, pick the first by alpha order. 22 | agents_dir="/var/lib/juju/agents" 23 | if [[ -d "$agents_dir" ]]; then 24 | desired_charm="$1" 25 | found_charm_dir="" 26 | if [[ -n "$desired_charm" ]]; then 27 | for charm_dir in $(/bin/ls -d "$agents_dir"/unit-*/charm); do 28 | charm_name="$(grep -o '^['\''"]\?name['\''"]\?:.*' $charm_dir/metadata.yaml 2> /dev/null | sed -e 's/.*: *//' -e 's/['\''"]//g')" 29 | if [[ "$charm_name" == "$desired_charm" ]]; then 30 | if [[ -n "$found_charm_dir" ]]; then 31 | >&2 echo "Ambiguous possibilities for JUJU_CHARM_DIR matching '$desired_charm'; please run within a Juju hook context" 32 | exit 1 33 | fi 34 | found_charm_dir="$charm_dir" 35 | fi 36 | done 37 | if [[ -z "$found_charm_dir" ]]; then 38 | >&2 echo "Unable to determine JUJU_CHARM_DIR matching '$desired_charm'; please run within a Juju hook context" 39 | exit 1 40 | fi 41 | export JUJU_CHARM_DIR="$found_charm_dir" 42 | export CHARM_DIR="$found_charm_dir" 43 | return 44 | fi 45 | # shellcheck disable=SC2126 46 | non_subordinates="$(grep -L 'subordinate"\?:.*true' "$agents_dir"/unit-*/charm/metadata.yaml | wc -l)" 47 | if [[ "$non_subordinates" -gt 1 ]]; then 48 | >&2 echo 'Ambiguous possibilities for JUJU_CHARM_DIR; please use --charm or run within a Juju hook context' 49 | exit 1 50 | elif [[ "$non_subordinates" -eq 1 ]]; then 51 | for charm_dir in $(/bin/ls -d "$agents_dir"/unit-*/charm); do 52 | if grep -q 'subordinate"\?:.*true' "$charm_dir/metadata.yaml"; then 53 | continue 54 | fi 55 | export JUJU_CHARM_DIR="$charm_dir" 56 | export CHARM_DIR="$charm_dir" 57 | return 58 | done 59 | fi 60 | fi 61 | >&2 echo 'Unable to determine JUJU_CHARM_DIR; please run within a Juju hook context' 62 | exit 1 63 | } 64 | 65 | try_activate_venv() { 66 | if [[ -d "$JUJU_CHARM_DIR/../.venv" ]]; then 67 | . "$JUJU_CHARM_DIR/../.venv/bin/activate" 68 | fi 69 | } 70 | 71 | find_wrapped() { 72 | PATH="${PATH/\/usr\/local\/sbin:}" which "$(basename "$0")" 73 | } 74 | 75 | 76 | if [[ "$1" == "--version" || "$1" == "-v" ]]; then 77 | echo "$VERSION" 78 | exit 0 79 | fi 80 | 81 | 82 | # allow --charm option to hint which JUJU_CHARM_DIR to choose when ambiguous 83 | # NB: --charm option must come first 84 | # NB: option must be processed outside find_charm_dirs to modify $@ 85 | charm_name="" 86 | if [[ "$1" == "--charm" ]]; then 87 | charm_name="$2" 88 | shift; shift 89 | fi 90 | 91 | find_charm_dirs "$charm_name" 92 | try_activate_venv 93 | export PYTHONPATH="$JUJU_CHARM_DIR/lib:$PYTHONPATH" 94 | 95 | if [[ "$(basename "$0")" == "charm-env" ]]; then 96 | # being used as a shebang 97 | exec "$@" 98 | elif [[ "$0" == "$BASH_SOURCE" ]]; then 99 | # being invoked as a symlink wrapping something to find in the venv 100 | exec "$(find_wrapped)" "$@" 101 | elif [[ "$(basename "$BASH_SOURCE")" == "charm-env" ]]; then 102 | # being sourced directly; do nothing 103 | /bin/true 104 | else 105 | # being sourced for wrapped bash helpers 106 | . "$(find_wrapped)" 107 | fi 108 | -------------------------------------------------------------------------------- /unit_tests/test_lib_charms_layer_basic.py: -------------------------------------------------------------------------------- 1 | import os 2 | import mock 3 | import subprocess 4 | 5 | import lib.charms.layer.basic as basic 6 | 7 | import unit_tests.utils as test_utils 8 | 9 | from unittest.mock import patch 10 | 11 | 12 | class TestLayerBasic(test_utils.BaseTestCase): 13 | 14 | wheelhouse_glob = [ 15 | 'python-dateutil-2.8.1.tar.gz', 16 | 'setuptools_scm-1.17.0.tar.gz', 17 | 'wheel-0.33.6.tar.gz', 18 | 'cffi-1.15.1' 19 | '-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl', 20 | 'flit_core-3.7.1-py3-none-any.whl', 21 | ] 22 | 23 | def test__load_wheelhouse_versions(self): 24 | self.patch_object(basic, 'glob') 25 | self.patch_object(basic, 'LooseVersion') 26 | self.glob.return_value = self.wheelhouse_glob 27 | self.assertDictEqual( 28 | basic._load_wheelhouse_versions(), { 29 | 'setuptools-scm': mock.ANY, 30 | 'python-dateutil': mock.ANY, 31 | 'wheel': mock.ANY, 32 | 'cffi': mock.ANY, 33 | 'flit-core': mock.ANY, 34 | }) 35 | self.LooseVersion.assert_has_calls([ 36 | mock.call('0.33.6.tar.gz'), 37 | mock.call('2.8.1.tar.gz'), 38 | mock.call('1.17.0.tar.gz'), 39 | mock.call('1.15.1'), 40 | mock.call('3.7.1'), 41 | ], any_order=True) 42 | self.assertEqual( 43 | self.LooseVersion.call_count, 44 | 5) 45 | 46 | def test__add_back_versions(self): 47 | self.patch_object(basic, 'glob') 48 | self.glob.return_value = self.wheelhouse_glob 49 | self.assertEqual( 50 | basic._add_back_versions( 51 | [ 52 | 'python-dateutil', 53 | 'setuptools-scm', 54 | 'wheel', 55 | 'cffi', 56 | 'flit-core', 57 | ], 58 | basic._load_wheelhouse_versions()), 59 | [ 60 | 'python-dateutil==2.8.1', 61 | 'setuptools-scm==1.17.0', 62 | 'wheel==0.33.6', 63 | 'cffi==1.15.1', 64 | 'flit-core==3.7.1' 65 | ]) 66 | 67 | @patch.dict('os.environ', {'LANG': 'su_SU.UTF-8'}) 68 | def test__get_subprocess_env_lang_set(self): 69 | env = basic._get_subprocess_env() 70 | self.assertEqual(env['LANG'], 'su_SU.UTF-8') 71 | self.assertEqual(dict(os.environ), env) 72 | 73 | def test__get_subprocess_env_lang_not_set(self): 74 | with mock.patch.dict('os.environ'): 75 | del os.environ['LANG'] 76 | env = basic._get_subprocess_env() 77 | self.assertEqual(env['LANG'], 'C.UTF-8') 78 | # The only difference between dicts is the lack of LANG 79 | # in os.environ. 80 | self.assertEqual({key for key in set(env) - set(os.environ)}, 81 | {'LANG'}) 82 | 83 | def test__load_installed_versions(self): 84 | self.patch_object(basic, 'LooseVersion') 85 | with mock.patch('lib.charms.layer.basic.check_output') as reqs: 86 | reqs.return_value = b""" 87 | # comments are ignored 88 | wget==3.2 89 | zope.interface==4.3.2 90 | ignored>=1.2.3 91 | -e git+git+ssh://git.launchpad.net/git-project""" 92 | installed = basic._load_installed_versions("path/to/pip") 93 | self.assertDictEqual(installed, { 94 | "wget": mock.ANY, 95 | "zope.interface": mock.ANY, 96 | }) 97 | self.LooseVersion.assert_has_calls([ 98 | mock.call('3.2'), 99 | mock.call('4.3.2'), 100 | ], any_order=False) 101 | 102 | @mock.patch('lib.charms.layer.basic.check_call') 103 | @mock.patch('lib.charms.layer.basic.sleep') 104 | def test__apt_install(self, sleep, check_call): 105 | def fake_check_call(*args, **kwargs): 106 | raise subprocess.CalledProcessError(basic.APT_NO_LOCK, ['apt-get']) 107 | 108 | check_call.side_effect = fake_check_call 109 | self.assertRaises(subprocess.CalledProcessError, 110 | basic.apt_install, ["coreutils"]) 111 | self.assertEqual(len(check_call.mock_calls), 112 | # `apt-get install` and `apt-get update` are run, but 113 | # in the last iteration `apt-get update` is not 114 | # executed. 115 | basic.CMD_RETRY_COUNT * 2 - 1) 116 | sleep.assert_called_with(basic.CMD_RETRY_DELAY) 117 | self.assertEqual(len(sleep.mock_calls), basic.CMD_RETRY_COUNT - 1) 118 | -------------------------------------------------------------------------------- /lib/charms/layer/execd.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014-2016 Canonical Limited. 2 | # 3 | # This file is part of layer-basic, the reactive base layer for Juju. 4 | # 5 | # charm-helpers is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Lesser General Public License version 3 as 7 | # published by the Free Software Foundation. 8 | # 9 | # charm-helpers is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public License 15 | # along with charm-helpers. If not, see . 16 | 17 | # This module may only import from the Python standard library. 18 | import os 19 | import sys 20 | import subprocess 21 | import time 22 | 23 | ''' 24 | execd/preinstall 25 | 26 | Read the layer-basic docs for more info on how to use this feature. 27 | https://charmsreactive.readthedocs.io/en/latest/layer-basic.html#exec-d-support 28 | ''' 29 | 30 | 31 | def default_execd_dir(): 32 | return os.path.join(os.environ['JUJU_CHARM_DIR'], 'exec.d') 33 | 34 | 35 | def execd_module_paths(execd_dir=None): 36 | """Generate a list of full paths to modules within execd_dir.""" 37 | if not execd_dir: 38 | execd_dir = default_execd_dir() 39 | 40 | if not os.path.exists(execd_dir): 41 | return 42 | 43 | for subpath in os.listdir(execd_dir): 44 | module = os.path.join(execd_dir, subpath) 45 | if os.path.isdir(module): 46 | yield module 47 | 48 | 49 | def execd_submodule_paths(command, execd_dir=None): 50 | """Generate a list of full paths to the specified command within exec_dir. 51 | """ 52 | for module_path in execd_module_paths(execd_dir): 53 | path = os.path.join(module_path, command) 54 | if os.access(path, os.X_OK) and os.path.isfile(path): 55 | yield path 56 | 57 | 58 | def execd_sentinel_path(submodule_path): 59 | module_path = os.path.dirname(submodule_path) 60 | execd_path = os.path.dirname(module_path) 61 | module_name = os.path.basename(module_path) 62 | submodule_name = os.path.basename(submodule_path) 63 | return os.path.join(execd_path, 64 | '.{}_{}.done'.format(module_name, submodule_name)) 65 | 66 | 67 | def execd_run(command, execd_dir=None, stop_on_error=True, stderr=None): 68 | """Run command for each module within execd_dir which defines it.""" 69 | if stderr is None: 70 | stderr = sys.stdout 71 | for submodule_path in execd_submodule_paths(command, execd_dir): 72 | # Only run each execd once. We cannot simply run them in the 73 | # install hook, as potentially storage hooks are run before that. 74 | # We cannot rely on them being idempotent. 75 | sentinel = execd_sentinel_path(submodule_path) 76 | if os.path.exists(sentinel): 77 | continue 78 | 79 | try: 80 | subprocess.check_call([submodule_path], stderr=stderr, 81 | universal_newlines=True) 82 | with open(sentinel, 'w') as f: 83 | f.write('{} ran successfully {}\n'.format(submodule_path, 84 | time.ctime())) 85 | f.write('Removing this file will cause it to be run again\n') 86 | except subprocess.CalledProcessError as e: 87 | # Logs get the details. We can't use juju-log, as the 88 | # output may be substantial and exceed command line 89 | # length limits. 90 | print("ERROR ({}) running {}".format(e.returncode, e.cmd), 91 | file=stderr) 92 | print("STDOUT<==. 215 | # This ensures that pip 20.3.4+ will install the packages from the 216 | # wheelhouse without (erroneously) flagging an error. 217 | pkgs = _add_back_versions(_pkgs_set, _versions) 218 | reinstall_flag = '--force-reinstall' 219 | # if not cfg.get('use_venv', True) and pre_eoan: 220 | if not cfg.get('use_venv', True): 221 | reinstall_flag = '--ignore-installed' 222 | if not pkgs: 223 | continue 224 | check_call([pip, 'install', '-U', reinstall_flag, '--no-index', 225 | '--no-cache-dir', '-f', 'wheelhouse'] + list(pkgs), 226 | env=_get_subprocess_env()) 227 | # re-enable installation from pypi 228 | os.remove('/root/.pydistutils.cfg') 229 | 230 | # install pyyaml for centos7, since, unlike the ubuntu image, the 231 | # default image for centos doesn't include pyyaml; see the discussion: 232 | # https://discourse.jujucharms.com/t/charms-for-centos-lets-begin 233 | if 'centos' in series: 234 | check_call([pip, 'install', '-U', 'pyyaml'], 235 | env=_get_subprocess_env()) 236 | 237 | # install python packages from layer options 238 | if cfg.get('python_packages'): 239 | check_call([pip, 'install', '-U'] + cfg.get('python_packages'), 240 | env=_get_subprocess_env()) 241 | if not cfg.get('use_venv'): 242 | # restore system pip to prevent `pip3 install -U pip` 243 | # from changing it 244 | if os.path.exists('/usr/bin/pip.save'): 245 | shutil.copy2('/usr/bin/pip.save', '/usr/bin/pip') 246 | os.remove('/usr/bin/pip.save') 247 | # setup wrappers to ensure envs are used for scripts 248 | install_or_update_charm_env() 249 | for wrapper in ('charms.reactive', 'charms.reactive.sh', 250 | 'chlp', 'layer_option'): 251 | src = os.path.join('/usr/local/sbin', 'charm-env') 252 | dst = os.path.join('/usr/local/sbin', wrapper) 253 | if not os.path.exists(dst): 254 | os.symlink(src, dst) 255 | if cfg.get('use_venv'): 256 | shutil.copy2('bin/layer_option', vbin) 257 | else: 258 | shutil.copy2('bin/layer_option', '/usr/local/bin/') 259 | # re-link the charm copy to the wrapper in case charms 260 | # call bin/layer_option directly (as was the old pattern) 261 | os.remove('bin/layer_option') 262 | os.symlink('/usr/local/sbin/layer_option', 'bin/layer_option') 263 | # flag us as having already bootstrapped so we don't do it again 264 | open('wheelhouse/.bootstrapped', 'w').close() 265 | if is_upgrade: 266 | # flag us as having already upgraded so we don't do it again 267 | open('wheelhouse/.upgraded', 'w').close() 268 | # Ensure that the newly bootstrapped libs are available. 269 | # Note: this only seems to be an issue with namespace packages. 270 | # Non-namespace-package libs (e.g., charmhelpers) are available 271 | # without having to reload the interpreter. :/ 272 | reload_interpreter(vpy if cfg.get('use_venv') else sys.argv[0]) 273 | 274 | 275 | def _load_installed_versions(pip): 276 | pip_freeze = check_output([pip, 'freeze']).decode('utf8') 277 | versions = {} 278 | for pkg_ver in pip_freeze.splitlines(): 279 | try: 280 | req = Requirement.parse(pkg_ver) 281 | except ValueError: 282 | continue 283 | versions.update({ 284 | req.project_name: LooseVersion(ver) 285 | for op, ver in req.specs if op == '==' 286 | }) 287 | return versions 288 | 289 | 290 | def _load_wheelhouse_versions(): 291 | versions = {} 292 | for wheel in glob('wheelhouse/*'): 293 | if wheel.endswith('.whl'): 294 | # The binary wheel package format has a more stringent definition 295 | # of how the filenames are formulated. As such we can safely 296 | # extract the exact version string and store that. 297 | # 298 | # Reference: 299 | # PEP 427 https://peps.python.org/pep-0427/#file-name-convention 300 | # 'setuptools_scm-6.4.2-py3-none-any.whl'.split('-', 2) == 301 | # ['setuptools_scm', '6.4.2', 'py3-none-any.whl'] 302 | pkg, ver, _ = os.path.basename(wheel).split('-', 2) 303 | else: 304 | pkg, ver = os.path.basename(wheel).rsplit('-', 1) 305 | # nb: LooseVersion ignores the file extension 306 | versions[pkg.replace('_', '-')] = LooseVersion(ver) 307 | return versions 308 | 309 | 310 | def _add_back_versions(pkgs, versions): 311 | """Add back the version strings to each of the packages. 312 | 313 | The versions are LooseVersion() from _load_wheelhouse_versions(). This 314 | function strips the ".zip" or ".tar.gz" from the end of the version string 315 | and adds it back to the package in the form of == 316 | 317 | If a package name is not a key in the versions dictionary, then it is 318 | returned in the list unchanged. 319 | 320 | :param pkgs: A list of package names 321 | :type pkgs: List[str] 322 | :param versions: A map of package to LooseVersion 323 | :type versions: Dict[str, LooseVersion] 324 | :returns: A list of (maybe) versioned packages 325 | :rtype: List[str] 326 | """ 327 | def _strip_ext(s): 328 | """Strip an extension (if it exists) from the string 329 | 330 | :param s: the string to strip an extension off if it exists 331 | :type s: str 332 | :returns: string without an extension of .zip or .tar.gz 333 | :rtype: str 334 | """ 335 | for ending in [".zip", ".tar.gz"]: 336 | if s.endswith(ending): 337 | return s[:-len(ending)] 338 | return s 339 | 340 | def _maybe_add_version(pkg): 341 | """Maybe add back the version number to a package if it exists. 342 | 343 | Adds the version number, if the package exists in the lexically 344 | captured `versions` dictionary, in the form ==. Strips 345 | the extension if it exists. 346 | 347 | :param pkg: the package name to (maybe) add the version number to. 348 | :type pkg: str 349 | """ 350 | try: 351 | return "{}=={}".format(pkg, _strip_ext(str(versions[pkg]))) 352 | except KeyError: 353 | pass 354 | return pkg 355 | 356 | return [_maybe_add_version(pkg) for pkg in pkgs] 357 | 358 | 359 | def _update_if_newer(pip, pkgs): 360 | installed = _load_installed_versions(pip) 361 | wheelhouse = _load_wheelhouse_versions() 362 | for pkg in pkgs: 363 | if pkg not in installed or wheelhouse[pkg] > installed[pkg]: 364 | check_call([pip, 'install', '-U', '--no-index', '-f', 'wheelhouse', 365 | pkg], env=_get_subprocess_env()) 366 | 367 | 368 | def install_or_update_charm_env(): 369 | # On Trusty python3-pkg-resources is not installed 370 | try: 371 | from pkg_resources import parse_version 372 | except ImportError: 373 | apt_install(['python3-pkg-resources']) 374 | from pkg_resources import parse_version 375 | 376 | try: 377 | installed_version = parse_version( 378 | check_output(['/usr/local/sbin/charm-env', 379 | '--version']).decode('utf8')) 380 | except (CalledProcessError, FileNotFoundError): 381 | installed_version = parse_version('0.0.0') 382 | try: 383 | bundled_version = parse_version( 384 | check_output(['bin/charm-env', 385 | '--version']).decode('utf8')) 386 | except (CalledProcessError, FileNotFoundError): 387 | bundled_version = parse_version('0.0.0') 388 | if installed_version < bundled_version: 389 | shutil.copy2('bin/charm-env', '/usr/local/sbin/') 390 | 391 | 392 | def activate_venv(): 393 | """ 394 | Activate the venv if enabled in ``layer.yaml``. 395 | 396 | This is handled automatically for normal hooks, but actions might 397 | need to invoke this manually, using something like: 398 | 399 | # Load modules from $JUJU_CHARM_DIR/lib 400 | import sys 401 | sys.path.append('lib') 402 | 403 | from charms.layer.basic import activate_venv 404 | activate_venv() 405 | 406 | This will ensure that modules installed in the charm's 407 | virtual environment are available to the action. 408 | """ 409 | from charms.layer import options 410 | venv = os.path.abspath('../.venv') 411 | vbin = os.path.join(venv, 'bin') 412 | vpy = os.path.join(vbin, 'python') 413 | use_venv = options.get('basic', 'use_venv') 414 | if use_venv and '.venv' not in sys.executable: 415 | # activate the venv 416 | os.environ['PATH'] = ':'.join([vbin, os.environ['PATH']]) 417 | reload_interpreter(vpy) 418 | layer.patch_options_interface() 419 | layer.import_layer_libs() 420 | 421 | 422 | def reload_interpreter(python): 423 | """ 424 | Reload the python interpreter to ensure that all deps are available. 425 | 426 | Newly installed modules in namespace packages sometimes seemt to 427 | not be picked up by Python 3. 428 | """ 429 | os.execve(python, [python] + list(sys.argv), os.environ) 430 | 431 | 432 | def apt_install(packages): 433 | """ 434 | Install apt packages. 435 | 436 | This ensures a consistent set of options that are often missed but 437 | should really be set. 438 | """ 439 | if isinstance(packages, (str, bytes)): 440 | packages = [packages] 441 | 442 | env = _get_subprocess_env() 443 | 444 | if 'DEBIAN_FRONTEND' not in env: 445 | env['DEBIAN_FRONTEND'] = 'noninteractive' 446 | 447 | cmd = ['apt-get', 448 | '--option=Dpkg::Options::=--force-confold', 449 | '--assume-yes', 450 | 'install'] 451 | for attempt in range(CMD_RETRY_COUNT): 452 | try: 453 | check_call(cmd + packages, env=env) 454 | except CalledProcessError: 455 | if attempt == (CMD_RETRY_COUNT - 1): # last attempt 456 | raise 457 | try: 458 | # sometimes apt-get update needs to be run 459 | check_call(['apt-get', 'update'], env=env) 460 | except CalledProcessError: 461 | # sometimes it's a dpkg lock issue 462 | pass 463 | sleep(CMD_RETRY_DELAY) 464 | else: 465 | break 466 | 467 | 468 | def yum_install(packages): 469 | """ Installs packages with yum. 470 | This function largely mimics the apt_install function for consistency. 471 | """ 472 | if packages: 473 | env = os.environ.copy() 474 | cmd = ['yum', '-y', 'install'] 475 | for attempt in range(3): 476 | try: 477 | check_call(cmd + packages, env=env) 478 | except CalledProcessError: 479 | if attempt == 2: 480 | raise 481 | try: 482 | check_call(['yum', 'update'], env=env) 483 | except CalledProcessError: 484 | pass 485 | sleep(5) 486 | else: 487 | break 488 | else: 489 | pass 490 | 491 | 492 | def init_config_states(): 493 | import yaml 494 | from charmhelpers.core import hookenv 495 | from charms.reactive import set_state 496 | from charms.reactive import toggle_state 497 | config = hookenv.config() 498 | config_defaults = {} 499 | config_defs = {} 500 | config_yaml = os.path.join(hookenv.charm_dir(), 'config.yaml') 501 | if os.path.exists(config_yaml): 502 | with open(config_yaml) as fp: 503 | config_defs = yaml.safe_load(fp).get('options', {}) 504 | config_defaults = {key: value.get('default') 505 | for key, value in config_defs.items()} 506 | for opt in config_defs.keys(): 507 | if config.changed(opt): 508 | set_state('config.changed') 509 | set_state('config.changed.{}'.format(opt)) 510 | toggle_state('config.set.{}'.format(opt), config.get(opt)) 511 | toggle_state('config.default.{}'.format(opt), 512 | config.get(opt) == config_defaults[opt]) 513 | 514 | 515 | def clear_config_states(): 516 | from charmhelpers.core import hookenv, unitdata 517 | from charms.reactive import remove_state 518 | config = hookenv.config() 519 | remove_state('config.changed') 520 | for opt in config.keys(): 521 | remove_state('config.changed.{}'.format(opt)) 522 | remove_state('config.set.{}'.format(opt)) 523 | remove_state('config.default.{}'.format(opt)) 524 | unitdata.kv().flush() 525 | --------------------------------------------------------------------------------