Merge ~przemeklal/charm-juju-ro:initial-code-drop into charm-juju-ro:main
- Git
- lp:~przemeklal/charm-juju-ro
- initial-code-drop
- Merge into 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) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Alvaro Uria (community) | Approve | ||
Review via email:
|
Commit message
Description of the change
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/.gitignore b/.gitignore |
2 | new file mode 100644 |
3 | index 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 |
18 | diff --git a/.jujuignore b/.jujuignore |
19 | new file mode 100644 |
20 | index 0000000..6ccd559 |
21 | --- /dev/null |
22 | +++ b/.jujuignore |
23 | @@ -0,0 +1,3 @@ |
24 | +/venv |
25 | +*.py[cod] |
26 | +*.charm |
27 | diff --git a/Makefile b/Makefile |
28 | new file mode 100644 |
29 | index 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 |
130 | diff --git a/README.md b/README.md |
131 | new file mode 100644 |
132 | index 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. |
185 | diff --git a/actions.yaml b/actions.yaml |
186 | new file mode 100644 |
187 | index 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 |
204 | diff --git a/charmcraft.yaml b/charmcraft.yaml |
205 | new file mode 100644 |
206 | index 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" |
224 | diff --git a/config.yaml b/config.yaml |
225 | new file mode 100644 |
226 | index 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. |
249 | diff --git a/lib/charms/juju_ro/v0/lib_juju_addons.py b/lib/charms/juju_ro/v0/lib_juju_addons.py |
250 | new file mode 100644 |
251 | index 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 |
790 | diff --git a/metadata.yaml b/metadata.yaml |
791 | new file mode 100644 |
792 | index 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 |
809 | diff --git a/rename.sh b/rename.sh |
810 | new file mode 100755 |
811 | index 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 |
828 | diff --git a/requirements-dev.txt b/requirements-dev.txt |
829 | new file mode 100644 |
830 | index 0000000..4f2a3f5 |
831 | --- /dev/null |
832 | +++ b/requirements-dev.txt |
833 | @@ -0,0 +1,3 @@ |
834 | +-r requirements.txt |
835 | +coverage |
836 | +flake8 |
837 | diff --git a/requirements.txt b/requirements.txt |
838 | new file mode 100644 |
839 | index 0000000..5bb7c92 |
840 | --- /dev/null |
841 | +++ b/requirements.txt |
842 | @@ -0,0 +1,3 @@ |
843 | +ops >= 1.4.0 |
844 | +charmhelpers |
845 | +juju |
846 | diff --git a/scripts/templates/auto_jujuro.py b/scripts/templates/auto_jujuro.py |
847 | new file mode 100644 |
848 | index 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() |
932 | diff --git a/src/charm.py b/src/charm.py |
933 | new file mode 100755 |
934 | index 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) |
1119 | diff --git a/tests/__init__.py b/tests/__init__.py |
1120 | new file mode 100644 |
1121 | index 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 |
1128 | diff --git a/tests/test_charm.py b/tests/test_charm.py |
1129 | new file mode 100644 |
1130 | index 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 |
1146 | diff --git a/tests/unit/files/juju_bundle.yaml b/tests/unit/files/juju_bundle.yaml |
1147 | new file mode 100644 |
1148 | index 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 |
1223 | diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt |
1224 | new file mode 100644 |
1225 | index 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 |
1235 | diff --git a/tests/unit/test_lib_juju_addons.py b/tests/unit/test_lib_juju_addons.py |
1236 | new file mode 100644 |
1237 | index 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) |
1270 | diff --git a/tox.ini b/tox.ini |
1271 | new file mode 100644 |
1272 | index 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 |
Code LGTM. Lint and unit tests passed.