Merge ~przemeklal/charm-juju-ro:initial-code-drop into charm-juju-ro:main

Proposed by Przemyslaw Lal
Status: Merged
Merged at revision: bbb2b7495eb36ec7f44e6d8b51933f968d5be742
Proposed branch: ~przemeklal/charm-juju-ro:initial-code-drop
Merge into: charm-juju-ro:main
Diff against target: 1364 lines (+1242/-0)
20 files modified
.gitignore (+11/-0)
.jujuignore (+3/-0)
Makefile (+97/-0)
README.md (+49/-0)
actions.yaml (+12/-0)
charmcraft.yaml (+14/-0)
config.yaml (+19/-0)
lib/charms/juju_ro/v0/lib_juju_addons.py (+535/-0)
metadata.yaml (+13/-0)
rename.sh (+13/-0)
requirements-dev.txt (+3/-0)
requirements.txt (+3/-0)
scripts/templates/auto_jujuro.py (+80/-0)
src/charm.py (+181/-0)
tests/__init__.py (+3/-0)
tests/test_charm.py (+12/-0)
tests/unit/files/juju_bundle.yaml (+70/-0)
tests/unit/requirements.txt (+6/-0)
tests/unit/test_lib_juju_addons.py (+29/-0)
tox.ini (+89/-0)
Reviewer Review Type Date Requested Status
Alvaro Uria (community) Approve
Review via email: mp+423461@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Alvaro Uria (aluria) wrote :

Code LGTM. Lint and unit tests passed.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.gitignore b/.gitignore
2new file mode 100644
3index 0000000..c5cb289
4--- /dev/null
5+++ b/.gitignore
6@@ -0,0 +1,11 @@
7+venv/
8+build/
9+*.charm
10+.build/
11+
12+.coverage
13+__pycache__/
14+*.py[cod]
15+
16+repo-info
17+version
18diff --git a/.jujuignore b/.jujuignore
19new file mode 100644
20index 0000000..6ccd559
21--- /dev/null
22+++ b/.jujuignore
23@@ -0,0 +1,3 @@
24+/venv
25+*.py[cod]
26+*.charm
27diff --git a/Makefile b/Makefile
28new file mode 100644
29index 0000000..f335696
30--- /dev/null
31+++ b/Makefile
32@@ -0,0 +1,97 @@
33+PYTHON := /usr/bin/python3
34+
35+PROJECTPATH=$(dir $(realpath $(MAKEFILE_LIST)))
36+ifndef CHARM_BUILD_DIR
37+ CHARM_BUILD_DIR=${PROJECTPATH}.build
38+endif
39+ifdef CONTAINER
40+ BUILD_ARGS="--destructive-mode"
41+endif
42+METADATA_FILE="metadata.yaml"
43+CHARM_NAME=$(shell cat ${PROJECTPATH}/${METADATA_FILE} | grep -E '^name:' | awk '{print $$2}')
44+
45+help:
46+ @echo "This project supports the following targets"
47+ @echo ""
48+ @echo " make help - show this text"
49+ @echo " make clean - remove unneeded files"
50+ @echo " make submodules - make sure that the submodules are up-to-date"
51+ @echo " make submodules-update - update submodules to latest changes on remote branch"
52+ @echo " make build - build the charm"
53+ @echo " make release - run clean, submodules and build targets"
54+ @echo " make lint - run flake8 and black --check"
55+ @echo " make black - run black and reformat files"
56+ @echo " make proof - run charm proof"
57+ @echo " make unittests - run the tests defined in the unittest subdirectory"
58+ @echo " make functional - run the tests defined in the functional subdirectory"
59+ @echo " make test - run lint, proof, unittests and functional targets"
60+ @echo ""
61+
62+clean:
63+ @echo "Cleaning files"
64+ @git clean -ffXd -e '!.idea'
65+ @echo "Cleaning existing build"
66+ @rm -rf ${CHARM_BUILD_DIR}/${CHARM_NAME}
67+ @charmcraft clean
68+ @rm -rf ${PROJECTPATH}/${CHARM_NAME}.charm
69+
70+submodules:
71+ @echo "Cloning submodules"
72+ @git submodule update --init --recursive
73+
74+submodules-update:
75+ @echo "Pulling latest updates for submodules"
76+ @git submodule update --init --recursive --remote --merge
77+
78+build: clean submodules-update
79+ @echo "Building charm to base directory ${CHARM_BUILD_DIR}/${CHARM_NAME}"
80+ @-git rev-parse --abbrev-ref HEAD > ./repo-info
81+ @-git describe --always > ./version
82+ @charmcraft -v pack ${BUILD_ARGS}
83+ @bash -c ./rename.sh
84+ @mkdir -p ${CHARM_BUILD_DIR}/${CHARM_NAME}
85+ @unzip ${PROJECTPATH}/${CHARM_NAME}.charm -d ${CHARM_BUILD_DIR}/${CHARM_NAME}
86+
87+release: clean build unpack
88+ @echo "Charm is built at ${CHARM_BUILD_DIR}/${CHARM_NAME}"
89+
90+unpack: build
91+ @-rm -rf ${CHARM_BUILD_DIR}/${CHARM_NAME}
92+ @mkdir -p ${CHARM_BUILD_DIR}/${CHARM_NAME}
93+ @echo "Unpacking built .charm into ${CHARM_BUILD_DIR}/${CHARM_NAME}"
94+ @cd ${CHARM_BUILD_DIR}/${CHARM_NAME} && unzip -q ${CHARM_BUILD_DIR}/${CHARM_NAME}.charm
95+ # until charmcraft copies READMEs in, we need to publish charms with readmes in them.
96+ @cp ${PROJECTPATH}/README.md ${CHARM_BUILD_DIR}/${CHARM_NAME}
97+ @cp ${PROJECTPATH}/copyright ${CHARM_BUILD_DIR}/${CHARM_NAME}
98+ @cp ${PROJECTPATH}/repo-info ${CHARM_BUILD_DIR}/${CHARM_NAME}
99+ @cp ${PROJECTPATH}/version ${CHARM_BUILD_DIR}/${CHARM_NAME}
100+
101+lint:
102+ @echo "Running lint checks"
103+ @tox -e lint
104+
105+black:
106+ @echo "Reformat files with black"
107+ @tox -e black
108+
109+proof: unpack
110+ @echo "Running charm proof"
111+ @charm proof ${CHARM_BUILD_DIR}/${CHARM_NAME}
112+
113+unittests:
114+ @echo "Running unit tests"
115+ @tox -e unit
116+
117+snap:
118+ @echo "Downloading snap"
119+ snap download juju-lint --basename juju-lint --target-directory tests/functional/tests/resources
120+
121+functional: build snap
122+ @echo "Executing functional tests in ${CHARM_BUILD_DIR}"
123+ @CHARM_LOCATION=${PROJECTPATH} tox -e func
124+
125+test: lint proof unittests functional
126+ @echo "Tests completed for charm ${CHARM_NAME}."
127+
128+# The targets below don't depend on a file
129+.PHONY: help submodules submodules-update clean build release lint black proof unittests functional test unpack snap
130diff --git a/README.md b/README.md
131new file mode 100644
132index 0000000..9c8b093
133--- /dev/null
134+++ b/README.md
135@@ -0,0 +1,49 @@
136+# Juju RO
137+
138+## Description
139+
140+A charm to collect and publish sanitized and up to date Juju status outputs and bundle exports.
141+
142+## Usage
143+
144+Deploy:
145+
146+ $ juju deploy ch:juju-ro
147+
148+Next, create a new readonly juju user and grant `read` access to any Juju model:
149+
150+ $ juju add-user <user_name>
151+ # capture the registration key
152+ $ juju grant <user_name> read <model_name>
153+
154+Example:
155+
156+ $ juju add-user jujureadonly
157+ User "jujureadonly" added
158+ Please send this command to jujureadonly:
159+ juju register <registration_key>
160+
161+ "jujureadonly" has not been granted access to any models. You can use "juju grant" to grant access.
162+ $ juju grant jujureadonly read openstack
163+
164+Run `register-user` action on the juju-ro unit using the registration key from the previous step:
165+
166+ $ juju run-action juju-ro/0 register-user reg-key="<registration_key>" controller-name="<juju_controller_name>" --wait
167+
168+Files with the sanitized juju model bundles and juju status can be found in the `dumpdirectory` specified in the config, by default:
169+
170+ $ juju run -u juju-ro/0 -- ls /var/lib/jujureadonly
171+ RO-juju-bundle-admin_openstack.txt
172+ RO-juju-status-admin_openstack.txt
173+
174+Files are created and refreshed by the cron job configured by this charm. Refresh interval can be specified by updating the config, for example:
175+
176+ $ juju config juju-ro interval=15
177+
178+## Configuration
179+
180+The following options are available:
181+
182+* `interval` - specifies how often juju status and bundle will be generated in minutes.
183+* `dumpdirectory` - Location of the juju status output and exported bundles.
184+* `user` and `group` - User and group name for the local UNIX account that will be used to log in to Juju controller.
185diff --git a/actions.yaml b/actions.yaml
186new file mode 100644
187index 0000000..e975c54
188--- /dev/null
189+++ b/actions.yaml
190@@ -0,0 +1,12 @@
191+register-user:
192+ description: |
193+ Register the current user with a registration string.
194+ params:
195+ reg-key:
196+ type: string
197+ description: |
198+ Registration key for logging into a Juju controller.
199+ controller-name:
200+ type: string
201+ description: |
202+ Name to give controller when registering.
203\ No newline at end of file
204diff --git a/charmcraft.yaml b/charmcraft.yaml
205new file mode 100644
206index 0000000..804eb40
207--- /dev/null
208+++ b/charmcraft.yaml
209@@ -0,0 +1,14 @@
210+type: "charm"
211+parts:
212+ charm:
213+ charm-python-packages: [setuptools<58, pip>19]
214+ build-packages: [libffi-dev, rustc, gcc, build-essential, cargo, libssl-dev]
215+ prime:
216+ - scripts
217+bases:
218+ - build-on:
219+ - name: "ubuntu"
220+ channel: "20.04"
221+ run-on:
222+ - name: "ubuntu"
223+ channel: "20.04"
224diff --git a/config.yaml b/config.yaml
225new file mode 100644
226index 0000000..ff36f8c
227--- /dev/null
228+++ b/config.yaml
229@@ -0,0 +1,19 @@
230+options:
231+ # Register readonly user to get juju status and juju bundle
232+ user:
233+ type: string
234+ description: "User to run."
235+ default: "jujureadonly"
236+ group:
237+ type: string
238+ description: "Group to run the user as."
239+ default: "jujureadonly"
240+ dumpdirectory:
241+ type: string
242+ description: "location to store the juju status output and dump the bundles."
243+ default: "/var/lib/jujureadonly"
244+ interval:
245+ type: int
246+ default: 30
247+ description: |
248+ Specifies how often to generate juju status and bundle in minutes.
249diff --git a/lib/charms/juju_ro/v0/lib_juju_addons.py b/lib/charms/juju_ro/v0/lib_juju_addons.py
250new file mode 100644
251index 0000000..6458fae
252--- /dev/null
253+++ b/lib/charms/juju_ro/v0/lib_juju_addons.py
254@@ -0,0 +1,535 @@
255+#!/usr/bin/env python3
256+# Copyright 2022 Canonical Ltd.
257+# See LICENSE file for licensing details.
258+
259+"""Shared library for charms interacting with Juju controllers."""
260+
261+import pathlib
262+import re
263+import subprocess
264+from subprocess import PIPE
265+import logging
266+import json
267+import math
268+import sys
269+
270+import yaml
271+
272+from juju import loop # noqa E402
273+from juju.model import Model # noqa E402
274+from juju.controller import Controller
275+from juju.status import formatted_status
276+from charmhelpers.core import host
277+
278+logger = logging.getLogger(__name__)
279+
280+# The unique Charmhub library identifier, never change it
281+LIBID = "f846c7b4b30347f9816a1230ae805c9f"
282+
283+# Increment this major API version when introducing breaking changes
284+LIBAPI = 0
285+
286+# Increment this PATCH version before using `charmcraft publish-lib` or reset
287+# to 0 if you are raising the major API version
288+LIBPATCH = 1
289+
290+MAX_FRAME_SIZE = 2**25
291+MASK_KEYS = (
292+ "(.*(ssl-public-key|ssl[_-](ca|cert|key|chain)|secret|password|"
293+ "pagerduty_key|license-file|registration-key|token|accesskey|"
294+ r"private-ppa|(http|https)://.*:.+\@|os-credentials).*)|key|"
295+ "ldaps://.*|livepatch_key|tls-ca-ldap|"
296+ "lb-mgmt.*(cacert|cert|private-key|passphrase)"
297+)
298+EMPTY_KEY_PLACE_HOLDER = "__EMPTY_KEY_PLACE_HOLDER__"
299+BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
300+HEX_CHARS = "1234567890abcdefABCDEF"
301+
302+
303+class RegistrationError(Exception):
304+ """Juju user registartion error."""
305+
306+ pass
307+
308+
309+def register_juju_user(local_user, juju_reg_key, juju_controller_name):
310+ """Register Juju controller using a local user account."""
311+ pw = host.pwgen()
312+ # TODO: Sometimes a third password input is required when adding
313+ # a controller and one already exists. This should revisited
314+ # when multi-controller support is added.
315+ cmd = ["sudo", "-u", local_user, "juju", "register", juju_reg_key]
316+ output = subprocess.run(
317+ cmd,
318+ input=f"{pw}\n{pw}\n{juju_controller_name}\n",
319+ universal_newlines=True,
320+ stderr=subprocess.STDOUT,
321+ )
322+
323+ if output.returncode != 0:
324+ msg = "User registration failed, please investigate logs"
325+ raise RegistrationError(msg)
326+
327+ # save the password so we can authenticate with it going forward
328+ juju_dir = f"/home/{local_user}/.local/share/juju"
329+ accounts_file = f"{juju_dir}/accounts.yaml"
330+ with open(accounts_file, "r") as f:
331+ accounts = yaml.safe_load(f)
332+ accounts["controllers"][juju_controller_name]["password"] = pw
333+ # TODO: add better error handling
334+ with open(accounts_file, "w") as f:
335+ yaml.dump(accounts, f)
336+
337+ logger.info(f"Password saved for {juju_controller_name}")
338+
339+
340+class Paths:
341+ """Namespace for path constants."""
342+
343+ RO_SCRIPT_NAME = "auto_jujuro.py"
344+ CRON_FILE_NAME = "/etc/cron.d/juju_ro"
345+ USR_LOCAL = pathlib.Path("/usr/local")
346+ SANITIZED_SCRIPT_PATH = USR_LOCAL / f"bin/{RO_SCRIPT_NAME}"
347+ SANITIZED_CRONTAB_PATH = pathlib.Path(CRON_FILE_NAME)
348+
349+
350+class Bundle:
351+ """Bundle class to provide sanitizer function."""
352+
353+ def __init__(self, input_bundle, masks): # noqa C901
354+ """Init bundle class."""
355+ self.secret_regex = re.compile(masks["secrets"])
356+ self.input_bundle = input_bundle
357+ self.common_bundle = {"applications": {}, "relations": [], "machines": {}}
358+ self.secrets_bundle = {"applications": {}}
359+ # No secrets in some parts of the bundle
360+ # placements get difficult unless machines are copied
361+ for key in ["series", "relations", "machines"]:
362+ if key in input_bundle.keys():
363+ self.common_bundle[key] = input_bundle[key]
364+ for app in input_bundle["applications"].keys():
365+ app_data = {}
366+ secret_data = {}
367+ settings = input_bundle["applications"][app]
368+ for param in settings.keys():
369+ param_value = settings[param]
370+ if param in ["annotations"]:
371+ continue
372+ if param in ["options"]: # This might want extracting
373+ for option in param_value.keys():
374+ option_value = param_value[option]
375+ if "options" not in app_data.keys():
376+ app_data["options"] = {}
377+ if self.secret_regex.match(option):
378+ if "options" not in secret_data.keys():
379+ secret_data["options"] = {}
380+ secret_data["options"][option] = option_value
381+ app_data["options"][option] = "[REDACTED]"
382+ else:
383+ app_data["options"][option] = option_value
384+ else:
385+ if param == "bindings":
386+ # in bindings, option with empty key like `"": internal-space`
387+ # is allowed. however, yaml will consider empty key as
388+ # complex mapping key and dump it to `? ''\n: internal-space`
389+ # here we replace the empty key to a placeholder to bypass
390+ # this issue it will be replaced back after dumped
391+ # ref: https://yaml.org/spec/1.2/spec.html#id2760695
392+ if "" in param_value:
393+ param_value[EMPTY_KEY_PLACE_HOLDER] = param_value[""]
394+ del param_value[""]
395+ app_data[param] = param_value
396+ self.common_bundle["applications"][app] = app_data
397+ if len(secret_data.keys()) > 0:
398+ self.secrets_bundle["applications"][app] = secret_data
399+
400+ def get_common_bundle(self):
401+ """Return the common info only."""
402+ if self.__check_for_entropy_in_dict(self.common_bundle):
403+ logger.warning("Some entropy was found in the common bundle")
404+ common_bundle = self.common_bundle
405+ return common_bundle
406+
407+ def get_secrets_bundle(self):
408+ """Return the bunlde includes secrets."""
409+ return self.secrets_bundle
410+
411+ def __shannon_entropy(self, data, iterator):
412+ """
413+ Borrowed from.
414+
415+ http://blog.dkbza.org/2007/05/scanning-data-for-entropy-anomalies.html.
416+ """
417+ if not data:
418+ return 0
419+ entropy = 0
420+ for x in iterator:
421+ p_x = float(data.count(x)) / len(data)
422+ if p_x > 0:
423+ entropy += -p_x * math.log(p_x, 2)
424+ return entropy
425+
426+ def __get_strings_of_set(self, word, char_set, threshold=8):
427+ count = 0
428+ letters = ""
429+ strings = []
430+ for char in word:
431+ if char in char_set:
432+ letters += char
433+ count += 1
434+ else:
435+ if count > threshold:
436+ strings.append(letters)
437+ letters = ""
438+ count = 0
439+ if count > threshold:
440+ strings.append(letters)
441+ return strings
442+
443+ def __check_for_entropy_in_dict(self, bundle):
444+ lines = yaml.dump(bundle).split("\n")
445+ strings_found = []
446+ entropy_found = False
447+ for line in lines:
448+ for word in line.split():
449+ base64_strings = self.__get_strings_of_set(word, BASE64_CHARS)
450+ hex_strings = self.__get_strings_of_set(word, HEX_CHARS)
451+ for string in base64_strings:
452+ b64_entropy = self.__shannon_entropy(string, BASE64_CHARS)
453+ if b64_entropy > 3.8:
454+ strings_found.append(string)
455+ entropy_found = True
456+ logger.warning(
457+ "WARNING! Entropy found in string " '"{}"'.format(string)
458+ )
459+ for string in hex_strings:
460+ hex_entropy = self.__shannon_entropy(string, HEX_CHARS)
461+ if hex_entropy > 3:
462+ strings_found.append(string)
463+ entropy_found = True
464+ logger.warning(
465+ "WARNING! Entropy found in string " '"{}"'.format(string)
466+ )
467+
468+ if len(strings_found) != 0:
469+ logger.error(
470+ "ERROR!!! Check output file for entropy warnings, " "may not be secure."
471+ )
472+
473+ return entropy_found
474+
475+
476+def juju_get_models():
477+ """Get juju models."""
478+ models = get_juju_models()
479+ return models
480+
481+
482+def juju_get_status(
483+ model_name=None,
484+ keep_relations_info=False,
485+ jsfy=False,
486+ format_status=False,
487+ filename=None,
488+ add_charm_config=False,
489+):
490+ """Get juju status in a named model."""
491+ juju_status = loop.run(
492+ get_juju_status(
493+ model_name,
494+ keep_relations_info,
495+ jsfy,
496+ format_status,
497+ filename,
498+ add_charm_config,
499+ )
500+ )
501+ return juju_status
502+
503+
504+def juju_get_bundle(model_name, filename=None, sanitized=False):
505+ """Get juju bundle."""
506+ juju_bundle = loop.run(get_juju_bundle(model_name, filename, sanitized))
507+ return juju_bundle
508+
509+
510+def juju_get_mode_config(model_name):
511+ """Get juju model config."""
512+ model_config = loop.run(get_juju_model_config(model_name))
513+ return model_config
514+
515+
516+def juju_get_machines(model_name):
517+ """Get juju machines in a model."""
518+ machines = loop.run(get_juju_machines(model_name))
519+ return machines
520+
521+
522+def sanitized_bundle(bundle_file, out_file):
523+ """Sanitize bundle to get rid of secret info."""
524+ masks = {"secrets": MASK_KEYS} # this is a dict so we can add things later
525+ with open(bundle_file, "r") as in_file:
526+ bundle = readyaml(in_file)
527+ entire_bundle = Bundle(bundle, masks)
528+ grab_out = {
529+ "common": entire_bundle.get_common_bundle,
530+ "secrets": entire_bundle.get_secrets_bundle,
531+ }
532+ output = yaml.dump(grab_out["common"](), default_flow_style=False)
533+ with open(out_file, "w") as f:
534+ print(output.replace(EMPTY_KEY_PLACE_HOLDER, '""'), file=f)
535+
536+
537+class JujuModel:
538+ """Context manager that connects and disconnects from the currently active model."""
539+
540+ def __init__(self, model_name=None) -> None:
541+ """Init JujuModel."""
542+ self._model = None
543+ self.model_name = model_name
544+
545+ async def __aenter__(self):
546+ """Set up model connection."""
547+ self._model = Model(max_frame_size=MAX_FRAME_SIZE)
548+ await self._model.connect(model_name=self.model_name)
549+ return self._model
550+
551+ async def __aexit__(self, exc_type, exc, tb):
552+ """Disconnet model."""
553+ await self._model.disconnect()
554+
555+
556+class JujuController:
557+ """Context manager connects and disconnects from the current controller."""
558+
559+ def __init__(self) -> None:
560+ """Init Juju controller."""
561+ self._controller = None
562+
563+ async def __aenter__(self):
564+ """Set up juju controller connection."""
565+ self._controller = Controller()
566+ await self._controller.connect()
567+ return self._controller
568+
569+ async def __aexit__(self, exc_type, exc, tb):
570+ """Disconnect juju controller."""
571+ await self._controller.disconnect()
572+
573+
574+def read_json_file(path):
575+ """Read json file."""
576+ with open(path) as fp:
577+ return json.load(fp)
578+
579+
580+# internal functions
581+def get_juju_models():
582+ """Controller.list_models requires superuser permission to list models.
583+
584+ there is a bug for it, see following link. Need to update the code to use libjuju
585+ to get models once the bug is fixed.
586+ https://github.com/juju/python-libjuju/issues/624.
587+ """
588+ cmd = (
589+ r"juju models --quiet | "
590+ r"awk '{{print $1}}' | egrep -v '(Controller|Model)' | sed -e 's/\*//'"
591+ )
592+ logger.info(cmd)
593+ output = subprocess.run(cmd, stdout=PIPE, stderr=PIPE, shell=True)
594+ if output.returncode != 0:
595+ logger.error(f"juju models failed with: {output}")
596+ return None
597+ return list(output.stdout.decode().split())
598+
599+
600+# async def get_juju_models():
601+# """Use this function to get juju model once the following bug was fixed.
602+# https://github.com/juju/python-libjuju/issues/624
603+# """
604+# async with JujuController() as controller:
605+# models = await controller.list_models()
606+# return models
607+
608+
609+async def get_juju_machines(model_name):
610+ """Get juju model machines."""
611+ result = []
612+ async with JujuModel(model_name) as model:
613+ machines = model.machines.values()
614+ for m in machines:
615+ result.append(m.data)
616+ return result
617+
618+
619+async def get_juju_model_config(model_name):
620+ """Get juju model config."""
621+ async with JujuModel(model_name) as model:
622+ model_config = await model.get_config()
623+ return model_config
624+
625+
626+async def get_juju_bundle(model_name, filename, sanitized):
627+ """Get juju bundle."""
628+ async with JujuModel(model_name) as model:
629+ juju_bundle = await model.export_bundle(filename)
630+ if sanitized:
631+ sanitized_bundle(filename, filename)
632+ return juju_bundle
633+
634+
635+async def get_juju_status(
636+ model_name,
637+ keep_relations_info,
638+ jsfy,
639+ format_status,
640+ filename,
641+ add_charm_config,
642+):
643+ """Get juju status."""
644+ async with JujuModel(model_name) as model:
645+ juju_status = ""
646+ try:
647+ if format_status:
648+ juju_status = await formatted_status(model)
649+ else:
650+ juju_status = await model.get_status()
651+ juju_status = process_juju_status(
652+ juju_status, keep_relations_info, jsfy
653+ )
654+ if add_charm_config:
655+ # inject the charm config into the status, so juju-lint
656+ # can check the "config" rules
657+ juju_status = await add_charm_config_to_juju_status(
658+ juju_status, model
659+ )
660+ except Exception as e:
661+ logger.error(
662+ "Failed to get juju status for model {}, {}".format(model_name, str(e))
663+ )
664+ if filename:
665+ write_file(filename, juju_status)
666+ return juju_status
667+
668+
669+async def add_charm_config_to_juju_status(juju_status, model):
670+ """Add the charm configuration to the juju status output."""
671+ for app_name, status_app in juju_status["applications"].items():
672+ model_app = model.applications[app_name]
673+ model_app_config = await model_app.get_config()
674+
675+ # Collect all manual/default config settings
676+ # this similar to juju export-bundle --include-charm-defaults
677+ status_app_config = {}
678+ for k, v in model_app_config.items():
679+ if v["source"] != "unset":
680+ try:
681+ status_app_config[k] = v["value"]
682+ except KeyError as error:
683+ logging.error(
684+ 'missing info for app: "{}", key: "{}", val: "{}"'.format(
685+ app_name, k, v
686+ )
687+ )
688+ raise error
689+
690+ juju_status["applications"][app_name]["options"] = status_app_config
691+
692+ return juju_status
693+
694+
695+def write_file(path, text, mode="w"):
696+ """Write json file."""
697+ with open(path, mode) as fp:
698+ fp.write(text)
699+
700+
701+def process_juju_status(juju_status, keep_relations_info, jsfy):
702+ """Convert an application status structure to the one expected by juju-lint."""
703+ juju_status_serial = juju_status.serialize()
704+
705+ # drop the "relations" key, otherwise juju-lint will not do the status
706+ # checks (assumes this is just a bundle)
707+ if not keep_relations_info:
708+ juju_status_serial.pop("relations", None)
709+
710+ # convert the output format to jsfy-style as expected by juju-lint
711+ if jsfy:
712+ convert_libjuju_to_jsfy(juju_status_serial)
713+
714+ return juju_status_serial
715+
716+
717+def convert_libjuju_to_jsfy(juju_status):
718+ """
719+ Perform the conversion between the libjuju output and the jsfy output.
720+
721+ There are some differences between the output of libjuju and
722+ and `juju status --format yaml`, see bug reports below for
723+ details. This function acts like a "shim" to convert between the
724+ two formats. When the bugs are resolved this can be removed
725+
726+ https://github.com/juju/python-libjuju/issues/500
727+ https://bugs.launchpad.net/juju/+bug/1930184
728+ """
729+ # perform machine conversion
730+ if "machines" in juju_status:
731+ for machine_name, machine_struct in juju_status["machines"].items():
732+ machine_dict = machine_struct.serialize()
733+ remap_instance_dict(
734+ machine_dict,
735+ {"instance-status": "machine-status", "agent-status": "juju-status"},
736+ )
737+ juju_status["machines"][machine_name] = machine_dict
738+
739+ # perform application and unit conversion
740+ if "applications" in juju_status:
741+ for app_name, app_struct in juju_status["applications"].items():
742+ app_dict = app_struct.serialize()
743+ remap_instance_dict(app_dict, {"status": "application-status"})
744+
745+ for unit_name, unit_struct in app_dict["units"].items():
746+ unit_dict = app_dict["units"][unit_name] = unit_struct.serialize()
747+ remap_instance_dict(
748+ unit_dict,
749+ {
750+ "workload-status": "workload-status",
751+ "agent-status": "juju-status",
752+ },
753+ )
754+
755+ for subord_name, subord_struct in unit_dict["subordinates"].items():
756+ subord_dict = unit_dict["subordinates"][
757+ subord_name
758+ ] = subord_struct.serialize()
759+ remap_instance_dict(
760+ subord_dict,
761+ {
762+ "workload-status": "workload-status",
763+ "agent-status": "juju-status",
764+ },
765+ )
766+ juju_status["applications"][app_name] = app_dict
767+
768+ return juju_status
769+
770+
771+def remap_instance_dict(instance_dict, mapping_dict):
772+ """Convert an unit status structure to the one expected by juju-lint."""
773+ for from_key, to_key in mapping_dict.items():
774+ if from_key in instance_dict:
775+ instance_status = instance_dict[to_key] = instance_dict.pop(
776+ from_key
777+ ).serialize()
778+ instance_status["message"] = instance_status.pop("info")
779+ instance_status["current"] = instance_status.pop("status")
780+
781+
782+def readyaml(stream):
783+ """Read yaml file."""
784+ try:
785+ myyaml = yaml.safe_load(stream.read())
786+ except yaml.YAMLError as exc:
787+ msg = "Failed to read yaml with {}".format(exc)
788+ sys.exit(msg)
789+ return myyaml
790diff --git a/metadata.yaml b/metadata.yaml
791new file mode 100644
792index 0000000..dbaa9be
793--- /dev/null
794+++ b/metadata.yaml
795@@ -0,0 +1,13 @@
796+name: juju-ro
797+display-name: Juju RO
798+maintainer: BootStack Charmers <bootstack-charmers@lists.canonical.com>
799+summary: |
800+ A charm to collect and publish sanitized and up to date Juju status outputs and bundle exports.
801+description: |
802+ A charm to collect and publish sanitized and up to date Juju status outputs and bundle exports.
803+tags:
804+ - ops
805+series:
806+ - focal
807+ - bionic
808+subordinate: false
809diff --git a/rename.sh b/rename.sh
810new file mode 100755
811index 0000000..956a76b
812--- /dev/null
813+++ b/rename.sh
814@@ -0,0 +1,13 @@
815+#!/bin/bash
816+charm=$(grep -E "^name:" metadata.yaml | awk '{print $2}')
817+echo "renaming ${charm}_*.charm to ${charm}.charm"
818+echo -n "pwd: "
819+pwd
820+ls -al
821+echo "Removing previous charm if it exists"
822+if [[ -e "${charm}.charm" ]];
823+then
824+ rm "${charm}.charm"
825+fi
826+echo "Renaming charm here."
827+mv ${charm}_*.charm ${charm}.charm
828diff --git a/requirements-dev.txt b/requirements-dev.txt
829new file mode 100644
830index 0000000..4f2a3f5
831--- /dev/null
832+++ b/requirements-dev.txt
833@@ -0,0 +1,3 @@
834+-r requirements.txt
835+coverage
836+flake8
837diff --git a/requirements.txt b/requirements.txt
838new file mode 100644
839index 0000000..5bb7c92
840--- /dev/null
841+++ b/requirements.txt
842@@ -0,0 +1,3 @@
843+ops >= 1.4.0
844+charmhelpers
845+juju
846diff --git a/scripts/templates/auto_jujuro.py b/scripts/templates/auto_jujuro.py
847new file mode 100644
848index 0000000..bfff447
849--- /dev/null
850+++ b/scripts/templates/auto_jujuro.py
851@@ -0,0 +1,80 @@
852+#!/usr/bin/env python3
853+# Copyright 2022 Canonical
854+# See LICENSE file for licensing details.
855+
856+"""Automatically grab juju status output and juju sanitized bundle."""
857+import sys
858+import os
859+import logging
860+import argparse
861+from os.path import join
862+
863+
864+# The path below is templated in during charm install
865+sys.path.append("REPLACE_CHARMDIR/venv")
866+sys.path.append("REPLACE_CHARMDIR/lib")
867+
868+from charms.juju_ro.v0.lib_juju_addons import ( # noqa E402
869+ juju_get_models,
870+ juju_get_status,
871+ juju_get_bundle,
872+)
873+
874+
875+PID_FILENAME = "/tmp/auto_jujuro.pid"
876+
877+logger = logging.getLogger(__name__)
878+
879+
880+def main():
881+ """Call main function."""
882+ parser = argparse.ArgumentParser(
883+ description="Grab juju status and sanitized bundle output."
884+ )
885+
886+ parser.add_argument(
887+ "-d",
888+ "--dump-directory",
889+ dest="dumpdir",
890+ required=True,
891+ help="location to store the juju status output and dump the bundles.",
892+ )
893+
894+ args = parser.parse_args()
895+
896+ # Ensure a single instance via a simple pidfile
897+ pid = str(os.getpid())
898+
899+ if os.path.isfile(PID_FILENAME):
900+ sys.exit("{} already exists, exiting".format(PID_FILENAME))
901+
902+ with open(PID_FILENAME, "w") as f:
903+ f.write(pid)
904+
905+ for model in juju_get_models():
906+ try:
907+ juju_status_file = join(
908+ args.dumpdir, f"RO-juju-status-{model.replace('/','_')}.txt"
909+ )
910+ juju_get_status(
911+ model_name=model, format_status=True, filename=juju_status_file
912+ )
913+ except Exception as e:
914+ logger.error(
915+ "failed to get juju status for model {}, {}".format(model, str(e))
916+ )
917+
918+ for model in juju_get_models():
919+ try:
920+ bundle_file = join(
921+ args.dumpdir, f"RO-juju-bundle-{model.replace('/','_')}.txt"
922+ )
923+ juju_get_bundle(model_name=model, filename=bundle_file)
924+ except Exception as e:
925+ logger.error("failed to get bundle for model {}, {}".format(model, str(e)))
926+
927+ os.unlink(PID_FILENAME)
928+
929+
930+if __name__ == "__main__":
931+ main()
932diff --git a/src/charm.py b/src/charm.py
933new file mode 100755
934index 0000000..eb213f6
935--- /dev/null
936+++ b/src/charm.py
937@@ -0,0 +1,181 @@
938+#!/usr/bin/env python3
939+# Copyright 2022 Canonical
940+# See LICENSE file for licensing details.
941+#
942+# Learn more at: https://juju.is/docs/sdk
943+
944+"""Collect and santize Juju status and bundles.
945+
946+A charm to collect and publish sanitized and up to date Juju status
947+outputs and bundle exports.
948+"""
949+
950+import pathlib
951+import os
952+import logging
953+
954+from ops.charm import CharmBase
955+from ops.framework import StoredState
956+from ops.main import main
957+from ops.model import ActiveStatus, BlockedStatus
958+from charmhelpers.fetch import snap
959+from charmhelpers.core import hookenv, host
960+
961+from charms.juju_ro.v0.lib_juju_addons import (
962+ Paths,
963+ register_juju_user,
964+ RegistrationError,
965+)
966+
967+logger = logging.getLogger(__name__)
968+
969+
970+class JujuROHelper:
971+ """Juju Sanitizer helper object."""
972+
973+ def __init__(self, model):
974+ """Construct the helper."""
975+ self.model = model
976+ self.charm_config = model.config
977+
978+ def update_crontab(self, user, frequency, dumpdir):
979+ """Update crontab at /etc/cron.d/juju-ro."""
980+ cron_job = (
981+ "PATH=/usr/bin:/bin:/snap/bin\n"
982+ "*/{} * * * * {} {} -d {} > /tmp/juju_ro.log 2>&1\n".format(
983+ frequency,
984+ user,
985+ Paths.SANITIZED_SCRIPT_PATH,
986+ dumpdir,
987+ )
988+ )
989+ with Paths.SANITIZED_CRONTAB_PATH.open("w") as fp:
990+ fp.write(cron_job)
991+
992+ def delete_crontab(self):
993+ """Delete cron tab."""
994+ if os.path.exists(Paths.SANITIZED_CRONTAB_PATH):
995+ os.unlink(Paths.SANITIZED_CRONTAB_PATH)
996+
997+ def install_snap(self):
998+ """Install juju snap."""
999+ snaps = ["juju"]
1000+ snap_flags = "--classic"
1001+ snap.snap_install(snaps, snap_flags)
1002+
1003+ def install_scripts(self):
1004+ """Install scripts."""
1005+ charm_dir = pathlib.Path(hookenv.charm_dir())
1006+ scripts_dir = charm_dir / "scripts"
1007+ templates_dir = scripts_dir / "templates"
1008+
1009+ # Create the auto_jujuro.py script from the template with
1010+ # the right permissions
1011+ with open(str(templates_dir / Paths.RO_SCRIPT_NAME)) as f:
1012+ auto_template = f.read()
1013+
1014+ sanitizer_juju_script = auto_template.replace(
1015+ "REPLACE_CHARMDIR", str(charm_dir)
1016+ )
1017+
1018+ with open(str(Paths.SANITIZED_SCRIPT_PATH), "w") as f:
1019+ f.write(sanitizer_juju_script)
1020+
1021+ os.chmod(str(Paths.SANITIZED_SCRIPT_PATH), 0o755)
1022+
1023+
1024+class JujuRoCharm(CharmBase):
1025+ """Collect and santize Juju status and bundles."""
1026+
1027+ state = StoredState()
1028+
1029+ def __init__(self, *args):
1030+ super().__init__(*args)
1031+ self.framework.observe(self.on.install, self.on_install)
1032+ self.framework.observe(self.on.upgrade_charm, self.on_upgrade_charm)
1033+ self.framework.observe(self.on.config_changed, self.on_config_changed)
1034+ self.framework.observe(self.on.update_status, self.update_status)
1035+ self.framework.observe(self.on.register_user_action, self.register_user)
1036+ self.jujuro_helper = JujuROHelper(self.model)
1037+ self.state.set_default(
1038+ installed=False,
1039+ user_registered=False,
1040+ )
1041+ self.ctxt = {
1042+ "user": self.model.config["user"],
1043+ "group": self.model.config["group"],
1044+ "dumpdir": self.model.config["dumpdirectory"],
1045+ "interval": self.model.config["interval"],
1046+ }
1047+ self.logger = logging.getLogger(__name__)
1048+
1049+ def on_install(self, event):
1050+ """Install juju snap and cron scripts."""
1051+ self.jujuro_helper.install_snap()
1052+ self.jujuro_helper.install_scripts()
1053+ self.state.installed = True
1054+ self.update_status(event)
1055+
1056+ def on_upgrade_charm(self, event):
1057+ """Handle upgrade and resource updates."""
1058+ # Reinstall snaps and scripts
1059+ logging.info("upgrade-charm: reinstall")
1060+ self.jujuro_helper.install_snap()
1061+ self.jujuro_helper.install_scripts()
1062+ self.update_status(event)
1063+
1064+ def config_juju_readonly_user(self):
1065+ """Need home dir to configure juju read only user."""
1066+ host.add_group(self.ctxt["group"])
1067+ host.adduser(
1068+ self.ctxt["user"],
1069+ primary_group=self.ctxt["group"],
1070+ shell="/bin/bash",
1071+ password=host.pwgen(), # don't save it, not needed
1072+ home_dir=f"/home/{self.ctxt['user']}",
1073+ )
1074+
1075+ host.mkdir(
1076+ self.ctxt["dumpdir"],
1077+ owner=self.ctxt["user"],
1078+ group=self.ctxt["group"],
1079+ perms=0o700,
1080+ force=True,
1081+ )
1082+
1083+ def on_config_changed(self, event):
1084+ """Reconfigure charm."""
1085+ self.config_juju_readonly_user()
1086+ self.jujuro_helper.update_crontab(
1087+ self.ctxt["user"], self.ctxt["interval"], self.ctxt["dumpdir"]
1088+ )
1089+ self.update_status(event)
1090+
1091+ def update_status(self, _):
1092+ """Asses the current status of the unit."""
1093+ if self.state.user_registered:
1094+ self.unit.status = ActiveStatus("Ready")
1095+ elif self.state.installed:
1096+ self.unit.status = BlockedStatus("Juju read-only user not registered")
1097+
1098+ def register_user(self, event):
1099+ """Register juju user using registration key."""
1100+ try:
1101+ register_juju_user(
1102+ self.ctxt["user"],
1103+ event.params["reg-key"],
1104+ event.params["controller-name"],
1105+ # TODO: validate if controller_name has acceptable input
1106+ )
1107+ self.logger.info("juju registration successful")
1108+ self.state.user_registered = True
1109+ self.unit.status = ActiveStatus("Ready")
1110+ except RegistrationError as err:
1111+ self.logger.error(f"juju registration failed: {str(err)}")
1112+ msg = "User registration failed, please investigate logs"
1113+ self.unit.status = BlockedStatus(msg)
1114+ return
1115+
1116+
1117+if __name__ == "__main__":
1118+ main(JujuRoCharm)
1119diff --git a/tests/__init__.py b/tests/__init__.py
1120new file mode 100644
1121index 0000000..c5e3156
1122--- /dev/null
1123+++ b/tests/__init__.py
1124@@ -0,0 +1,3 @@
1125+import ops.testing
1126+
1127+ops.testing.SIMULATE_CAN_CONNECT = True
1128diff --git a/tests/test_charm.py b/tests/test_charm.py
1129new file mode 100644
1130index 0000000..0529d5d
1131--- /dev/null
1132+++ b/tests/test_charm.py
1133@@ -0,0 +1,12 @@
1134+# Copyright 2022 Canonical
1135+# See LICENSE file for licensing details.
1136+#
1137+# Learn more about testing at: https://juju.is/docs/sdk/testing
1138+
1139+import unittest
1140+
1141+
1142+class TestCharm(unittest.TestCase):
1143+ """Test charm."""
1144+
1145+ pass
1146diff --git a/tests/unit/files/juju_bundle.yaml b/tests/unit/files/juju_bundle.yaml
1147new file mode 100644
1148index 0000000..2e139e6
1149--- /dev/null
1150+++ b/tests/unit/files/juju_bundle.yaml
1151@@ -0,0 +1,70 @@
1152+series: focal
1153+saas:
1154+ dashboards:
1155+ url: foundations-maas:admin/lma.dashboards
1156+ grafana:
1157+ url: foundations-maas:admin/lma.grafana
1158+ graylog:
1159+ url: foundations-maas:admin/lma.graylog-beats
1160+ landscape-server:
1161+ url: foundations-maas:admin/lma.landscape-server
1162+ nagios:
1163+ url: foundations-maas:admin/lma.nagios-monitors
1164+ prometheus:
1165+ url: foundations-maas:admin/lma.prometheus-target
1166+ prometheus-rules:
1167+ url: foundations-maas:admin/lma.prometheus-rules
1168+ prometheus-target:
1169+ url: foundations-maas:admin/lma.prometheus-target
1170+applications:
1171+ aodh:
1172+ charm: cs:aodh-46
1173+ num_units: 3
1174+ to:
1175+ - lxd:6
1176+ - lxd:7
1177+ - lxd:8
1178+ options:
1179+ openstack-origin: distro
1180+ os-admin-hostname: aodh-admin.example.com
1181+ os-internal-hostname: aodh-internal.example.com
1182+ os-public-hostname: aodh.example.com
1183+ region: example
1184+ ssl_cert: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUdUVENDQlRXZ0F3SUJBZ0lRQWp5UUl2MzU1YTdjd1VZWTBYNE1FekFOQmdrcWhraUc5dzBCQVFzRkFEQlAKTVFzd0NRWURWUVFHRXdKVlV6RVZNQk1HQTFVRUNoTU1SR2xuYVVObGNuUWdTVzVqTVNrd0p3WURWUVFERXlCRQphV2RwUTJWeWRDQlVURk1nVWxOQklGTklRVEkxTmlBeU1ESXdJRU5CTVRBZUZ3MHlNREV4TVRjd01EQXdNREJhCkZ3MHlNVEV4TWpFeU16VTVOVGxhTUZveEN6QUpCZ05WQkFZVEFrZENNUTh3RFFZRFZRUUhFd1pNYjI1a2IyNHgKSERBYUJnTlZCQW9URTBOaGJtOXVhV05oYkNCSGNtOTFjQ0JNZEdReEhEQWFCZ05WQkFNTUV5b3VjSE0xTG1OaApibTl1YVdOaGJDNWpiMjB3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRRHJXUWxPCldsUXNpK1VIWG1wTlIvYVBWVjJ6ZWIrcklKY2l0d3ZwcDBsODJraDU2Y
1185+ use-internal-endpoints: true
1186+ vip: 1.1.48.20 1.1.52.20 1.1.56.20
1187+ worker-multiplier: 0.25
1188+ bindings:
1189+ "": oam-space
1190+ admin: admin-space
1191+ amqp: oam-space
1192+ certificates: oam-space
1193+ cluster: oam-space
1194+ ha: oam-space
1195+ identity-service: oam-space
1196+ internal: internal-space
1197+ mongodb: oam-space
1198+ public: public-space
1199+ shared-db: admin-space
1200+ aodh-mysql-router:
1201+ charm: cs:mysql-router-6
1202+ bindings:
1203+ "": oam-space
1204+ certificates: oam-space
1205+ db-router: admin-space
1206+ juju-info: oam-space
1207+ shared-db: oam-space
1208+ barbican:
1209+ charm: cs:barbican-36
1210+ num_units: 3
1211+ to:
1212+ - lxd:3
1213+ - lxd:4
1214+ - lxd:5
1215+ options:
1216+ openstack-origin: distro
1217+ os-admin-hostname: barbican-admin.example.com
1218+ os-internal-hostname: barbican-internal.example.com
1219+ os-public-hostname: barbican.example.com
1220+ region: example
1221+ ssl_cert: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUdUVENDQlRXZ0F3SUJBZ0lRQWp5UUl2MzU1YTdjd1VZWTBYNE1FekFOQmdrcWhraUc5dzBCQVFzRkFEQlAKTVFzd0NRWURWUVFHRXdKVlV6RVZNQk1HQTFVRUNoTU1SR2xuYVVObGNuUWdTVzVqTVNrd0p3WURWUVFERXlCRQphV2RwUTJWeWRDQlVURk1nVWxOQklGTklRVEkxTmlBeU1ESXdJRU5CTVRBZUZ3MHlNREV4TVRjd01EQXdNREJhCkZ3MHlNVEV4TWpFeU16VTVOVGxhTUZveEN6QUpCZ05WQkFZVEFrZENNUTh3RFFZRFZRUUhFd1pNYjI1a2IyNHgKSERBYUJnTlZCQW9URTBOaGJtOXVhV05oYkNCSGNtOTFjQ0JNZEdReEhEQWFCZ05WQkFNTUV5b3VjSE0xT
1222\ No newline at end of file
1223diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt
1224new file mode 100644
1225index 0000000..fd430bd
1226--- /dev/null
1227+++ b/tests/unit/requirements.txt
1228@@ -0,0 +1,6 @@
1229+mock
1230+pyyaml
1231+six
1232+jinja2
1233+coverage
1234+juju
1235diff --git a/tests/unit/test_lib_juju_addons.py b/tests/unit/test_lib_juju_addons.py
1236new file mode 100644
1237index 0000000..ca87659
1238--- /dev/null
1239+++ b/tests/unit/test_lib_juju_addons.py
1240@@ -0,0 +1,29 @@
1241+"""lib_juju_addons.py unittests."""
1242+import os
1243+import unittest
1244+from os.path import join
1245+
1246+from charms.juju_ro.v0.lib_juju_addons import (
1247+ sanitized_bundle,
1248+)
1249+
1250+
1251+class TestLibJujuAddons(unittest.TestCase):
1252+ """test class for lib_juju_addons.py."""
1253+
1254+ def test_sanitized_bundle(self):
1255+ """Test that no secret info in the output."""
1256+ file_path = "tests/unit/files"
1257+ test_file = join(file_path, "juju_bundle.yaml")
1258+ out_file = join(file_path, "sanitized_bundle.yaml")
1259+ sanitized_bundle(test_file, out_file)
1260+ with open(out_file, "r") as f:
1261+ line = f.readline()
1262+ while line:
1263+ kv = line.split(":")
1264+ k = kv[0]
1265+ v = kv[-1]
1266+ if k == "ssl_cert":
1267+ assert v == "[REDACTED]"
1268+ line = f.readline()
1269+ os.unlink(out_file)
1270diff --git a/tox.ini b/tox.ini
1271new file mode 100644
1272index 0000000..5ca9012
1273--- /dev/null
1274+++ b/tox.ini
1275@@ -0,0 +1,89 @@
1276+[tox]
1277+skipsdist=True
1278+skip_missing_interpreters = True
1279+envlist = lint, unit, func
1280+
1281+[testenv]
1282+basepython = python3
1283+setenv =
1284+ PYTHONPATH = {toxinidir}:{toxinidir}/lib/:{toxinidir}/hooks/
1285+passenv =
1286+ HOME
1287+ PATH
1288+ CHARM_*
1289+ OS_*
1290+ MODEL_SETTINGS
1291+ HTTP_PROXY
1292+ HTTPS_PROXY
1293+ NO_PROXY
1294+ SNAP_HTTP_PROXY
1295+ SNAP_HTTPS_PROXY
1296+
1297+[testenv:build]
1298+deps = charmcraft<1.1.0
1299+commands = charmcraft build
1300+
1301+[testenv:lint]
1302+commands =
1303+ flake8 {posargs} src tests lib scripts
1304+ black --check --exclude "/(\.eggs|\.git|\.tox|\.venv|\.build|build|dist|charmhelpers|mod)/" .
1305+deps =
1306+ black
1307+ flake8
1308+ flake8-docstrings
1309+ flake8-import-order
1310+ pep8-naming
1311+ flake8-colors
1312+
1313+
1314+[testenv:black]
1315+commands =
1316+ black --exclude "/(\.eggs|\.git|\.tox|\.venv|\.build|build|dist|charmhelpers|mod)/" .
1317+deps =
1318+ black
1319+
1320+[testenv:unit]
1321+commands =
1322+ coverage run -m unittest discover -s {toxinidir}/tests/unit -v
1323+ coverage report --omit tests/*,mod/*,.tox/*
1324+ coverage html --omit tests/*,mod/*,.tox/*
1325+deps = -r{toxinidir}/tests/unit/requirements.txt
1326+ -r{toxinidir}/requirements.txt
1327+
1328+[testenv:func]
1329+changedir = {toxinidir}/tests/functional
1330+deps = -r{toxinidir}/tests/functional/requirements.txt
1331+commands = functest-run-suite --keep-faulty-model {posargs}
1332+
1333+
1334+[testenv:func-smoke]
1335+changedir = {toxinidir}/tests/functional
1336+deps = -r{toxinidir}/tests/functional/requirements.txt
1337+commands = functest-run-suite --keep-model --smoke
1338+
1339+[testenv:func-dev]
1340+changedir = {toxinidir}/tests/functional
1341+deps = -r{toxinidir}/tests/functional/requirements.txt
1342+commands = functest-run-suite --keep-model --dev
1343+
1344+[testenv:func-target]
1345+changedir = {toxinidir}/tests/functional
1346+deps = -r{toxinidir}/tests/functional/requirements.txt
1347+commands = functest-run-suite --keep-model --bundle {posargs}
1348+
1349+[flake8]
1350+exclude =
1351+ .git,
1352+ __pycache__,
1353+ .tox,
1354+ .build,
1355+ build
1356+
1357+ignore = D100, D104, D107
1358+
1359+max-line-length = 88
1360+max-complexity = 10
1361+
1362+# flake8-import-order configurations
1363+import-order-style = pep8
1364+application-import-names = src.charm,charms.juju_ro.v0.lib_juju_addons

Subscribers

People subscribed via source and target branches

to all changes: