Merge ~llama-charmers/charm-duplicity:develop into ~llama-charmers/charm-duplicity:master
- Git
- lp:~llama-charmers/charm-duplicity
- develop
- Merge into master
Proposed by
Drew Freiberger
Status: | Rejected |
---|---|
Rejected by: | Jeremy Lounder |
Proposed branch: | ~llama-charmers/charm-duplicity:develop |
Merge into: | ~llama-charmers/charm-duplicity:master |
Diff against target: |
1899 lines (+1584/-0) (has conflicts) 30 files modified
.gitignore (+4/-0) CONTRIB.md (+108/-0) Makefile (+8/-0) README.md (+130/-0) actions.yaml (+17/-0) actions/actions.py (+51/-0) actions/do-backup (+1/-0) config.yaml (+101/-0) layer.yaml (+13/-0) lib/lib_duplicity.py (+195/-0) metadata.yaml (+30/-0) reactive/duplicity.py (+247/-0) requirements.txt (+5/-0) scripts/periodic_backup.py (+50/-0) scripts/plugins/check_backup_status.py (+19/-0) templates/periodic_backup (+1/-0) tests/bundles/bionic.yaml (+28/-0) tests/bundles/xenial.yaml (+28/-0) tests/functional/configure.py (+82/-0) tests/functional/requirements.txt (+4/-0) tests/functional/resources/hello-world.txt (+1/-0) tests/functional/resources/testing_id_rsa (+27/-0) tests/functional/resources/testing_id_rsa.pub (+1/-0) tests/functional/test_duplicity.py (+240/-0) tests/functional/utils.py (+54/-0) tests/tests.yaml (+20/-0) tests/unit/conftest.py (+36/-0) tests/unit/test_actions.py (+16/-0) tests/unit/test_lib.py (+31/-0) tox.ini (+36/-0) Conflict in .gitignore Conflict in Makefile Conflict in actions.yaml Conflict in config.yaml Conflict in layer.yaml Conflict in metadata.yaml Conflict in requirements.txt Conflict in tests/functional/requirements.txt Conflict in tests/unit/conftest.py Conflict in tests/unit/test_actions.py Conflict in tests/unit/test_lib.py Conflict in tox.ini |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Jeremy Lounder (community) | Disapprove | ||
Review via email: mp+377615@code.launchpad.net |
Commit message
Description of the change
To post a comment you must log in.
Unmerged commits
- 6bc04f7... by Zachary Zehring
-
Merge branch 'readmes'
- 9d0460a... by Zachary Zehring
-
Write guide for contributing to project, starting, testing, etc.
- f7e111c... by Zachary Zehring
-
Add title of charm to README.
- 1fa90c8... by Zachary Zehring
-
Add additional information to usage and limited functionality sections.
- b174415... by Zachary Zehring
-
[WIP] Add important sections for README.
- 95aa540... by Zachary Zehring
-
Add CONTRIB.md for guidelines for contributing to the project and howto.
- 4a9a55b... by Zachary Zehring
-
Merge branch 'nrpe-checks'
- 96a0a15... by Zachary Zehring
-
Reorder module vars and constants.
- ad183c2... by Zachary Zehring
-
Ignore tests files and E402 (line before all imports, needed in some
cases). - 230cc89... by Zachary Zehring
-
Add nrpe and corresponding relations for testing.
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 | index 32e2995..e8a93af 100644 |
3 | --- a/.gitignore |
4 | +++ b/.gitignore |
5 | @@ -8,6 +8,10 @@ __pycache__/ |
6 | |
7 | .tox/ |
8 | .coverage |
9 | +<<<<<<< .gitignore |
10 | +======= |
11 | +.env |
12 | +>>>>>>> .gitignore |
13 | |
14 | # vi |
15 | .*.swp |
16 | diff --git a/CONTRIB.md b/CONTRIB.md |
17 | new file mode 100644 |
18 | index 0000000..842df2d |
19 | --- /dev/null |
20 | +++ b/CONTRIB.md |
21 | @@ -0,0 +1,108 @@ |
22 | +# Duplicity - Contributing |
23 | + |
24 | +This is a quick guide to help developers start contributing to the Duplicity charm project. |
25 | + |
26 | +## Getting Started |
27 | + |
28 | +This section will help with setting up the development and testing environment for this charm. |
29 | + |
30 | +### Prerequisites |
31 | + |
32 | +What you'll need is: |
33 | + |
34 | +- charm-tools |
35 | +- tox |
36 | +- python3 |
37 | +- juju |
38 | + |
39 | +To download: |
40 | + |
41 | +``` |
42 | +sudo snap install charm --classic |
43 | +sudo snap install juju --classic |
44 | + |
45 | +pip install tox |
46 | +``` |
47 | + |
48 | +### Dev Setup |
49 | + |
50 | +Here's some quick commands to help you get started developing: |
51 | + |
52 | +``` |
53 | +# Clone the repo |
54 | +git clone git+ssh://git.launchpad.net/charm-duplicity |
55 | + |
56 | +# Get virtual environment. Then you can set this as your project interpreter (helpful with PyCharm) |
57 | +tox -e func-noop |
58 | +source .tox/func-noop/bin/activate |
59 | +``` |
60 | + |
61 | +### Building the charm |
62 | + |
63 | +You can build the charm through different methods: |
64 | + |
65 | +``` |
66 | +# Build using make command |
67 | +make build |
68 | + |
69 | +# Build using charm tools command |
70 | +charm build |
71 | +``` |
72 | + |
73 | +You can set various environment variables (e.g. JUJU_REPOSITORY, CHARM_BUILD_DIR) to build into |
74 | +your desired directory. By default, the project will build into /tmp/charm-builds/duplicity. |
75 | + |
76 | +## Running Tests |
77 | + |
78 | +The following section will review running automated tests in the project. |
79 | + |
80 | +### Unit Tests |
81 | + |
82 | +Unit tests utilize the [pytest framework](https://docs.pytest.org/en/latest/). |
83 | + |
84 | +TODO: Unit tests still need to be implemented fully. However, running them is simple. You can use the make |
85 | +command or call pytest directly: |
86 | + |
87 | +``` |
88 | +make unittest |
89 | + |
90 | +# or |
91 | + |
92 | +pytest |
93 | + |
94 | +``` |
95 | + |
96 | +### Functional Tests |
97 | + |
98 | +This project uses [zaza](https://zaza.readthedocs.io/en/latest/addingcharmtests.html) for it's functional |
99 | +test framework. This provides a solid structure for testing as well as the `functest` tool for granular |
100 | +control over the functional test lifecycle. |
101 | + |
102 | +**Note**: the bundles zaza uses grab the local charm from the default `/tmp/charm-builds/duplicity` |
103 | +directory. You can change this in the bundle, however please refrain from pushing said change as this will |
104 | +move away from the default. |
105 | + |
106 | +You can run the full test suite using make (will also build the charm before running): |
107 | + |
108 | +``` |
109 | +make functional |
110 | + |
111 | +# use tox to skip the build step |
112 | +tox -e functional |
113 | +``` |
114 | + |
115 | +You can also use `functest` to run the suite in chunks, separating the preparation, deployment, configuration, and |
116 | +allowing singular test class to be run. You can find more information regarding these functions in the zaza docs |
117 | +[here](https://zaza.readthedocs.io/en/latest/runningcharmtests.html). |
118 | + |
119 | +### Code Style lint |
120 | + |
121 | +This project uses various linting techniques and tools. To run against the code run the following: |
122 | + |
123 | +``` |
124 | +make lint |
125 | +``` |
126 | + |
127 | +## Authors |
128 | + |
129 | +[Llama (LMA) Charmers](https://launchpad.net/~llama-charmers) |
130 | diff --git a/Makefile b/Makefile |
131 | index 5f771cf..11eea15 100644 |
132 | --- a/Makefile |
133 | +++ b/Makefile |
134 | @@ -25,15 +25,23 @@ unittest: |
135 | @tox -e unit |
136 | |
137 | functional: build |
138 | +<<<<<<< Makefile |
139 | @PYTEST_KEEP_MODEL=$(PYTEST_KEEP_MODEL) \ |
140 | PYTEST_CLOUD_NAME=$(PYTEST_CLOUD_NAME) \ |
141 | PYTEST_CLOUD_REGION=$(PYTEST_CLOUD_REGION) \ |
142 | tox -e functional |
143 | +======= |
144 | + @tox -e functional |
145 | +>>>>>>> Makefile |
146 | |
147 | build: |
148 | @echo "Building charm to base directory $(JUJU_REPOSITORY)" |
149 | @-git describe --tags > ./repo-info |
150 | +<<<<<<< Makefile |
151 | @LAYER_PATH=./layers INTERFACE_PATH=./interfaces TERM=linux \ |
152 | +======= |
153 | + @CHARM_LAYERS_DIR=./layers CHARM_INTERFACES_DIR=./interfaces TERM=linux \ |
154 | +>>>>>>> Makefile |
155 | JUJU_REPOSITORY=$(JUJU_REPOSITORY) charm build . --force |
156 | |
157 | release: clean build |
158 | diff --git a/README.md b/README.md |
159 | new file mode 100644 |
160 | index 0000000..76c567a |
161 | --- /dev/null |
162 | +++ b/README.md |
163 | @@ -0,0 +1,130 @@ |
164 | +# Duplicity Charm |
165 | + |
166 | +## Overview |
167 | + |
168 | +The Duplicity charm provides functionality for both manual and automatic backups for a deployed application. |
169 | +As the name suggests, it utilizes the Duplicity tool and acts as an easy-to-use-and-configure interface for |
170 | +operators to set up backups. |
171 | + |
172 | +After relating the [Duplicity](http://duplicity.nongnu.org/) to another charm, you can backup a directory to |
173 | +either the local unit, a remote host, or even an AWS S3 bucket. All it takes is a bit of configuration and |
174 | +remote destination preparation. |
175 | + |
176 | +The following backends are currently supported: |
177 | +- File (local) |
178 | +- S3 |
179 | +- SCP |
180 | +- Rsync |
181 | +- FTP/SFTP |
182 | + |
183 | +# Usage |
184 | + |
185 | +### Simple deployment |
186 | + |
187 | +This will get duplicity deployed on whatever deployed charm you want. Here, we see |
188 | +it being related to the ubuntu charm. |
189 | + |
190 | +```bash |
191 | +juju deploy ubuntu |
192 | +juju deploy duplicity |
193 | +juju add-relation duplicity ubuntu |
194 | +``` |
195 | + |
196 | +However, we will need to fill out various other, required configs, depending on the backend type selected. |
197 | + |
198 | +### Local file backups |
199 | + |
200 | +This will backup a selected directory to the local unit. |
201 | + |
202 | +``` |
203 | +juju config duplicity \ |
204 | + backend=file \ |
205 | + remote_backup_url=file:///home/me/backups |
206 | + aux_backup_dir=/path/to/back/up |
207 | +``` |
208 | + |
209 | +### SCP/Rsync/SFTP Backups |
210 | + |
211 | +Using the backends scp, rsync, and sftp require, at minimum, the following options to be set. |
212 | + |
213 | +``` |
214 | +juju config duplicity \ |
215 | + backend=scp \ |
216 | + remote_backup_url=my.host:22/my_backups |
217 | + known_host_key='my.host,10.10.10.2 ssh-rsa AAABBBCCC' \ |
218 | + private_ssh_key=$(base64 my_priv_id) |
219 | +``` |
220 | + |
221 | +Alternatively, you can use `remote_password=password` instead of the `private_ssh_key` option if you prefer |
222 | +password authentication. |
223 | + |
224 | +### S3 Backups |
225 | + |
226 | +The following will backup to S3 buckets. This configuration requires an IAM account |
227 | +access and secret key to be passed into the config. |
228 | + |
229 | +``` |
230 | +juju config duplicity \ |
231 | + backend=s3 \ |
232 | + remote_backup_url=s3:my.aws.com/bucket_name/prefix \ |
233 | + aws_access_key_id=my_aws_key \ |
234 | + aws_secret_access_key=my_aws_secret |
235 | +``` |
236 | + |
237 | +### Encryption |
238 | + |
239 | +To encrypt your backups, you can use symmetric encryption using a passed in password or |
240 | +encrypt the backup with a GPG key. Alternative to these methods, you can ignore encryption |
241 | +entirely. |
242 | + |
243 | +``` |
244 | +# Symmetric password encryption |
245 | +juju config duplicity encryption_passphrase=my_passphrase |
246 | + |
247 | +# Asymmetric GPG encryption |
248 | +juju config duplicity gpg_public_key=MY_GPG_KEY |
249 | + |
250 | +# Disable encryption (not recommended) |
251 | +juju config duplicity disable_encryption=True |
252 | +``` |
253 | + |
254 | +### Setting Periodic Backups |
255 | + |
256 | +The big draw of this charm is being able to periodically backup a directory. By default, |
257 | +the charm will only backup manually, i.e. through the `do-backup` action. To enable |
258 | +periodic backups, set `backup_frequency` to any of the following: |
259 | + |
260 | +- hourly |
261 | +- daily |
262 | +- weekly |
263 | +- monthly |
264 | +- any valid cron schedule string |
265 | + |
266 | +### Adding NRPE Checks for alerting |
267 | + |
268 | +Adding NRPE checks allows for alerting when a periodic backup fails to complete. |
269 | + |
270 | +```bash |
271 | +juju deploy nrpe |
272 | +juju add-relation nrpe ubuntu # required on host |
273 | +juju add-relation nrpe duplicity |
274 | +``` |
275 | + |
276 | +# Known Limitations and Future Features |
277 | + |
278 | +This charm is currently still under development. The only supported Duplicity action right now |
279 | +is full backups (through both an action and periodic backups). The following is the list |
280 | +of future Duplicity functionality: |
281 | + |
282 | +- incremental backups |
283 | +- restoring backups |
284 | +- verifying backups |
285 | +- listing backed-up files |
286 | +- cleaning up backed files |
287 | +- additional supported backends |
288 | + |
289 | +# Upstream and Bugs |
290 | + |
291 | +The repository can be found [here](https://git.launchpad.net/charm-duplicity). |
292 | + |
293 | +Please report bugs or feature requests on [Launchpad](https://bugs.launchpad.net/charm-duplicity). |
294 | diff --git a/actions.yaml b/actions.yaml |
295 | index f1b2535..ef702e3 100644 |
296 | --- a/actions.yaml |
297 | +++ b/actions.yaml |
298 | @@ -1,2 +1,19 @@ |
299 | +<<<<<<< actions.yaml |
300 | example-action: |
301 | description: "This is just a test" |
302 | +======= |
303 | +do-backup: |
304 | + description: | |
305 | + Execute the duplicity backup procedure as configured by charm metadata. |
306 | + Config values may be overridden at the command line. |
307 | +# restore: |
308 | +# description: | |
309 | +# Executed the duplicity restore procedure using |
310 | +#verify: |
311 | +# description: | |
312 | +# Verify restores to a temporary path and checks if the result matches the |
313 | +# checksum saved during backup |
314 | +#list-current-files: |
315 | +# description: | |
316 | +# Lists the latest backed up files on the remote repository |
317 | +>>>>>>> actions.yaml |
318 | diff --git a/actions/actions.py b/actions/actions.py |
319 | new file mode 100755 |
320 | index 0000000..695d313 |
321 | --- /dev/null |
322 | +++ b/actions/actions.py |
323 | @@ -0,0 +1,51 @@ |
324 | +#!/usr/local/sbin/charm-env python3 |
325 | + |
326 | +import os |
327 | +import sys |
328 | +from subprocess import CalledProcessError |
329 | + |
330 | +sys.path.append('lib') |
331 | + |
332 | +from charmhelpers.core import hookenv |
333 | +from charms.reactive import clear_flag |
334 | + |
335 | +from lib.lib_duplicity import DuplicityHelper |
336 | + |
337 | + |
338 | +helper = DuplicityHelper() |
339 | +error_file = '/var/run/periodic_backup.error' |
340 | + |
341 | + |
342 | +def do_backup(*args): |
343 | + # TODO: Implement checking to see if application is active. |
344 | + output = helper.do_backup(logger=hookenv.log) |
345 | + hookenv.function_set(dict(output=output.decode('utf-8'))) |
346 | + |
347 | + |
348 | +ACTIONS = { |
349 | + 'do-backup': do_backup |
350 | +} |
351 | + |
352 | + |
353 | +def main(args): |
354 | + action_name = os.path.basename(args[0]) |
355 | + action = ACTIONS.get(action_name) |
356 | + if not action_name: |
357 | + return 'Action "{}" is undefined'.format(action_name) |
358 | + try: |
359 | + action(args) |
360 | + except CalledProcessError as e: |
361 | + err_msg = 'Command "{}" failed with return code "{}" and error output:\n{}'.format( |
362 | + e.cmd, e.returncode, e.output.decode('utf-8')) |
363 | + hookenv.log(err_msg, level=hookenv.ERROR) |
364 | + hookenv.function_fail(err_msg) |
365 | + except Exception as e: |
366 | + hookenv.function_fail(str(e)) |
367 | + else: |
368 | + clear_flag('duplicity.failed_backup') |
369 | + if os.path.exists(error_file): |
370 | + os.remove(error_file) |
371 | + |
372 | + |
373 | +if __name__ == '__main__': |
374 | + sys.exit(main(sys.argv)) |
375 | diff --git a/actions/do-backup b/actions/do-backup |
376 | new file mode 120000 |
377 | index 0000000..405a394 |
378 | --- /dev/null |
379 | +++ b/actions/do-backup |
380 | @@ -0,0 +1 @@ |
381 | +actions.py |
382 | \ No newline at end of file |
383 | diff --git a/config.yaml b/config.yaml |
384 | index 29ed5a5..9f2d2e7 100644 |
385 | --- a/config.yaml |
386 | +++ b/config.yaml |
387 | @@ -1,4 +1,5 @@ |
388 | options: |
389 | +<<<<<<< config.yaml |
390 | string-option: |
391 | type: string |
392 | default: "Default Value" |
393 | @@ -11,3 +12,103 @@ options: |
394 | type: int |
395 | default: 9001 |
396 | description: "A short description of the configuration option" |
397 | +======= |
398 | + aux_backup_directory: |
399 | + type: string |
400 | + default: "/tmp/duplicity" |
401 | + description: | |
402 | + Specifies an additional directory paths which duplicity will monitor on |
403 | + all units for backup. |
404 | + backend: |
405 | + type: string |
406 | + default: "" |
407 | + description: | |
408 | + Accepted values are s3 | ssh | scp | ftp | rsync | file |
409 | + An empty string will disable backups. |
410 | + remote_backup_url: |
411 | + type: string |
412 | + default: "" |
413 | + description: | |
414 | + URL to the remote server and its local path to be used as the |
415 | + backup destination. |
416 | + |
417 | + Backends and their URL formats: |
418 | + file: 'file:///some_dir' |
419 | + ftp & sftp: 'remote.host[:port]/some_dir' |
420 | + rsync: 'other.host[:port]::/module/some_dir' |
421 | + 'other.host[:port]/relative_path' |
422 | + 'other.host[:port]//absolute_path' |
423 | + s3: 's3:other.host[:port]/bucket_name[/prefix]' |
424 | + 's3+http://bucket_name[/prefix]' |
425 | + scp: 'other.host[:port]/some_dir' |
426 | + ssh: 'other.host[:port]/some_dir' |
427 | + aws_access_key_id: |
428 | + type: string |
429 | + default: "" |
430 | + description: | |
431 | + Access key id for the AWS IMA user. The user must have a policy that |
432 | + grants it privileges to upload to the S3 bucket. This value is required |
433 | + when backend='s3'. |
434 | + aws_secret_access_key: |
435 | + type: string |
436 | + default: "" |
437 | + description: | |
438 | + Secret access key for the AWS IMA user. The user must have a policy that |
439 | + grants it privileges to upload to the S3 bucket. This value is required |
440 | + when backend='s3'. |
441 | + remote_user: |
442 | + type: string |
443 | + default: "" |
444 | + description: | |
445 | + This value sets the remote host username for ssh or ftp backups. This is |
446 | + required for ftp type backups and optional for ssh, which if unset it |
447 | + will default to using the local hosts username. |
448 | + remote_password: |
449 | + type: string |
450 | + default: "" |
451 | + description: | |
452 | + This value sets the remote server's password to be used for ssh or ftp |
453 | + backups. This is required for ftp backups and optional for ssh, which if |
454 | + unset may still be able to authenticate via trusted host keys. |
455 | + backup_frequency: |
456 | + type: string |
457 | + default: "manual" |
458 | + description: | |
459 | + Sets the crontab backup frequency to a valid cron string or one of the following: |
460 | + hourly|daily|weekly|monthly|manual |
461 | + If set to manual, crontab backup will not run. |
462 | + disable_encryption: |
463 | + type: boolean |
464 | + default: False |
465 | + description: | |
466 | + By default, duplicity uses symmetric encryption on backup, requiring a |
467 | + simple password. Duplicity also supports asymmetric encryption, via GPG |
468 | + keys. Setting this value to True disables encryption across the entire |
469 | + application. |
470 | + encryption_passphrase: |
471 | + type: string |
472 | + default: "" |
473 | + description: | |
474 | + Set a passphrase required to perform symmetric encryption. |
475 | + gpg_public_key: |
476 | + type: string |
477 | + default: "" |
478 | + description: | |
479 | + Sets the GPG Public Key used for asymmetrical encryption. When set, this |
480 | + becomes the primary method for encryption. |
481 | + known_host_key: |
482 | + type: string |
483 | + default: '' |
484 | + description: | |
485 | + Host key for remote backup host when using scp, rsync, and sftp backends. |
486 | + Valid host key required when using these backends. The format is: |
487 | + hostname[,ip] algo public_key |
488 | + |
489 | + ex: example.com,10.0.0.0 ssh-rsa AAABBBCCC... |
490 | + private_ssh_key: |
491 | + type: string |
492 | + default: '' |
493 | + description: |
494 | + base64 encoded private SSH key for SSH authentication from duplicity |
495 | + application unit and the remote backup host. |
496 | +>>>>>>> config.yaml |
497 | diff --git a/layer.yaml b/layer.yaml |
498 | index cb74697..aa5a1df 100644 |
499 | --- a/layer.yaml |
500 | +++ b/layer.yaml |
501 | @@ -4,4 +4,17 @@ exclude: |
502 | - layers |
503 | # include required layers here |
504 | includes: |
505 | +<<<<<<< layer.yaml |
506 | - layer:basic |
507 | +======= |
508 | + - layer:apt |
509 | + - layer:nagios |
510 | + - interface:juju-info |
511 | +options: |
512 | + basic: |
513 | + python_packages: |
514 | + - croniter |
515 | + - pidfile |
516 | + use_venv: True |
517 | + include_system_packages: true |
518 | +>>>>>>> layer.yaml |
519 | diff --git a/lib/lib_duplicity.py b/lib/lib_duplicity.py |
520 | new file mode 100644 |
521 | index 0000000..5333bea |
522 | --- /dev/null |
523 | +++ b/lib/lib_duplicity.py |
524 | @@ -0,0 +1,195 @@ |
525 | +import subprocess |
526 | +import os |
527 | + |
528 | +from charmhelpers.core import hookenv, templating |
529 | + |
530 | + |
531 | +BACKUP_CRON_FILE = '/etc/cron.d/periodic_backup' |
532 | +BACKUP_CRON_LOG_PATH = '/var/log/duplicity' |
533 | + |
534 | + |
535 | +def safe_remove_backup_cron(): |
536 | + if os.path.exists(BACKUP_CRON_FILE): |
537 | + hookenv.log('Removing backup cron file.', level=hookenv.DEBUG) |
538 | + os.remove(BACKUP_CRON_FILE) |
539 | + hookenv.log('Backup cron file removed.', level=hookenv.DEBUG) |
540 | + |
541 | + |
542 | +class DuplicityHelper(): |
543 | + def __init__(self): |
544 | + self.charm_config = hookenv.config() |
545 | + |
546 | + @property |
547 | + def backup_cmd(self): |
548 | + cmd = ['duplicity'] |
549 | + if self.charm_config.get('private_ssh_key'): |
550 | + cmd.append('--ssh-options=-oIdentityFile=/root/.ssh/duplicity_id_rsa') |
551 | + # later switch to cmd.append('full' if self.charm_config.get('full_backup') else 'incr') |
552 | + # when full_backup implemented |
553 | + cmd.append('full') |
554 | + cmd.extend([self.charm_config.get('aux_backup_directory'), self._backup_url()]) |
555 | + cmd.extend(self._additional_options()) |
556 | + return cmd |
557 | + |
558 | + @property |
559 | + def list_files_cmd(self): |
560 | + cmd = ['duplicity', 'list-current-files', self._backup_url()] |
561 | + cmd.extend(self._additional_options()) |
562 | + return cmd |
563 | + |
564 | + def _backup_url(self): |
565 | + """ |
566 | + Helper function to assemble the backup url into a format accepted |
567 | + by duplicity, based off of the 'backend' and 'remote_backup_url' |
568 | + defined in the charm config. _backup_url will be appended with the |
569 | + charms unit name |
570 | + """ |
571 | + backend = self.charm_config.get("backend").lower() |
572 | + prefix = "{}://".format(backend) |
573 | + remote_path = self.charm_config.get("remote_backup_url") |
574 | + |
575 | + # start building the url |
576 | + url = "" |
577 | + |
578 | + if backend in ["rsync", "scp", "ssh", "ftp", "sftp"]: |
579 | + # These all require SSH Type Authentication and will attempt to use |
580 | + # the provided remote host credentials |
581 | + user = self.charm_config.get("remote_user") |
582 | + password = self.charm_config.get("remote_password") |
583 | + if user: |
584 | + if password: |
585 | + url += "{}:{}@".format(user, password) |
586 | + else: |
587 | + url += "{}@".format(user) |
588 | + url += remote_path.replace(prefix, "") |
589 | + elif backend in ["s3", "file"]: |
590 | + url = remote_path.replace(prefix, "") |
591 | + else: |
592 | + return None |
593 | + |
594 | + url = "{}://".format(backend) + url + "/{}".format( |
595 | + hookenv.local_unit().replace("/", "-")) |
596 | + return url |
597 | + |
598 | + def _set_environment_vars(self): |
599 | + """ |
600 | + Helper function sets the required environmental variables used by |
601 | + duplicity. |
602 | + :return: |
603 | + """ |
604 | + # Set the Aws Credentials. It doesnt matter if they are used or not |
605 | + os.environ["AWS_SECRET_ACCESS_KEY"] = self.charm_config.get( |
606 | + "aws_secret_access_key") |
607 | + os.environ["AWS_ACCESS_KEY_ID"] = self.charm_config.get( |
608 | + "aws_access_key_id") |
609 | + os.environ["PASSWORD"] = self.charm_config.get( |
610 | + "encryption_passphrase") |
611 | + |
612 | + def _additional_options(self): |
613 | + """ |
614 | + Parses the config options and provides a list of args to be passed to |
615 | + duplicity, and useful for multiple duplicity actions. |
616 | + :return: |
617 | + """ |
618 | + # backups named after the unit |
619 | + cmd = [] |
620 | + |
621 | + if self.charm_config.get("disable_encryption"): |
622 | + cmd.append("--no-encryption") |
623 | + elif self.charm_config.config.get("gpg_public_key"): |
624 | + cmd.append("--gpg-key={}".format( |
625 | + self.charm_config.config.get("gpg_public_key"))) |
626 | + return cmd |
627 | + |
628 | + def setup_backup_cron(self): |
629 | + """ |
630 | + Sets up the backup cron to run on the unit. Renders the cron and ensures logging |
631 | + directory exists. |
632 | + """ |
633 | + if not os.path.exists(BACKUP_CRON_LOG_PATH): |
634 | + os.mkdir(BACKUP_CRON_LOG_PATH) |
635 | + self._render_backup_cron() |
636 | + |
637 | + def _render_backup_cron(self): |
638 | + """ |
639 | + Render backup cron. |
640 | + """ |
641 | + backup_frequency = self.charm_config.get('backup_frequency') |
642 | + if backup_frequency in ['hourly', 'daily', 'weekly', 'monthly']: |
643 | + backup_frequency = '@{}'.format(backup_frequency) |
644 | + cron_ctx = dict( |
645 | + frequency=backup_frequency, |
646 | + unit_name=hookenv.local_unit(), |
647 | + charm_dir=hookenv.charm_dir() |
648 | + ) |
649 | + templating.render('periodic_backup', BACKUP_CRON_FILE, cron_ctx) |
650 | + with open('/etc/cron.d/periodic_backup', 'a') as cron_file: |
651 | + cron_file.write('\n') |
652 | + |
653 | + @staticmethod |
654 | + def update_known_host_file(known_host_key): |
655 | + permissions = 'a+' if os.path.exists('root_known_host_path') else 'w+' |
656 | + with open('/root/.ssh/known_hosts', permissions) as known_host_file: |
657 | + if known_host_key not in known_host_file.read(): |
658 | + print(known_host_key, file=known_host_file) |
659 | + |
660 | + def do_backup(self, **kwargs): |
661 | + """ Execute the backup call to duplicity as configured by the charm |
662 | + |
663 | + :param: kwargs |
664 | + :type: dictionary of values that may be used instead of config values |
665 | + """ |
666 | + self._set_environment_vars() |
667 | + cmd = self.backup_cmd |
668 | + # TODO: Clean password from command!!! |
669 | + hookenv.log("Duplicity Command: {}".format(cmd)) |
670 | + return subprocess.check_output(cmd, stderr=subprocess.STDOUT) |
671 | + |
672 | + def cleanup(self): |
673 | + #TODO |
674 | + # duplicity cleanup <target_url> |
675 | + raise NotImplementedError() |
676 | + |
677 | + def verify(self): |
678 | + #TODO |
679 | + # duplicity verify <target_url> <source_dir> |
680 | + raise NotImplementedError() |
681 | + |
682 | + def collection_status(self): |
683 | + #TODO |
684 | + # duplicity collection-status <target_url> |
685 | + raise NotImplementedError() |
686 | + |
687 | + def list_current_files(self, **kwargs): |
688 | + """ |
689 | + Function that runs duplicity list current files in the remote |
690 | + directory. |
691 | + :return: |
692 | + """ |
693 | + raise NotImplementedError() |
694 | + # self._set_environment_vars() |
695 | + # cmd = self.list_files_cmd |
696 | + # try: |
697 | + # subprocess.check_call(cmd) |
698 | + # except subprocess.CalledProcessError as e: |
699 | + # pass |
700 | + |
701 | + def restore(self): |
702 | + #TODO |
703 | + # duplicity restore <source_url> <target_dir> |
704 | + raise NotImplementedError() |
705 | + |
706 | + def remove_older_than(self): |
707 | + #TODO |
708 | + # duplicity remove-older-than time [options] target_url |
709 | + raise NotImplementedError() |
710 | + |
711 | + def remove_all_but_n_full(self): |
712 | + #TODO |
713 | + # duplicity remove-all-but-n-full <count> <target_url> |
714 | + raise NotImplementedError() |
715 | + |
716 | + def remove_all_inc_of_but_n_full(self): |
717 | + #TODO |
718 | + # duplicity remove-all-inc-of-but-n-full <count> <target_url> |
719 | + raise NotImplementedError() |
720 | diff --git a/metadata.yaml b/metadata.yaml |
721 | index bf95c82..8d707fb 100644 |
722 | --- a/metadata.yaml |
723 | +++ b/metadata.yaml |
724 | @@ -1,3 +1,4 @@ |
725 | +<<<<<<< metadata.yaml |
726 | name: charm-duplicity |
727 | summary: <Fill in summary here> |
728 | maintainer: Ryan Farrell <Ryan.Farrell@ryancision7250> |
729 | @@ -20,3 +21,32 @@ series: |
730 | # peers: |
731 | # peer-relation: |
732 | # interface: interface-name |
733 | +======= |
734 | +name: duplicity |
735 | +summary: Duplicity offers encrypted bandwidth-efficient backup |
736 | +maintainer: Ryan Farrell <Ryan.Farrell@ryancision7250> |
737 | +description: | |
738 | + Duplicity backs directories by producing encrypted tar-format volumes |
739 | + and uploading them to a remote or local file server. Because duplicity |
740 | + uses librsync, the incremental archives are space efficient and only |
741 | + record the parts of files that have changed since the last backup. |
742 | + Because duplicity uses GnuPG to encrypt and/or sign these archives, |
743 | + they will be safe from spying and/or modification by the server. |
744 | +tags: |
745 | + # Replace "misc" with one or more whitelisted tags from this list: |
746 | + # https://jujucharms.com/docs/stable/authors-charm-metadata |
747 | + - backup |
748 | +subordinate: true |
749 | +series: |
750 | + - bionic |
751 | + - xenial |
752 | +provides: |
753 | + nrpe-external-master: |
754 | + interface: nrpe-external-master |
755 | + scope: container |
756 | + optional: true |
757 | +requires: |
758 | + general-info: |
759 | + interface: juju-info |
760 | + scope: container |
761 | +>>>>>>> metadata.yaml |
762 | diff --git a/reactive/duplicity.py b/reactive/duplicity.py |
763 | new file mode 100644 |
764 | index 0000000..10ce0b2 |
765 | --- /dev/null |
766 | +++ b/reactive/duplicity.py |
767 | @@ -0,0 +1,247 @@ |
768 | +""" |
769 | +This is the collection of the duplicity charm's reactive scripts. It defines |
770 | +functions used as callbacks to shape the charm's behavior during various state |
771 | +change events such as relations being added, config values changing, relations |
772 | +joining etc. |
773 | + |
774 | +See the following for information about reactive charms: |
775 | + * https://jujucharms.com/docs/devel/developer-getting-started |
776 | + * https://github.com/juju-solutions/layer-basic#overview |
777 | +""" |
778 | +import base64 |
779 | +import os |
780 | + |
781 | +from charmhelpers.core import hookenv, host |
782 | +from charmhelpers.contrib.charmsupport.nrpe import NRPE |
783 | +from charmhelpers import fetch |
784 | +from charms.reactive import set_flag, clear_flag, when_not, when, hook, when_any, when_all |
785 | +import croniter |
786 | + |
787 | +from lib_duplicity import DuplicityHelper, safe_remove_backup_cron |
788 | + |
789 | +PRIVATE_SSH_KEY_PATH = '/root/.ssh/duplicity_id_rsa' |
790 | +PLUGINS_DIR = '/usr/local/lib/nagios/plugins/' |
791 | + |
792 | +helper = DuplicityHelper() |
793 | + |
794 | + |
795 | +@when_not('duplicity.installed') |
796 | +def install_duplicity(): |
797 | + """ |
798 | + Apt install duplicity's dependencies: |
799 | + - duplicity |
800 | + - python-paramiko for ssh |
801 | + - python-boto for aws |
802 | + |
803 | + :return: |
804 | + """ |
805 | + hookenv.status_set("maintenance", "Installing duplicity") |
806 | + fetch.apt_install("duplicity") |
807 | + fetch.apt_install("python-paramiko") |
808 | + fetch.apt_install("python-boto") |
809 | + fetch.apt_install("lftp") |
810 | + hookenv.status_set('active', '') |
811 | + set_flag('duplicity.installed') |
812 | + |
813 | + |
814 | +@when_any('config.changed.backend', |
815 | + 'config.changed.aws_access_key_id', |
816 | + 'config.changed.aws_secret_access_key' |
817 | + 'config.changed.remote_backup_url', |
818 | + 'config.changed.known_host_key', |
819 | + 'config.changed.remote_password', |
820 | + 'config.changed.private_ssh_key') |
821 | +def validate_backend(): |
822 | + """ |
823 | + Validates that the config value for 'backend' is something that duplicity |
824 | + can use (see config description for backend for the accepted types). For S3 |
825 | + only, check that the AWS IMA credentials are also set. |
826 | + """ |
827 | + backend = hookenv.config().get("backend").lower() |
828 | + if backend not in ["s3", "ssh", "scp", "sftp", "ftp", "rsync", "file"]: |
829 | + hookenv.status_set('blocked', |
830 | + 'Unrecognized backend "{}"'.format(backend)) |
831 | + clear_flag('duplicity.valid_backend') |
832 | + return |
833 | + elif backend == "s3": |
834 | + # make sure 'aws_access_key_id' and 'aws_secret_access_key' exist |
835 | + if not hookenv.config().get("aws_access_key_id") and \ |
836 | + not hookenv.config().get("aws_secret_access_key"): |
837 | + hookenv.status_set('blocked', 'S3 backups require \ |
838 | +"aws_access_key_id" and "aws_secret_access_key" to be set') |
839 | + clear_flag('duplicity.valid_backend') |
840 | + return |
841 | + if backend in ['scp', 'rsync', 'sftp']: |
842 | + known_host_key = hookenv.config().get('known_host_key') |
843 | + if not known_host_key: |
844 | + hookenv.status_set('blocked', '{} backend requires known_host_key to be set.'.format(backend)) |
845 | + clear_flag('duplicity.valid_backend') |
846 | + return |
847 | + else: |
848 | + helper.update_known_host_file(known_host_key) |
849 | + if not hookenv.config().get('remote_password') and not hookenv.config().get('private_ssh_key'): |
850 | + hookenv.status_set( |
851 | + 'blocked', |
852 | + 'Backend "{}" requires either remote_password or private_ssh_key to be set'.format(backend)) |
853 | + clear_flag('duplicity.valid_backend') |
854 | + return |
855 | + if not hookenv.config().get("remote_backup_url"): |
856 | + # remote url is unset |
857 | + hookenv.status_set('blocked', 'Backup path is required. Set config \ |
858 | +for "remote_backup_url"') |
859 | + clear_flag('duplicity.valid_backend') |
860 | + return |
861 | + set_flag('duplicity.valid_backend') |
862 | + |
863 | + |
864 | +@when('config.changed.aux_backup_directory') |
865 | +def create_aux_backup_directory(): |
866 | + aux_backup_dir = hookenv.config().get("aux_backup_directory") |
867 | + if aux_backup_dir: |
868 | + # if the data is not ok to make a directory path then let juju catch it |
869 | + if not os.path.exists(aux_backup_dir): |
870 | + os.makedirs(aux_backup_dir) |
871 | + hookenv.log("Creating auxiliary backup directory: {}".format( |
872 | + aux_backup_dir)) |
873 | + |
874 | + |
875 | +@when('config.changed.backup_frequency') |
876 | +def validate_cron_frequency(): |
877 | + cron_frequency = hookenv.config().get("backup_frequency").lower() |
878 | + no_cron_options = ['manual'] |
879 | + create_cron_options = ['hourly', 'daily', 'weekly', 'monthly'] |
880 | + if cron_frequency in no_cron_options: |
881 | + set_flag('duplicity.remove_backup_cron') |
882 | + elif cron_frequency in create_cron_options: |
883 | + set_flag('duplicity.create_backup_cron') |
884 | + else: |
885 | + try: |
886 | + croniter.croniter(cron_frequency) |
887 | + set_flag('duplicity.create_backup_cron') |
888 | + except (croniter.CroniterBadCronError, croniter.CroniterBadDateError, croniter.CroniterNotAlphaError): |
889 | + clear_flag('duplicity.valid_backup_frequency') |
890 | + clear_flag('duplicity.create_backup_cron') |
891 | + hookenv.status_set('blocked', |
892 | + 'Invalid value "{}" for cron frequency'.format(cron_frequency)) |
893 | + return |
894 | + set_flag('duplicity.valid_backup_frequency') |
895 | + |
896 | + |
897 | +@when_any('config.changed.encryption_passphrase', |
898 | + 'config.changed.gpg_public_key', |
899 | + 'config.changed.disable_encryption') |
900 | +def validate_encryption_method(): |
901 | + """ |
902 | + Function to check that a viable encryption method is configured. |
903 | + """ |
904 | + passphrase = hookenv.config().get("encryption_passphrase") |
905 | + gpg_key = hookenv.config().get("gpg_public_key") |
906 | + disable = hookenv.config().get("disable_encryption") |
907 | + if not passphrase and not gpg_key and not disable: |
908 | + hookenv.status_set('blocked', 'Must set either an encryption \ |
909 | +passphrase, GPG public key, or disable encryption') |
910 | + clear_flag('duplicity.valid_encryption_method') |
911 | + return |
912 | + set_flag('duplicity.valid_encryption_method') |
913 | + |
914 | + |
915 | +@when_all('duplicity.valid_encryption_method', |
916 | + 'duplicity.valid_backend', |
917 | + 'duplicity.valid_backup_frequency') |
918 | +@when_not('duplicity.invalid_private_ssh_key') |
919 | +def app_ready(): |
920 | + hookenv.status_set('active', 'Ready') |
921 | + |
922 | + |
923 | +@when_all('duplicity.create_backup_cron', |
924 | + 'duplicity.valid_encryption_method', |
925 | + 'duplicity.valid_backend') |
926 | +def create_backup_cron(): |
927 | + """ |
928 | + Finalizes the backup cron script when duplicity has been configured |
929 | + successfully. The cron script will be a call to juju run-action do-backup |
930 | + """ |
931 | + hookenv.status_set('active', 'Rendering duplicity crontab') |
932 | + helper.setup_backup_cron() |
933 | + hookenv.status_set('active', 'Ready.') |
934 | + clear_flag('duplicity.create_backup_cron') |
935 | + |
936 | + |
937 | +@when('duplicity.remove_backup_cron') |
938 | +def remove_backup_cron(): |
939 | + """ |
940 | + Stops and removes the backup cron in case of duplicity not being configured correctly or manual|auto |
941 | + config option is set. The former ensures backups won't run under an incorrect config. |
942 | + """ |
943 | + cron_backup_frequency = hookenv.config().get('backup_frequency') |
944 | + hookenv.log( |
945 | + 'Backup frequency set to {}. Skipping or removing cron setup.'.format(cron_backup_frequency)) |
946 | + safe_remove_backup_cron() |
947 | + clear_flag('duplicity.remove_backup_cron') |
948 | + |
949 | + |
950 | +@when('config.changed.private_ssh_key') |
951 | +def update_private_ssh_key(): |
952 | + private_key = hookenv.config().get('private_ssh_key') |
953 | + if private_key: |
954 | + hookenv.log('Updating private ssh key file.') |
955 | + encoded_private_key = hookenv.config().get('private_ssh_key') |
956 | + try: |
957 | + decoded_private_key = base64.b64decode(encoded_private_key).decode('utf-8') |
958 | + except UnicodeDecodeError as e: |
959 | + hookenv.log( |
960 | + 'Failed to decode private key {} to utf-8 with error: {}.\nNot creating ssh key file'.format( |
961 | + encoded_private_key, e), |
962 | + level=hookenv.ERROR) |
963 | + hookenv.status_set(workload_state='blocked', |
964 | + message='invalid private_ssh_key. ensure that key is base64 encoded') |
965 | + set_flag('duplicity.invalid_private_ssh_key') |
966 | + return |
967 | + with open(PRIVATE_SSH_KEY_PATH, 'w') as f: |
968 | + f.write(decoded_private_key) |
969 | + hookenv.log('Updated private ssh key file.') |
970 | + else: |
971 | + if os.path.exists(PRIVATE_SSH_KEY_PATH): |
972 | + os.remove(PRIVATE_SSH_KEY_PATH) |
973 | + clear_flag('duplicity.invalid_private_ssh_key') |
974 | + |
975 | + |
976 | +@when('nrpe-external-master.available') |
977 | +@when_not('nrpe-external-master.initial-config') |
978 | +def initial_nrpe_config(): |
979 | + set_flag('nrpe-external-master.initial-config') |
980 | + render_checks() |
981 | + |
982 | + |
983 | +@when('nrpe-external-master.initial-config') |
984 | +@when_any('config.changed.nagios_context', |
985 | + 'config.changed.nagios_servicegroups') |
986 | +def render_checks(): |
987 | + hookenv.log('Creating NRPE checks.') |
988 | + charm_plugin_dir = os.path.join(hookenv.charm_dir(), 'scripts', 'plugins/') |
989 | + if not os.path.exists(PLUGINS_DIR): |
990 | + os.makedirs(PLUGINS_DIR) |
991 | + host.rsync(charm_plugin_dir, PLUGINS_DIR) |
992 | + nrpe = NRPE() |
993 | + unit_name = hookenv.local_unit() |
994 | + nrpe.add_check( |
995 | + check_cmd=os.path.join(PLUGINS_DIR, 'check_backup_status.py'), |
996 | + shortname='backups', |
997 | + description='Check that periodic backups have not failed.' |
998 | + ) |
999 | + nrpe.write() |
1000 | + set_flag('nrpe-external-master.configured') |
1001 | + hookenv.log('NRPE checks created.') |
1002 | + |
1003 | + |
1004 | +@when('nrpe-external-master.configured') |
1005 | +@when_not('nrpe-external-master.available') |
1006 | +def remove_nrpe_checks(): |
1007 | + nrpe = NRPE() |
1008 | + nrpe.remove_check(shortname='backups') |
1009 | + clear_flag('nrpe-external-master.configured') |
1010 | + |
1011 | + |
1012 | +@hook() |
1013 | +def stop(): |
1014 | + safe_remove_backup_cron() |
1015 | diff --git a/requirements.txt b/requirements.txt |
1016 | index 8462291..371d4f8 100644 |
1017 | --- a/requirements.txt |
1018 | +++ b/requirements.txt |
1019 | @@ -1 +1,6 @@ |
1020 | # Include python requirements here |
1021 | +<<<<<<< requirements.txt |
1022 | +======= |
1023 | +croniter |
1024 | +pidfile |
1025 | +>>>>>>> requirements.txt |
1026 | diff --git a/scripts/periodic_backup.py b/scripts/periodic_backup.py |
1027 | new file mode 100755 |
1028 | index 0000000..db00bee |
1029 | --- /dev/null |
1030 | +++ b/scripts/periodic_backup.py |
1031 | @@ -0,0 +1,50 @@ |
1032 | +#!/usr/local/sbin/charm-env python3 |
1033 | +import sys |
1034 | +import subprocess |
1035 | +import os |
1036 | + |
1037 | +sys.path.append('lib') |
1038 | + |
1039 | +from charmhelpers.core import hookenv |
1040 | +from charms.reactive import clear_flag |
1041 | +from pidfile import PidFile |
1042 | + |
1043 | +from lib import lib_duplicity |
1044 | + |
1045 | + |
1046 | +pidfile = '/var/run/periodic_backup.pid' |
1047 | +error_file = '/var/run/periodic_backup.error' |
1048 | + |
1049 | + |
1050 | +def write_error_file(message): |
1051 | + with open(error_file, 'w') as f: |
1052 | + f.write(message) |
1053 | + |
1054 | + |
1055 | +def main(): |
1056 | + try: |
1057 | + hookenv.log('Performing backup.') |
1058 | + output = lib_duplicity.DuplicityHelper().do_backup() |
1059 | + hookenv.log('Periodic backup complete with following output:\n{}'.format(output.decode('utf-8'))) |
1060 | + except subprocess.CalledProcessError as e: |
1061 | + err_msg = 'Periodic backup failed. Command "{}" failed with return code "{}" and error output:\n{}'.format( |
1062 | + e.cmd, e.returncode, e.output.decode('utf-8')) |
1063 | + hookenv.log(err_msg, level=hookenv.ERROR) |
1064 | + write_error_file(err_msg) |
1065 | + except Exception as e: |
1066 | + err_msg = 'Periodic backup failed: {}'.format(str(e)) |
1067 | + hookenv.log(err_msg, level=hookenv.ERROR) |
1068 | + write_error_file(err_msg) |
1069 | + else: |
1070 | + clear_flag('duplicity.failed_backup') |
1071 | + if os.path.exists(error_file): |
1072 | + os.remove(error_file) |
1073 | + |
1074 | + |
1075 | +if __name__ == "__main__": |
1076 | + status, workload_msg = hookenv.status_get() |
1077 | + if status != 'active': |
1078 | + hookenv.log('Duplicity unit must be in ready state to execute backup command.', level=hookenv.WARNING) |
1079 | + sys.exit() |
1080 | + with PidFile(pidfile): |
1081 | + main() |
1082 | diff --git a/scripts/plugins/check_backup_status.py b/scripts/plugins/check_backup_status.py |
1083 | new file mode 100755 |
1084 | index 0000000..7c34410 |
1085 | --- /dev/null |
1086 | +++ b/scripts/plugins/check_backup_status.py |
1087 | @@ -0,0 +1,19 @@ |
1088 | +#!/usr/bin/env python3 |
1089 | + |
1090 | +import sys |
1091 | +import os |
1092 | + |
1093 | +error_file = '/var/run/periodic_backup.error' |
1094 | + |
1095 | + |
1096 | +def main(): |
1097 | + if os.path.exists(error_file): |
1098 | + with open(error_file) as f: |
1099 | + print('WARNING: Backup not completed successfully:\n', f.read()) |
1100 | + return 1 |
1101 | + print('OK') |
1102 | + return 0 |
1103 | + |
1104 | + |
1105 | +if __name__ == '__main__': |
1106 | + sys.exit(main()) |
1107 | diff --git a/templates/periodic_backup b/templates/periodic_backup |
1108 | new file mode 100644 |
1109 | index 0000000..8a5fd24 |
1110 | --- /dev/null |
1111 | +++ b/templates/periodic_backup |
1112 | @@ -0,0 +1 @@ |
1113 | +{{ frequency }} root /usr/bin/juju-run {{ unit_name }} {{ charm_dir }}/scripts/periodic_backup.py |
1114 | \ No newline at end of file |
1115 | diff --git a/tests/bundles/bionic.yaml b/tests/bundles/bionic.yaml |
1116 | new file mode 100644 |
1117 | index 0000000..5f60792 |
1118 | --- /dev/null |
1119 | +++ b/tests/bundles/bionic.yaml |
1120 | @@ -0,0 +1,28 @@ |
1121 | +series: bionic |
1122 | + |
1123 | +applications: |
1124 | + ubuntu: |
1125 | + charm: cs:ubuntu |
1126 | + num_units: 1 |
1127 | + |
1128 | + backup-host: |
1129 | + charm: cs:ubuntu |
1130 | + num_units: 1 |
1131 | + |
1132 | + nrpe: |
1133 | + charm: cs:nrpe |
1134 | + |
1135 | + duplicity: |
1136 | + charm: /tmp/charm-builds/duplicity |
1137 | + options: |
1138 | + backend: file |
1139 | + remote_backup_url: 'file:///home/ubuntu/somedir' |
1140 | + disable_encryption: True |
1141 | + |
1142 | +relations: |
1143 | + - - ubuntu |
1144 | + - duplicity |
1145 | + - - nrpe |
1146 | + - duplicity |
1147 | + - - nrpe |
1148 | + - ubuntu |
1149 | \ No newline at end of file |
1150 | diff --git a/tests/bundles/xenial.yaml b/tests/bundles/xenial.yaml |
1151 | new file mode 100644 |
1152 | index 0000000..809ad44 |
1153 | --- /dev/null |
1154 | +++ b/tests/bundles/xenial.yaml |
1155 | @@ -0,0 +1,28 @@ |
1156 | +series: xenial |
1157 | + |
1158 | +applications: |
1159 | + ubuntu: |
1160 | + charm: cs:ubuntu |
1161 | + num_units: 1 |
1162 | + |
1163 | + backup-host: |
1164 | + charm: cs:ubuntu |
1165 | + num_units: 1 |
1166 | + |
1167 | + nrpe: |
1168 | + charm: cs:nrpe |
1169 | + |
1170 | + duplicity: |
1171 | + charm: /tmp/charm-builds/duplicity |
1172 | + options: |
1173 | + backend: file |
1174 | + remote_backup_url: 'file:///home/ubuntu/somedir' |
1175 | + disable_encryption: True |
1176 | + |
1177 | +relations: |
1178 | + - - ubuntu |
1179 | + - duplicity |
1180 | + - - duplicity |
1181 | + - nrpe |
1182 | + - - nrpe |
1183 | + - ubuntu |
1184 | diff --git a/tests/functional/configure.py b/tests/functional/configure.py |
1185 | new file mode 100644 |
1186 | index 0000000..fa6b612 |
1187 | --- /dev/null |
1188 | +++ b/tests/functional/configure.py |
1189 | @@ -0,0 +1,82 @@ |
1190 | +import logging |
1191 | +import subprocess |
1192 | +from pprint import pprint |
1193 | + |
1194 | +import zaza.model |
1195 | + |
1196 | + |
1197 | +ubuntu_user_pass = 'ubuntu:sUp3r5ecr3tP45SW0rd' |
1198 | +ubuntu_backup_directory_source = '/home/ubuntu/test-files' |
1199 | + |
1200 | + |
1201 | +def set_ubuntu_password_on_backup_host(): |
1202 | + command = 'echo "{}" | chpasswd'.format(ubuntu_user_pass) |
1203 | + backup_host_unit = _get_unit('backup-host') |
1204 | + result = zaza.model.run_on_unit(backup_host_unit.name, command, timeout=15) |
1205 | + _check_run_result(result) |
1206 | + |
1207 | + |
1208 | +def set_ssh_password_access_on_backup_host(): |
1209 | + backup_host_unit = _get_unit('backup-host') |
1210 | + command = "sed -i 's/PasswordAuthentication no/PasswordAuthentication yes/' /etc/ssh/sshd_config && " \ |
1211 | + "service sshd reload" |
1212 | + result = zaza.model.run_on_unit(backup_host_unit.name, command, timeout=15) |
1213 | + _check_run_result(result) |
1214 | + |
1215 | + |
1216 | +def setup_test_files_for_backup(): |
1217 | + ubuntu_unit = _get_unit('ubuntu') |
1218 | + command = 'runuser -l ubuntu -c "mkdir {}"'.format(ubuntu_backup_directory_source) |
1219 | + result = zaza.model.run_on_unit(ubuntu_unit.name, command, timeout=15) |
1220 | + _check_run_result(result, codes=['1']) |
1221 | + zaza.model.scp_to_unit( |
1222 | + unit_name=ubuntu_unit.name, |
1223 | + source='./tests/functional/resources/hello-world.txt', |
1224 | + destination=ubuntu_backup_directory_source |
1225 | + ) |
1226 | + |
1227 | + |
1228 | +def set_backup_host_known_host_key(): |
1229 | + backup_host_ip = _get_unit('backup-host').public_address |
1230 | + command = ['ssh-keyscan', '-t', 'rsa', backup_host_ip] |
1231 | + output = subprocess.check_output(command) |
1232 | + zaza.model.set_application_config('duplicity', dict(known_host_key=output.decode('utf-8'))) |
1233 | + |
1234 | + |
1235 | +def add_pub_key_to_backup_host(): |
1236 | + backup_host_unit = _get_unit('backup-host') |
1237 | + with open('./tests/functional/resources/testing_id_rsa.pub') as f: |
1238 | + pub_key = f.read().strip() |
1239 | + command = 'echo "{}" >> /home/ubuntu/.ssh/authorized_keys'.format(pub_key) |
1240 | + result = zaza.model.run_on_unit(backup_host_unit.name, command, timeout=15) |
1241 | + _check_run_result(result) |
1242 | + |
1243 | + |
1244 | +def setup_ftp(): |
1245 | + backup_host_unit = _get_unit('backup-host') |
1246 | + install_command = 'apt install -y vsftpd' |
1247 | + result = zaza.model.run_on_unit(backup_host_unit.name, install_command, timeout=15) |
1248 | + _check_run_result(result) |
1249 | + vsconf = ['write_enable', 'ascii_upload_enable', 'ascii_download_enable', 'chroot_local_user', |
1250 | + 'chroot_list_enable', 'ls_recurse_enable'] |
1251 | + configure_command = 'for i in {}; do sed -i "s/#$i/$i/" /etc/vsftpd.conf; done && ' \ |
1252 | + 'echo ubuntu > /etc/vsftpd.chroot_list && ' \ |
1253 | + 'systemctl restart vsftpd'.format(' '.join(vsconf)) |
1254 | + result = zaza.model.run_on_unit(backup_host_unit.name, configure_command, timeout=15) |
1255 | + _check_run_result(result) |
1256 | + |
1257 | + |
1258 | +def _check_run_result(result, codes=None): |
1259 | + if not result: |
1260 | + raise Exception('Failed to get a result from run_on_unit command.') |
1261 | + allowed_codes = list('0') |
1262 | + if codes: |
1263 | + allowed_codes.extend(codes) |
1264 | + if result['Code'] not in allowed_codes: |
1265 | + logging.error('Bad result code received. Result code: {}'.format(result['Code'])) |
1266 | + logging.error('Returned: \n{}'.format(result)) |
1267 | + raise Exception('Command returned non-zero return code.') |
1268 | + |
1269 | + |
1270 | +def _get_unit(app): |
1271 | + return zaza.model.get_units(app)[0] |
1272 | diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt |
1273 | index f76bfbb..bc5e8ea 100644 |
1274 | --- a/tests/functional/requirements.txt |
1275 | +++ b/tests/functional/requirements.txt |
1276 | @@ -1,6 +1,10 @@ |
1277 | flake8 |
1278 | juju |
1279 | mock |
1280 | +<<<<<<< tests/functional/requirements.txt |
1281 | pytest |
1282 | pytest-asyncio |
1283 | requests |
1284 | +======= |
1285 | +git+https://github.com/openstack-charmers/zaza.git#egg=zaza |
1286 | +>>>>>>> tests/functional/requirements.txt |
1287 | diff --git a/tests/functional/resources/hello-world.txt b/tests/functional/resources/hello-world.txt |
1288 | new file mode 100644 |
1289 | index 0000000..a042389 |
1290 | --- /dev/null |
1291 | +++ b/tests/functional/resources/hello-world.txt |
1292 | @@ -0,0 +1 @@ |
1293 | +hello world! |
1294 | diff --git a/tests/functional/resources/testing_id_rsa b/tests/functional/resources/testing_id_rsa |
1295 | new file mode 100644 |
1296 | index 0000000..747d27a |
1297 | --- /dev/null |
1298 | +++ b/tests/functional/resources/testing_id_rsa |
1299 | @@ -0,0 +1,27 @@ |
1300 | +-----BEGIN RSA PRIVATE KEY----- |
1301 | +MIIEpAIBAAKCAQEAsavU+J2Rot++0nJWNP0phRHa49tiXrDSthapHiqoPwE8uVWE |
1302 | +3fFhE7Reil7VaKHiLtof2eD506JtOeFAvyQAoMpFXkN3s1QFXqacx5pZBav/TP/Y |
1303 | +ss7P7avRmR8Y5xyxEtbnYZFgUgXua4491KgcAwzqfQjuWSXalubthFbrOwKy+G0M |
1304 | +5JT5GCfZNzIKuxz8TPHzwEr1f9w/vUoQQvt2CzVn3M9ka/RTcmrVqzOTfmNCh/j7 |
1305 | +Qcg1c5gYBuQv/TmZae8bixhC/P6CD07qDZ1Q00iYk3mhuqkPNY8kq8XRmVNJQowf |
1306 | +/tyoQ/WAznNBdOY7qoDGmfv0jyQX83BqCOuy3QIDAQABAoIBAEuopLSKRO5a4WO0 |
1307 | +lMlT1U55YAEP9z/jhJdN5w6Vk7fgyv8RT9dDZteBQ5Eg+TfpV+wjrtSVXU2mKWUw |
1308 | +auX6atoNyKRvjpWq/e5kfPby313u9HTRrnHWZ+0J8eOGvpAMQ8uGAFooEiBbrj/W |
1309 | +/rWEMQmLgn9kQjtsRz1jcVmdueYSdkG9f1uHLv9kvrnPC6luGKTwMcx4PH1fGHAQ |
1310 | +NFLlDmf5M4GnRL+37zl6hmMuoFPhFrcSehvkCnzJEBo8958sBX8LEZ9c1vaye3Sh |
1311 | +n6UPL6iUh/zlGUCag7TbT2svvu2m1IM8sUCGGDNF5IJu+ocqWaUNWiHJmg5NhsjZ |
1312 | +COEzZnECgYEA4WIVG/nqtbeEI5/h0qkxauLBxbgrC8Gv5qrIMt0Z+yK8QitaIZe9 |
1313 | +d5rIVMsEmb83n0nYxgsULAR+iD7cBd0cJ6jg6zpPBdPZucZw/Yhlmezv/RX1TjlZ |
1314 | +ORHcueuj2UxxsoVPDdYRiO+CYupoLXINb4UK/iZP0eSBII3e00H1BmsCgYEAyc6F |
1315 | +bdftyjvNl5cdF/XMSZSrefpiGLsFgt9HhWHU/i698xGaMBaxo8z6YcgtecdgYH+c |
1316 | +LePoC1WaYQrNLvevCDMpjgqSXPCyuVKLLlI5+5beme+n3GyL3JpeK28Wd0dJs2nT |
1317 | +nRnK2+ChbI4wT8ylDedi7koHiV5IzeBXfV2nrdcCgYEAmdPRyIhok6IvhAkJnjhw |
1318 | +TB18V7B9YMbPgcYqYdzacLeieh8Qo0DnxgxUktsFxtHl6sgCNhk1qV1f5ynQDgh9 |
1319 | +wOvYp3Pin32aattwHvrLLaWznq8wADXQGc2BMzwLVrKAH3IxJKZozWd7PHv0op/n |
1320 | +X6gUeqY3cHBfWZK69MFdtQUCgYEAu3nLRN8rPgvOk/xDf+XN0bF2l8u+ZAEiPpFU |
1321 | +rRnUuAoOVohMuE3s2yHqnPpNHOvWoe8K1Sr7f8QXtf1F3lMk3LZC7Xzuub62GioP |
1322 | +uImU6iAfTdxxEfoY+GjEAQ+jTE4CrtUqTLEQXrHQ5Ls3MHsJ/t+tbXeCht/7PJ8k |
1323 | +SAfAZWMCgYBFw1VNBCE6fxGBpsZpo8RHHaGWVJDdI1mxzGNqTO6hcAqPVXzBYYaz |
1324 | +l1pdVXC0wVjKYa6+pS+4WNQSkUNLlrgeVqY3PC6YJMfyxR/+vOezjLOqGQk3lLna |
1325 | +Ds1QkNKV+2nOHGL9QQhfhuR+t7SmTkr/7ZWGv8iScNMYc8f59r5TTw== |
1326 | +-----END RSA PRIVATE KEY----- |
1327 | diff --git a/tests/functional/resources/testing_id_rsa.pub b/tests/functional/resources/testing_id_rsa.pub |
1328 | new file mode 100644 |
1329 | index 0000000..0ae5825 |
1330 | --- /dev/null |
1331 | +++ b/tests/functional/resources/testing_id_rsa.pub |
1332 | @@ -0,0 +1 @@ |
1333 | +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCxq9T4nZGi377SclY0/SmFEdrj22JesNK2FqkeKqg/ATy5VYTd8WETtF6KXtVooeIu2h/Z4PnTom054UC/JACgykVeQ3ezVAVeppzHmlkFq/9M/9iyzs/tq9GZHxjnHLES1udhkWBSBe5rjj3UqBwDDOp9CO5ZJdqW5u2EVus7ArL4bQzklPkYJ9k3Mgq7HPxM8fPASvV/3D+9ShBC+3YLNWfcz2Rr9FNyatWrM5N+Y0KH+PtByDVzmBgG5C/9OZlp7xuLGEL8/oIPTuoNnVDTSJiTeaG6qQ81jySrxdGZU0lCjB/+3KhD9YDOc0F05juqgMaZ+/SPJBfzcGoI67Ld zackz@zackz-Precision-5530 |
1334 | diff --git a/tests/functional/test_duplicity.py b/tests/functional/test_duplicity.py |
1335 | new file mode 100644 |
1336 | index 0000000..f6acc13 |
1337 | --- /dev/null |
1338 | +++ b/tests/functional/test_duplicity.py |
1339 | @@ -0,0 +1,240 @@ |
1340 | +import unittest |
1341 | +import asyncio |
1342 | +import concurrent.futures |
1343 | +from pprint import pprint |
1344 | +import base64 |
1345 | + |
1346 | +import zaza.model |
1347 | + |
1348 | +from tests.functional import utils |
1349 | +from tests.functional.configure import ( |
1350 | + ubuntu_backup_directory_source, |
1351 | + ubuntu_user_pass |
1352 | +) |
1353 | + |
1354 | + |
1355 | +def _run(coro): |
1356 | + """ A wrapper function to allow for the running of async functions in unittest.TestCase """ |
1357 | + return asyncio.get_event_loop().run_until_complete(coro) |
1358 | + |
1359 | + |
1360 | +class BaseDuplicityTest(unittest.TestCase): |
1361 | + """ Base class for Duplicity charm tests. """ |
1362 | + |
1363 | + @classmethod |
1364 | + def setUpClass(cls): |
1365 | + """ Run setup for Duplicity tests. """ |
1366 | + cls.model_name = zaza.model.get_juju_model() |
1367 | + cls.application_name = 'duplicity' |
1368 | + |
1369 | + |
1370 | +class DuplicityBackupCronTest(BaseDuplicityTest): |
1371 | + |
1372 | + @classmethod |
1373 | + def setUpClass(cls): |
1374 | + super().setUpClass() |
1375 | + |
1376 | + @utils.config_restore('duplicity') |
1377 | + def test_cron_creation(self): |
1378 | + options = ['daily', 'weekly', 'monthly'] |
1379 | + for option in options: |
1380 | + new_config = dict(backup_frequency=option) |
1381 | + zaza.model.set_application_config(self.application_name, new_config) |
1382 | + try: |
1383 | + zaza.model.block_until_file_has_contents( |
1384 | + application_name=self.application_name, |
1385 | + remote_file='/etc/cron.d/periodic_backup', |
1386 | + expected_contents=option, |
1387 | + timeout=60 |
1388 | + ) |
1389 | + except concurrent.futures._base.TimeoutError: |
1390 | + self.fail( |
1391 | + 'Cron file /etc/cron.d/period_backup never populated with option <{}>'.format(option)) |
1392 | + |
1393 | + @utils.config_restore('duplicity') |
1394 | + def test_cron_creation_cron_string(self): |
1395 | + cron_string = '* * * * *' |
1396 | + new_config = dict(backup_frequency=cron_string) |
1397 | + zaza.model.set_application_config(self.application_name, new_config) |
1398 | + try: |
1399 | + zaza.model.block_until_file_has_contents( |
1400 | + application_name=self.application_name, |
1401 | + remote_file='/etc/cron.d/periodic_backup', |
1402 | + expected_contents=cron_string, |
1403 | + timeout=60 |
1404 | + ) |
1405 | + except concurrent.futures._base.TimeoutError: |
1406 | + self.fail( |
1407 | + 'Cron file /etc/cron.d/period_backup never populated with option <{}>'.format(cron_string)) |
1408 | + |
1409 | + @utils.config_restore('duplicity') |
1410 | + def test_cron_invalid_cron_string(self): |
1411 | + cron_string = '* * * *' |
1412 | + new_config = dict(backup_frequency=cron_string) |
1413 | + zaza.model.set_application_config(self.application_name, new_config) |
1414 | + try: |
1415 | + duplicity_workload_checker = utils.get_workload_application_status_checker( |
1416 | + self.application_name, 'blocked') |
1417 | + _run(zaza.model.async_block_until(duplicity_workload_checker, timeout=15)) |
1418 | + a_unit = zaza.model.get_units(self.application_name)[0] |
1419 | + self.assertEquals(a_unit.workload_status_message, |
1420 | + 'Invalid value "{}" for cron frequency'.format(cron_string)) |
1421 | + except concurrent.futures._base.TimeoutError: |
1422 | + self.fail('Failed to enter blocked state with invalid backup_frequency.') |
1423 | + |
1424 | + @utils.config_restore('duplicity') |
1425 | + def test_no_cron(self): |
1426 | + options = ['auto', 'manual'] |
1427 | + for option in options: |
1428 | + new_config = dict(backup_frequency=option) |
1429 | + zaza.model.set_application_config(self.application_name, new_config) |
1430 | + try: |
1431 | + zaza.model.block_until_file_missing( |
1432 | + model_name=self.model_name, |
1433 | + app=self.application_name, |
1434 | + path='/etc/cron.d/periodic_backup', |
1435 | + timeout=60 |
1436 | + ) |
1437 | + except concurrent.futures._base.TimeoutError: |
1438 | + self.fail( |
1439 | + 'Cron file /etc/cron.d/period_backup exists with option <{}>'.format(option)) |
1440 | + |
1441 | + |
1442 | +class DuplicityEncryptionValidationTest(BaseDuplicityTest): |
1443 | + |
1444 | + @classmethod |
1445 | + def setUpClass(cls): |
1446 | + super().setUpClass() |
1447 | + |
1448 | + @utils.config_restore('duplicity') |
1449 | + def test_encryption_true_no_key_no_passphrase_blocks(self): |
1450 | + new_config = dict( |
1451 | + encryption_passphrase='', |
1452 | + gpg_public_key='', |
1453 | + disable_encryption='False' |
1454 | + ) |
1455 | + zaza.model.set_application_config(self.application_name, new_config, self.model_name) |
1456 | + try: |
1457 | + duplicity_workload_checker = utils.get_workload_application_status_checker( |
1458 | + self.application_name, 'blocked') |
1459 | + _run(zaza.model.async_block_until(duplicity_workload_checker, timeout=15)) |
1460 | + a_unit = zaza.model.get_units(self.application_name)[0] |
1461 | + self.assertEquals(a_unit.workload_status_message, |
1462 | + 'Must set either an encryption passphrase, GPG public key, or disable encryption') |
1463 | + except concurrent.futures._base.TimeoutError: |
1464 | + self.fail('Failed to enter blocked state with encryption enables and no passphrase or key.') |
1465 | + |
1466 | + @utils.config_restore('duplicity') |
1467 | + def test_encryption_true_with_key(self): |
1468 | + zaza.model.set_application_config(self.application_name, dict(disable_encryption='False'), self.model_name) |
1469 | + try: |
1470 | + duplicity_workload_checker = utils.get_workload_application_status_checker( |
1471 | + self.application_name, 'blocked') |
1472 | + _run(zaza.model.async_block_until(duplicity_workload_checker, timeout=15)) |
1473 | + except concurrent.futures._base.TimeoutError: |
1474 | + self.fail('Failed to enter blocked state with encryption enables and no passphrase or key.') |
1475 | + zaza.model.set_application_config(self.application_name, dict(gpg_public_key='S0M3k3Y')) |
1476 | + try: |
1477 | + zaza.model.block_until_all_units_idle() |
1478 | + except concurrent.futures._base.TimeoutError: |
1479 | + self.fail('Not all units entered idle state. Config change back failed to achieve active/idle.') |
1480 | + |
1481 | + @utils.config_restore('duplicity') |
1482 | + def test_encryption_true_with_passphrase(self): |
1483 | + zaza.model.set_application_config(self.application_name, dict(disable_encryption='False'), self.model_name) |
1484 | + try: |
1485 | + duplicity_workload_checker = utils.get_workload_application_status_checker( |
1486 | + self.application_name, 'blocked') |
1487 | + _run(zaza.model.async_block_until(duplicity_workload_checker, timeout=15)) |
1488 | + except concurrent.futures._base.TimeoutError: |
1489 | + self.fail('Failed to enter blocked state with encryption enables and no passphrase or key.') |
1490 | + zaza.model.set_application_config(self.application_name, dict(encryption_passphrase='somephrase')) |
1491 | + try: |
1492 | + zaza.model.block_until_all_units_idle() |
1493 | + except concurrent.futures._base.TimeoutError: |
1494 | + self.fail('Not all units entered idle state. Config change back failed to achieve active/idle.') |
1495 | + |
1496 | + |
1497 | +class DuplicityBackupCommandTest(BaseDuplicityTest): |
1498 | + @classmethod |
1499 | + def setUpClass(cls): |
1500 | + super().setUpClass() |
1501 | + cls.backup_host = zaza.model.get_units('backup-host')[0] |
1502 | + cls.duplicity_unit = zaza.model.get_units('duplicity')[0] |
1503 | + cls.backup_host_ip = cls.backup_host.public_address |
1504 | + user_pass_pair = ubuntu_user_pass.split(':') |
1505 | + cls.remote_user = user_pass_pair[0] |
1506 | + cls.remote_pass = user_pass_pair[1] |
1507 | + cls.action = 'do-backup' |
1508 | + cls.ssh_priv_key = cls.get_ssh_priv_key() |
1509 | + |
1510 | + def get_config(self, **kwargs): |
1511 | + base_config = dict( |
1512 | + remote_backup_url=self.backup_host_ip, |
1513 | + aux_backup_directory=ubuntu_backup_directory_source, |
1514 | + remote_user=self.remote_user, |
1515 | + remote_password=self.remote_pass, |
1516 | + ) |
1517 | + for key, value in kwargs.items(): |
1518 | + base_config[key] = value |
1519 | + return base_config |
1520 | + |
1521 | + @staticmethod |
1522 | + def get_ssh_priv_key(): |
1523 | + with open('./tests/functional/resources/testing_id_rsa', 'rb') as f: |
1524 | + ssh_private_key = f.read() |
1525 | + encoded_ssh_private_key = base64.b64encode(ssh_private_key) |
1526 | + return encoded_ssh_private_key.decode('utf-8') |
1527 | + |
1528 | + @utils.config_restore('duplicity') |
1529 | + def test_scp_full_do_backup_action(self): |
1530 | + additional_config = dict(backend='scp') |
1531 | + new_config = self.get_config(**additional_config) |
1532 | + utils.set_config_and_wait(self.application_name, new_config) |
1533 | + zaza.model.run_action(self.duplicity_unit.name, self.action, raise_on_failure=True) |
1534 | + |
1535 | + @utils.config_restore('duplicity') |
1536 | + def test_rsync_full_do_backup_action(self): |
1537 | + additional_config = dict(backend='scp') |
1538 | + new_config = self.get_config(**additional_config) |
1539 | + utils.set_config_and_wait(self.application_name, new_config) |
1540 | + zaza.model.run_action(self.duplicity_unit.name, self.action, raise_on_failure=True) |
1541 | + |
1542 | + @utils.config_restore('duplicity') |
1543 | + def test_file_full_do_backup_action(self): |
1544 | + additional_config = dict(backend='file', remote_backup_url='/home/ubuntu/test-backups') |
1545 | + new_config = self.get_config(**additional_config) |
1546 | + utils.set_config_and_wait(self.application_name, new_config) |
1547 | + zaza.model.run_action(self.duplicity_unit.name, self.action, raise_on_failure=True) |
1548 | + |
1549 | + @utils.config_restore('duplicity') |
1550 | + def test_scp_full_ssh_key_auth_backup_action(self): |
1551 | + additional_config = dict(backend='scp', |
1552 | + private_ssh_key=self.ssh_priv_key, |
1553 | + remote_password='') |
1554 | + new_config = self.get_config(**additional_config) |
1555 | + utils.set_config_and_wait(self.application_name, new_config) |
1556 | + zaza.model.run_action(self.duplicity_unit.name, self.action, raise_on_failure=True) |
1557 | + |
1558 | + @utils.config_restore('duplicity') |
1559 | + def test_sftp_full_do_backup(self): |
1560 | + additional_config = dict(backend='sftp') |
1561 | + new_config = self.get_config(**additional_config) |
1562 | + utils.set_config_and_wait(self.application_name, new_config) |
1563 | + zaza.model.run_action(self.duplicity_unit.name, self.action, raise_on_failure=True) |
1564 | + |
1565 | + @utils.config_restore('duplicity') |
1566 | + def test_sftp_full_ssh_key_do_backup(self): |
1567 | + additional_config = dict(backend='sftp', |
1568 | + private_ssh_key=self.ssh_priv_key, |
1569 | + remote_password='') |
1570 | + new_config = self.get_config(**additional_config) |
1571 | + utils.set_config_and_wait(self.application_name, new_config) |
1572 | + zaza.model.run_action(self.duplicity_unit.name, self.action, raise_on_failure=True) |
1573 | + |
1574 | + @utils.config_restore('duplicity') |
1575 | + def test_ftp_full_do_backup(self): |
1576 | + additional_config = dict(backend='ftp') |
1577 | + new_config = self.get_config(**additional_config) |
1578 | + utils.set_config_and_wait(self.application_name, new_config) |
1579 | + zaza.model.run_action(self.duplicity_unit.name, self.action, raise_on_failure=True) |
1580 | diff --git a/tests/functional/utils.py b/tests/functional/utils.py |
1581 | new file mode 100644 |
1582 | index 0000000..a3e58b5 |
1583 | --- /dev/null |
1584 | +++ b/tests/functional/utils.py |
1585 | @@ -0,0 +1,54 @@ |
1586 | +from functools import wraps |
1587 | +from collections import namedtuple |
1588 | + |
1589 | +import zaza.model |
1590 | + |
1591 | + |
1592 | +def get_app_config(app_name): |
1593 | + return _convert_config(zaza.model.get_application_config(app_name)) |
1594 | + |
1595 | + |
1596 | +def get_workload_application_status_checker(application_name, target_status): |
1597 | + """ Returns a function for checking the status of all units of an application. """ |
1598 | + async def checker(): |
1599 | + units = await zaza.model.async_get_units(application_name) |
1600 | + unit_statuses_blocked = [unit.workload_status == target_status for unit in units] |
1601 | + return all(unit_statuses_blocked) |
1602 | + return checker |
1603 | + |
1604 | + |
1605 | +def config_restore(*applications): |
1606 | + def config_restore_wrap(f): |
1607 | + AppConfigPair = namedtuple('AppConfigPair', ['app_name', 'config']) |
1608 | + @wraps(f) |
1609 | + def wrapped_f(*args): |
1610 | + original_configs = [AppConfigPair(app, get_app_config(app)) for app in applications] |
1611 | + try: |
1612 | + f(*args) |
1613 | + finally: |
1614 | + for app_config_pair in original_configs: |
1615 | + zaza.model.set_application_config(app_config_pair.app_name, app_config_pair.config) |
1616 | + zaza.model.block_until_all_units_idle(timeout=60) |
1617 | + return wrapped_f |
1618 | + return config_restore_wrap |
1619 | + |
1620 | + |
1621 | +def set_config_and_wait(application_name, config, model_name=None): |
1622 | + zaza.model.set_application_config( |
1623 | + application_name=application_name, |
1624 | + configuration=config, |
1625 | + model_name=model_name |
1626 | + ) |
1627 | + zaza.model.block_until_all_units_idle() |
1628 | + |
1629 | + |
1630 | +def _convert_config(config): |
1631 | + """ |
1632 | + Converts config dictionary from get_config to one valid for set_config. |
1633 | + """ |
1634 | + clean_config = dict() |
1635 | + for key, value in config.items(): |
1636 | + clean_config[key] = "{}".format(value["value"]) |
1637 | + return clean_config |
1638 | + |
1639 | + |
1640 | diff --git a/tests/tests.yaml b/tests/tests.yaml |
1641 | new file mode 100644 |
1642 | index 0000000..d45d5a4 |
1643 | --- /dev/null |
1644 | +++ b/tests/tests.yaml |
1645 | @@ -0,0 +1,20 @@ |
1646 | +#charm_name: duplicity |
1647 | +tests: |
1648 | + - tests.functional.test_duplicity.DuplicityBackupCronTest |
1649 | + - tests.functional.test_duplicity.DuplicityEncryptionValidationTest |
1650 | + - tests.functional.test_duplicity.DuplicityBackupCommandTest |
1651 | +configure: |
1652 | + - tests.functional.configure.set_ubuntu_password_on_backup_host |
1653 | + - tests.functional.configure.set_ssh_password_access_on_backup_host |
1654 | + - tests.functional.configure.setup_test_files_for_backup |
1655 | + - tests.functional.configure.set_backup_host_known_host_key |
1656 | + - tests.functional.configure.add_pub_key_to_backup_host |
1657 | + - tests.functional.configure.setup_ftp |
1658 | +gate_bundles: |
1659 | + - xenial |
1660 | + - bionic |
1661 | +dev_bundles: |
1662 | + - xenial |
1663 | + - bionic |
1664 | +smoke_bundles: |
1665 | + - bionic |
1666 | diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py |
1667 | index 8957d36..bdf00ed 100644 |
1668 | --- a/tests/unit/conftest.py |
1669 | +++ b/tests/unit/conftest.py |
1670 | @@ -3,8 +3,13 @@ import mock |
1671 | import pytest |
1672 | |
1673 | |
1674 | +<<<<<<< tests/unit/conftest.py |
1675 | # If layer options are used, add this to charmduplicity |
1676 | # and import layer in lib_charm_duplicity |
1677 | +======= |
1678 | +# If layer options are used, add this to duplicity |
1679 | +# and import layer in lib_duplicity |
1680 | +>>>>>>> tests/unit/conftest.py |
1681 | @pytest.fixture |
1682 | def mock_layers(monkeypatch): |
1683 | import sys |
1684 | @@ -20,7 +25,11 @@ def mock_layers(monkeypatch): |
1685 | else: |
1686 | return None |
1687 | |
1688 | +<<<<<<< tests/unit/conftest.py |
1689 | monkeypatch.setattr('lib_charm_duplicity.layer.options', options) |
1690 | +======= |
1691 | + monkeypatch.setattr('lib_duplicity.layer.options', options) |
1692 | +>>>>>>> tests/unit/conftest.py |
1693 | |
1694 | |
1695 | @pytest.fixture |
1696 | @@ -39,11 +48,16 @@ def mock_hookenv_config(monkeypatch): |
1697 | # cfg['my-other-layer'] = 'mock' |
1698 | return cfg |
1699 | |
1700 | +<<<<<<< tests/unit/conftest.py |
1701 | monkeypatch.setattr('lib_charm_duplicity.hookenv.config', mock_config) |
1702 | +======= |
1703 | + monkeypatch.setattr('lib_duplicity.hookenv.config', mock_config) |
1704 | +>>>>>>> tests/unit/conftest.py |
1705 | |
1706 | |
1707 | @pytest.fixture |
1708 | def mock_remote_unit(monkeypatch): |
1709 | +<<<<<<< tests/unit/conftest.py |
1710 | monkeypatch.setattr('lib_charm_duplicity.hookenv.remote_unit', lambda: 'unit-mock/0') |
1711 | |
1712 | |
1713 | @@ -56,6 +70,24 @@ def mock_charm_dir(monkeypatch): |
1714 | def charmduplicity(tmpdir, mock_hookenv_config, mock_charm_dir, monkeypatch): |
1715 | from lib_charm_duplicity import CharmduplicityHelper |
1716 | helper = CharmduplicityHelper() |
1717 | +======= |
1718 | + monkeypatch.setattr('lib_duplicity.hookenv.remote_unit', lambda: 'unit-mock/0') |
1719 | + |
1720 | +@pytest.fixture |
1721 | +def mock_local_unit(monkeypatch): |
1722 | + monkeypatch.setattr('lib_duplicity.hookenv.local_unit', lambda: 'unit-mock/0') |
1723 | + |
1724 | +@pytest.fixture |
1725 | +def mock_charm_dir(monkeypatch): |
1726 | + monkeypatch.setattr('lib_duplicity.hookenv.charm_dir', lambda: '/mock/charm/dir') |
1727 | + |
1728 | + |
1729 | +@pytest.fixture |
1730 | +def duplicity(tmpdir, mock_hookenv_config, mock_charm_dir, mock_local_unit, |
1731 | + monkeypatch): |
1732 | + from lib_duplicity import DuplicityHelper |
1733 | + helper = DuplicityHelper() |
1734 | +>>>>>>> tests/unit/conftest.py |
1735 | |
1736 | # Example config file patching |
1737 | cfg_file = tmpdir.join('example.cfg') |
1738 | @@ -64,6 +96,10 @@ def charmduplicity(tmpdir, mock_hookenv_config, mock_charm_dir, monkeypatch): |
1739 | helper.example_config_file = cfg_file.strpath |
1740 | |
1741 | # Any other functions that load helper will get this version |
1742 | +<<<<<<< tests/unit/conftest.py |
1743 | monkeypatch.setattr('lib_charm_duplicity.CharmduplicityHelper', lambda: helper) |
1744 | +======= |
1745 | + monkeypatch.setattr('lib_duplicity.DuplicityHelper', lambda: helper) |
1746 | +>>>>>>> tests/unit/conftest.py |
1747 | |
1748 | return helper |
1749 | diff --git a/tests/unit/test_actions.py b/tests/unit/test_actions.py |
1750 | index f02df4e..369908c 100644 |
1751 | --- a/tests/unit/test_actions.py |
1752 | +++ b/tests/unit/test_actions.py |
1753 | @@ -4,9 +4,25 @@ import mock |
1754 | |
1755 | |
1756 | class TestActions(): |
1757 | +<<<<<<< tests/unit/test_actions.py |
1758 | def test_example_action(self, charmduplicity, monkeypatch): |
1759 | mock_function = mock.Mock() |
1760 | monkeypatch.setattr(charmduplicity, 'action_function', mock_function) |
1761 | assert mock_function.call_count == 0 |
1762 | imp.load_source('action_function', './actions/example-action') |
1763 | assert mock_function.call_count == 1 |
1764 | +======= |
1765 | + def test_verify(self, duplicity, monkeypatch): |
1766 | + mock_function = mock.Mock() |
1767 | + monkeypatch.setattr(duplicity, 'verify', mock_function) |
1768 | + assert mock_function.call_count == 0 |
1769 | + imp.load_source('verify', './actions/verify') |
1770 | + assert mock_function.call_count == 1 |
1771 | + |
1772 | + def test_do_backup(self, duplicity, monkeypatch): |
1773 | + mock_function = mock.Mock() |
1774 | + monkeypatch.setattr(duplicity, 'do_backup', mock_function) |
1775 | + assert mock_function.call_count == 0 |
1776 | + imp.load_source('do_backup', './actions/do-backup') |
1777 | + assert mock_function.call_count == 1 |
1778 | +>>>>>>> tests/unit/test_actions.py |
1779 | diff --git a/tests/unit/test_lib.py b/tests/unit/test_lib.py |
1780 | index 879d76c..ea6e67b 100644 |
1781 | --- a/tests/unit/test_lib.py |
1782 | +++ b/tests/unit/test_lib.py |
1783 | @@ -1,12 +1,43 @@ |
1784 | #!/usr/bin/python3 |
1785 | +<<<<<<< tests/unit/test_lib.py |
1786 | |
1787 | +======= |
1788 | +import pytest |
1789 | +>>>>>>> tests/unit/test_lib.py |
1790 | |
1791 | class TestLib(): |
1792 | def test_pytest(self): |
1793 | assert True |
1794 | |
1795 | +<<<<<<< tests/unit/test_lib.py |
1796 | def test_charmduplicity(self, charmduplicity): |
1797 | ''' See if the helper fixture works to load charm configs ''' |
1798 | assert isinstance(charmduplicity.charm_config, dict) |
1799 | |
1800 | # Include tests for functions in lib_charm_duplicity |
1801 | +======= |
1802 | + def test_duplicity_charm_config(self, duplicity): |
1803 | + """ See if the helper fixture works to load charm configs """ |
1804 | + assert isinstance(duplicity.charm_config, dict) |
1805 | + |
1806 | + |
1807 | + @pytest.mark.parametrize("backend,username,password,path,expected", |
1808 | +[("rsync", "user", "", "other.host:44/bak", "rsync://user@other.host:44/bak"), |
1809 | + ("ssh", "user", "pass", "ssh://other.host:55/bak", |
1810 | + "ssh://user:pass@other.host:55/bak"), |
1811 | + ("scp", "", "pass", "scp://other.host//bak", "scp://other.host//bak"), |
1812 | + ("s3", "user", "pass", "s3://aws-remote-host/bak", "s3://aws-remote-host/bak"), |
1813 | + pytest.param("ftp", "user", "pass", "ftp-host:bak", |
1814 | + "ftp://user:pass@ftp-host:bak", marks=pytest.mark.xfail), |
1815 | +]) |
1816 | + def test_duplicity_url(self, duplicity, backend, username, password, path, |
1817 | + expected): |
1818 | + """ Test formation of duplicity urls for the various backend types """ |
1819 | + |
1820 | + duplicity.charm_config["backend"] = backend |
1821 | + duplicity.charm_config["remote_user"] = username |
1822 | + duplicity.charm_config["remote_password"] = password |
1823 | + duplicity.charm_config["remote_backup_url"] = path |
1824 | + |
1825 | + assert duplicity._backup_url() == expected + "/unit-mock-0" |
1826 | +>>>>>>> tests/unit/test_lib.py |
1827 | diff --git a/tox.ini b/tox.ini |
1828 | index 6ad05cf..5c962df 100644 |
1829 | --- a/tox.ini |
1830 | +++ b/tox.ini |
1831 | @@ -1,12 +1,21 @@ |
1832 | [tox] |
1833 | +<<<<<<< tox.ini |
1834 | skipsdist=True |
1835 | +======= |
1836 | +skipsdist = True |
1837 | +>>>>>>> tox.ini |
1838 | envlist = unit, functional |
1839 | skip_missing_interpreters = True |
1840 | |
1841 | [testenv] |
1842 | basepython = python3 |
1843 | setenv = |
1844 | +<<<<<<< tox.ini |
1845 | PYTHONPATH = . |
1846 | +======= |
1847 | + PYTHONPATH = {toxinidir}/reactive:{toxinidir}/lib/:{toxinidir}/tests |
1848 | +passenv = HOME |
1849 | +>>>>>>> tox.ini |
1850 | |
1851 | [testenv:unit] |
1852 | commands = pytest -v --ignore {toxinidir}/tests/functional \ |
1853 | @@ -18,6 +27,7 @@ commands = pytest -v --ignore {toxinidir}/tests/functional \ |
1854 | --cov-report=html:report/html |
1855 | deps = -r{toxinidir}/tests/unit/requirements.txt |
1856 | -r{toxinidir}/requirements.txt |
1857 | +<<<<<<< tox.ini |
1858 | setenv = PYTHONPATH={toxinidir}/lib |
1859 | |
1860 | [testenv:functional] |
1861 | @@ -29,6 +39,18 @@ passenv = |
1862 | PYTEST_CLOUD_NAME |
1863 | PYTEST_CLOUD_REGION |
1864 | commands = pytest -v --ignore {toxinidir}/tests/unit |
1865 | +======= |
1866 | + |
1867 | +[testenv:functional] |
1868 | +commands = functest-run-suite --keep-model |
1869 | +deps = -r{toxinidir}/tests/functional/requirements.txt |
1870 | + -r{toxinidir}/requirements.txt |
1871 | + |
1872 | +[testenv:func-noop] |
1873 | +basepython = python3 |
1874 | +commands = |
1875 | + functest-run-suite --help |
1876 | +>>>>>>> tox.ini |
1877 | deps = -r{toxinidir}/tests/functional/requirements.txt |
1878 | -r{toxinidir}/requirements.txt |
1879 | |
1880 | @@ -46,5 +68,19 @@ exclude = |
1881 | .git, |
1882 | __pycache__, |
1883 | .tox, |
1884 | +<<<<<<< tox.ini |
1885 | +max-line-length = 120 |
1886 | +max-complexity = 10 |
1887 | +======= |
1888 | + tests, |
1889 | max-line-length = 120 |
1890 | max-complexity = 10 |
1891 | +ignore = E402 |
1892 | + |
1893 | +[pytest] |
1894 | +markers = |
1895 | + deploy: mark deployment tests to allow running w/o redeploy |
1896 | + relate: mark relations to allow running w/o re-relating |
1897 | +filterwarnings = |
1898 | + ignore::DeprecationWarning |
1899 | +>>>>>>> tox.ini |
Rejecting due to size and conflicts. Merge has been split into two new MRs, which were rebased onto master