Merge ~llama-charmers/charm-duplicity:develop into ~llama-charmers/charm-duplicity: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
Reviewer Review Type Date Requested Status
Jeremy Lounder (community) Disapprove
Review via email: mp+377615@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Jeremy Lounder (jldev) wrote :

Rejecting due to size and conflicts. Merge has been split into two new MRs, which were rebased onto master

review: Disapprove

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
1diff --git a/.gitignore b/.gitignore
2index 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
16diff --git a/CONTRIB.md b/CONTRIB.md
17new file mode 100644
18index 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)
130diff --git a/Makefile b/Makefile
131index 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
158diff --git a/README.md b/README.md
159new file mode 100644
160index 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).
294diff --git a/actions.yaml b/actions.yaml
295index 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
318diff --git a/actions/actions.py b/actions/actions.py
319new file mode 100755
320index 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))
375diff --git a/actions/do-backup b/actions/do-backup
376new file mode 120000
377index 0000000..405a394
378--- /dev/null
379+++ b/actions/do-backup
380@@ -0,0 +1 @@
381+actions.py
382\ No newline at end of file
383diff --git a/config.yaml b/config.yaml
384index 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
497diff --git a/layer.yaml b/layer.yaml
498index 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
519diff --git a/lib/lib_duplicity.py b/lib/lib_duplicity.py
520new file mode 100644
521index 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()
720diff --git a/metadata.yaml b/metadata.yaml
721index 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
762diff --git a/reactive/duplicity.py b/reactive/duplicity.py
763new file mode 100644
764index 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()
1015diff --git a/requirements.txt b/requirements.txt
1016index 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
1026diff --git a/scripts/periodic_backup.py b/scripts/periodic_backup.py
1027new file mode 100755
1028index 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()
1082diff --git a/scripts/plugins/check_backup_status.py b/scripts/plugins/check_backup_status.py
1083new file mode 100755
1084index 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())
1107diff --git a/templates/periodic_backup b/templates/periodic_backup
1108new file mode 100644
1109index 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
1115diff --git a/tests/bundles/bionic.yaml b/tests/bundles/bionic.yaml
1116new file mode 100644
1117index 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
1150diff --git a/tests/bundles/xenial.yaml b/tests/bundles/xenial.yaml
1151new file mode 100644
1152index 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
1184diff --git a/tests/functional/configure.py b/tests/functional/configure.py
1185new file mode 100644
1186index 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]
1272diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt
1273index 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
1287diff --git a/tests/functional/resources/hello-world.txt b/tests/functional/resources/hello-world.txt
1288new file mode 100644
1289index 0000000..a042389
1290--- /dev/null
1291+++ b/tests/functional/resources/hello-world.txt
1292@@ -0,0 +1 @@
1293+hello world!
1294diff --git a/tests/functional/resources/testing_id_rsa b/tests/functional/resources/testing_id_rsa
1295new file mode 100644
1296index 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-----
1327diff --git a/tests/functional/resources/testing_id_rsa.pub b/tests/functional/resources/testing_id_rsa.pub
1328new file mode 100644
1329index 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
1334diff --git a/tests/functional/test_duplicity.py b/tests/functional/test_duplicity.py
1335new file mode 100644
1336index 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)
1580diff --git a/tests/functional/utils.py b/tests/functional/utils.py
1581new file mode 100644
1582index 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+
1640diff --git a/tests/tests.yaml b/tests/tests.yaml
1641new file mode 100644
1642index 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
1666diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
1667index 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
1749diff --git a/tests/unit/test_actions.py b/tests/unit/test_actions.py
1750index 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
1779diff --git a/tests/unit/test_lib.py b/tests/unit/test_lib.py
1780index 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
1827diff --git a/tox.ini b/tox.ini
1828index 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

Subscribers

People subscribed via source and target branches

to all changes: