Merge ~dmzoneill/charm-advanced-routing:dev/upgrade into ~dmzoneill/charm-advanced-routing:master

Proposed by David O Neill
Status: Needs review
Proposed branch: ~dmzoneill/charm-advanced-routing:dev/upgrade
Merge into: ~dmzoneill/charm-advanced-routing:master
Diff against target: 1956 lines (+1785/-0)
28 files modified
.gitignore (+12/-0)
LICENSE (+202/-0)
Makefile (+49/-0)
README (+0/-0)
README.md (+49/-0)
config.yaml (+17/-0)
example_config.yaml (+27/-0)
icon.svg (+279/-0)
interfaces/.empty (+1/-0)
layer.yaml (+2/-0)
layers/.empty (+1/-0)
lib/AdvancedRoutingHelper.py (+132/-0)
lib/RoutingEntry.py (+221/-0)
lib/RoutingValidator.py (+182/-0)
metadata.yaml (+16/-0)
reactive/advanced_routing.py (+56/-0)
requirements.txt (+3/-0)
revision (+1/-0)
tests/bundles/bionic.yaml (+45/-0)
tests/bundles/overlays/bionic.yaml.j2 (+2/-0)
tests/functional/conftest.py (+71/-0)
tests/functional/juju_tools.py (+69/-0)
tests/functional/requirements.txt (+6/-0)
tests/functional/test_routing.py (+54/-0)
tests/unit/conftest.py (+80/-0)
tests/unit/requirements.txt (+8/-0)
tests/unit/test_AdvancedRoutingHelper.py (+154/-0)
tox.ini (+46/-0)
Reviewer Review Type Date Requested Status
Jeremy Lounder (community) Approve
Drew Freiberger (community) Needs Fixing
David O Neill Pending
Review via email: mp+371884@code.launchpad.net

Commit message

Upgraded to reactive and added static routing

 - Upgraded charm to reactive
 - Cleaned up old libraries
 - Add parser, extensibility to different routing, table, rule configuration options
 - Change to json blob in juju config item
 - Added multiple sanity checks and verification + better juju logging
 - Added functionality testing with libjuju

  expected config: based on example_config.yaml

  root@juju-720cde-24:/home/ubuntu# cat /etc/iproute2/rt_tables.d/juju-managed.conf
  100 SF1

  root@juju-720cde-24:/home/ubuntu# cat /usr/local/sbin/if-down/95-juju_routing
  # This file is managed by Juju.
  sudo ip rule del from 192.170.2.0/24 to 192.170.2.0/24 lookup SF1
  sudo ip route del 6.6.6.0/24 via 10.191.86.2
  sudo ip route del default via 10.191.86.2 table SF1 dev eth0
  sudo ip route flush table SF1
  sudo ip rule del table SF1
  sudo ip route flush cache

  root@juju-720cde-24:/home/ubuntu# cat /usr/local/sbin/if-up/95-juju_routing
  # This file is managed by Juju.
  sudo ip route flush cache
  # Table: name SF1
  sudo ip route replace default via 10.191.86.2 table SF1 dev eth0
  sudo ip route replace 6.6.6.0/24 via 10.191.86.2
  sudo ip rule add from 192.170.2.0/24 to 192.170.2.0/24 lookup SF1

To post a comment you must log in.
Revision history for this message
Drew Freiberger (afreiberger) wrote :

See comments inline.

review: Needs Fixing
25b0cd4... by David O Neill

Fixes and improvements from review 371884 MR

https://code.launchpad.net/~dmzoneill/charm-advanced-routing/+git/charm-advanced-routing/+merge/371884

 * replaced format for all string formatting
 * replaced exits with raise exception
 * solved a bug of duplicate entries in ip/down script
 * numerous of code tidy ups and best practices
 * linted it
 * updates for unit and functional testing

Revision history for this message
Drew Freiberger (afreiberger) wrote :

Now that you're raising excceptions, you can catch them in the reactive state machine and set statuses based off of a try: setup: except: set_state(blocked, blocked due to error "foo" in setup

Revision history for this message
Jeremy Lounder (jldev) wrote :

This merge was already completed via an intermediary branch.

review: Approve
Revision history for this message
Paul Goins (vultaire) wrote :

The MR here is for merging into David's branch, however these commits are the exact same (hashes and all) as the first 2 commits in the central repo.

Unmerged commits

25b0cd4... by David O Neill

Fixes and improvements from review 371884 MR

https://code.launchpad.net/~dmzoneill/charm-advanced-routing/+git/charm-advanced-routing/+merge/371884

 * replaced format for all string formatting
 * replaced exits with raise exception
 * solved a bug of duplicate entries in ip/down script
 * numerous of code tidy ups and best practices
 * linted it
 * updates for unit and functional testing

2a2c57d... by David O Neill

Migrate from hooks to reactive based charm

Upgraded to reactive and added static routing

 - Upgraded charm to reactive
 - Cleaned up old libraries
 - Add parser, extensibility to different routing, table, rule configuration options
 - Change to json blob in juju config item
 - Added multiple sanity checks and verification + better juju logging
 - Added functionality testing with libjuju

  expected config: based on example_config.yaml

  root@juju-720cde-24:/home/ubuntu# cat /etc/iproute2/rt_tables.d/juju-managed.conf
  100 SF1

  root@juju-720cde-24:/home/ubuntu# cat /usr/local/sbin/if-down/95-juju_routing
  # This file is managed by Juju.
  sudo ip rule del from 192.170.2.0/24 to 192.170.2.0/24 lookup SF1
  sudo ip route del 6.6.6.0/24 via 10.191.86.2
  sudo ip route del default via 10.191.86.2 table SF1 dev eth0
  sudo ip route flush table SF1
  sudo ip rule del table SF1
  sudo ip route flush cache

  root@juju-720cde-24:/home/ubuntu# cat /usr/local/sbin/if-up/95-juju_routing
  # This file is managed by Juju.
  sudo ip route flush cache
  # Table: name SF1
  sudo ip route replace default via 10.191.86.2 table SF1 dev eth0
  sudo ip route replace 6.6.6.0/24 via 10.191.86.2
  sudo ip rule add from 192.170.2.0/24 to 192.170.2.0/24 lookup SF1

Co-authored-by: Diko Parvanov <email address hidden>

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/.gitignore b/.gitignore
0new file mode 1006440new file mode 100644
index 0000000..ebec07a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
1*.swp
2*~
3.idea
4.tox
5.vscode
6/builds/
7/deb_dist
8/dist
9/repo-info
10__pycache__
11
12
diff --git a/LICENSE b/LICENSE
0new file mode 10064413new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
1
2 Apache License
3 Version 2.0, January 2004
4 http://www.apache.org/licenses/
5
6 TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
8 1. Definitions.
9
10 "License" shall mean the terms and conditions for use, reproduction,
11 and distribution as defined by Sections 1 through 9 of this document.
12
13 "Licensor" shall mean the copyright owner or entity authorized by
14 the copyright owner that is granting the License.
15
16 "Legal Entity" shall mean the union of the acting entity and all
17 other entities that control, are controlled by, or are under common
18 control with that entity. For the purposes of this definition,
19 "control" means (i) the power, direct or indirect, to cause the
20 direction or management of such entity, whether by contract or
21 otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 outstanding shares, or (iii) beneficial ownership of such entity.
23
24 "You" (or "Your") shall mean an individual or Legal Entity
25 exercising permissions granted by this License.
26
27 "Source" form shall mean the preferred form for making modifications,
28 including but not limited to software source code, documentation
29 source, and configuration files.
30
31 "Object" form shall mean any form resulting from mechanical
32 transformation or translation of a Source form, including but
33 not limited to compiled object code, generated documentation,
34 and conversions to other media types.
35
36 "Work" shall mean the work of authorship, whether in Source or
37 Object form, made available under the License, as indicated by a
38 copyright notice that is included in or attached to the work
39 (an example is provided in the Appendix below).
40
41 "Derivative Works" shall mean any work, whether in Source or Object
42 form, that is based on (or derived from) the Work and for which the
43 editorial revisions, annotations, elaborations, or other modifications
44 represent, as a whole, an original work of authorship. For the purposes
45 of this License, Derivative Works shall not include works that remain
46 separable from, or merely link (or bind by name) to the interfaces of,
47 the Work and Derivative Works thereof.
48
49 "Contribution" shall mean any work of authorship, including
50 the original version of the Work and any modifications or additions
51 to that Work or Derivative Works thereof, that is intentionally
52 submitted to Licensor for inclusion in the Work by the copyright owner
53 or by an individual or Legal Entity authorized to submit on behalf of
54 the copyright owner. For the purposes of this definition, "submitted"
55 means any form of electronic, verbal, or written communication sent
56 to the Licensor or its representatives, including but not limited to
57 communication on electronic mailing lists, source code control systems,
58 and issue tracking systems that are managed by, or on behalf of, the
59 Licensor for the purpose of discussing and improving the Work, but
60 excluding communication that is conspicuously marked or otherwise
61 designated in writing by the copyright owner as "Not a Contribution."
62
63 "Contributor" shall mean Licensor and any individual or Legal Entity
64 on behalf of whom a Contribution has been received by Licensor and
65 subsequently incorporated within the Work.
66
67 2. Grant of Copyright License. Subject to the terms and conditions of
68 this License, each Contributor hereby grants to You a perpetual,
69 worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 copyright license to reproduce, prepare Derivative Works of,
71 publicly display, publicly perform, sublicense, and distribute the
72 Work and such Derivative Works in Source or Object form.
73
74 3. Grant of Patent License. Subject to the terms and conditions of
75 this License, each Contributor hereby grants to You a perpetual,
76 worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 (except as stated in this section) patent license to make, have made,
78 use, offer to sell, sell, import, and otherwise transfer the Work,
79 where such license applies only to those patent claims licensable
80 by such Contributor that are necessarily infringed by their
81 Contribution(s) alone or by combination of their Contribution(s)
82 with the Work to which such Contribution(s) was submitted. If You
83 institute patent litigation against any entity (including a
84 cross-claim or counterclaim in a lawsuit) alleging that the Work
85 or a Contribution incorporated within the Work constitutes direct
86 or contributory patent infringement, then any patent licenses
87 granted to You under this License for that Work shall terminate
88 as of the date such litigation is filed.
89
90 4. Redistribution. You may reproduce and distribute copies of the
91 Work or Derivative Works thereof in any medium, with or without
92 modifications, and in Source or Object form, provided that You
93 meet the following conditions:
94
95 (a) You must give any other recipients of the Work or
96 Derivative Works a copy of this License; and
97
98 (b) You must cause any modified files to carry prominent notices
99 stating that You changed the files; and
100
101 (c) You must retain, in the Source form of any Derivative Works
102 that You distribute, all copyright, patent, trademark, and
103 attribution notices from the Source form of the Work,
104 excluding those notices that do not pertain to any part of
105 the Derivative Works; and
106
107 (d) If the Work includes a "NOTICE" text file as part of its
108 distribution, then any Derivative Works that You distribute must
109 include a readable copy of the attribution notices contained
110 within such NOTICE file, excluding those notices that do not
111 pertain to any part of the Derivative Works, in at least one
112 of the following places: within a NOTICE text file distributed
113 as part of the Derivative Works; within the Source form or
114 documentation, if provided along with the Derivative Works; or,
115 within a display generated by the Derivative Works, if and
116 wherever such third-party notices normally appear. The contents
117 of the NOTICE file are for informational purposes only and
118 do not modify the License. You may add Your own attribution
119 notices within Derivative Works that You distribute, alongside
120 or as an addendum to the NOTICE text from the Work, provided
121 that such additional attribution notices cannot be construed
122 as modifying the License.
123
124 You may add Your own copyright statement to Your modifications and
125 may provide additional or different license terms and conditions
126 for use, reproduction, or distribution of Your modifications, or
127 for any such Derivative Works as a whole, provided Your use,
128 reproduction, and distribution of the Work otherwise complies with
129 the conditions stated in this License.
130
131 5. Submission of Contributions. Unless You explicitly state otherwise,
132 any Contribution intentionally submitted for inclusion in the Work
133 by You to the Licensor shall be under the terms and conditions of
134 this License, without any additional terms or conditions.
135 Notwithstanding the above, nothing herein shall supersede or modify
136 the terms of any separate license agreement you may have executed
137 with Licensor regarding such Contributions.
138
139 6. Trademarks. This License does not grant permission to use the trade
140 names, trademarks, service marks, or product names of the Licensor,
141 except as required for reasonable and customary use in describing the
142 origin of the Work and reproducing the content of the NOTICE file.
143
144 7. Disclaimer of Warranty. Unless required by applicable law or
145 agreed to in writing, Licensor provides the Work (and each
146 Contributor provides its Contributions) on an "AS IS" BASIS,
147 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 implied, including, without limitation, any warranties or conditions
149 of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 PARTICULAR PURPOSE. You are solely responsible for determining the
151 appropriateness of using or redistributing the Work and assume any
152 risks associated with Your exercise of permissions under this License.
153
154 8. Limitation of Liability. In no event and under no legal theory,
155 whether in tort (including negligence), contract, or otherwise,
156 unless required by applicable law (such as deliberate and grossly
157 negligent acts) or agreed to in writing, shall any Contributor be
158 liable to You for damages, including any direct, indirect, special,
159 incidental, or consequential damages of any character arising as a
160 result of this License or out of the use or inability to use the
161 Work (including but not limited to damages for loss of goodwill,
162 work stoppage, computer failure or malfunction, or any and all
163 other commercial damages or losses), even if such Contributor
164 has been advised of the possibility of such damages.
165
166 9. Accepting Warranty or Additional Liability. While redistributing
167 the Work or Derivative Works thereof, You may choose to offer,
168 and charge a fee for, acceptance of support, warranty, indemnity,
169 or other liability obligations and/or rights consistent with this
170 License. However, in accepting such obligations, You may act only
171 on Your own behalf and on Your sole responsibility, not on behalf
172 of any other Contributor, and only if You agree to indemnify,
173 defend, and hold each Contributor harmless for any liability
174 incurred by, or claims asserted against, such Contributor by reason
175 of your accepting any such warranty or additional liability.
176
177 END OF TERMS AND CONDITIONS
178
179 APPENDIX: How to apply the Apache License to your work.
180
181 To apply the Apache License to your work, attach the following
182 boilerplate notice, with the fields enclosed by brackets "[]"
183 replaced with your own identifying information. (Don't include
184 the brackets!) The text should be enclosed in the appropriate
185 comment syntax for the file format. We also recommend that a
186 file or class name and description of purpose be included on the
187 same "printed page" as the copyright notice for easier
188 identification within third-party archives.
189
190 Copyright [yyyy] [name of copyright owner]
191
192 Licensed under the Apache License, Version 2.0 (the "License");
193 you may not use this file except in compliance with the License.
194 You may obtain a copy of the License at
195
196 http://www.apache.org/licenses/LICENSE-2.0
197
198 Unless required by applicable law or agreed to in writing, software
199 distributed under the License is distributed on an "AS IS" BASIS,
200 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 See the License for the specific language governing permissions and
202 limitations under the License.
diff --git a/Makefile b/Makefile
0new file mode 100644203new file mode 100644
index 0000000..a8c1fd7
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,49 @@
1help:
2 @echo "This project supports the following targets"
3 @echo ""
4 @echo " make help - show this text"
5 @echo " make submodules - make sure that the submodules are up-to-date"
6 @echo " make lint - run flake8"
7 @echo " make test - run the unittests and lint"
8 @echo " make unittest - run the tests defined in the unittest subdirectory"
9 @echo " make functional - run the tests defined in the functional subdirectory"
10 @echo " make release - build the charm"
11 @echo " make clean - remove unneeded files"
12 @echo ""
13
14submodules:
15 @echo "Cloning submodules"
16 @git submodule update --init --recursive
17
18lint:
19 @echo "Running flake8"
20 @tox -e lint
21
22test: unittest functional lint
23
24unittest:
25 @tox -e unit
26
27functional: build
28 @PYTEST_KEEP_MODEL=$(PYTEST_KEEP_MODEL) \
29 PYTEST_CLOUD_NAME=$(PYTEST_CLOUD_NAME) \
30 PYTEST_CLOUD_REGION=$(PYTEST_CLOUD_REGION) \
31 tox -e functional
32
33build:
34 @echo "Building charm to base directory $(JUJU_REPOSITORY)"
35 @-git describe --tags > ./repo-info
36 @LAYER_PATH=./layers INTERFACE_PATH=./interfaces TERM=linux \
37 JUJU_REPOSITORY=$(JUJU_REPOSITORY) charm build . --force
38
39release: clean build
40 @echo "Charm is built at $(JUJU_REPOSITORY)/builds"
41
42clean:
43 @echo "Cleaning files"
44 @if [ -d .tox ] ; then rm -r .tox ; fi
45 @if [ -d .pytest_cache ] ; then rm -r .pytest_cache ; fi
46 @find . -iname __pycache__ -exec rm -r {} +
47
48# The targets below don't depend on a file
49.PHONY: lint test unittest functional build release clean help submodules
diff --git a/README b/README
0new file mode 10064450new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/README
diff --git a/README.md b/README.md
1new file mode 10064451new file mode 100644
index 0000000..f03175f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,49 @@
1# Overview
2
3This subordinate charm allows for the configuration of simple policy routing rules on the deployed host
4and adding routing to configured services via a JSON.
5
6# Usage
7
8
9# Build
10```
11cd charm-advanced-routing
12make build
13```
14
15# Usage
16Add to an existing application using juju-info relation.
17
18Example:
19```
20juju deploy cs:~canonical-bootstack/routing
21juju add-relation ubuntu advanced-routing
22```
23
24# Configuration
25The user can configure the following parameters:
26* enable-advanced-routing: Enable routing. This requires for the charm to have JSON with routing information configured: ```juju config advanced-routing --file path/to/your/config```
27
28A example_config.json file is provided with the codebase.
29
30# Testing
31To run lint tests:
32```bash
33tox -e lint
34
35```
36To run unit tests:
37```bash
38tox -e unit
39```
40Functional tests have been developed using python-libjuju, deploying a simple ubuntu charm and adding the charm as a subordinate.
41
42To run tests using python-libjuju:
43```bash
44tox -e functional
45```
46
47# Contact Information
48David O Neill <david.o.neill@canonical.com>
49
diff --git a/config.yaml b/config.yaml
0new file mode 10064450new file mode 100644
index 0000000..eb6c6de
--- /dev/null
+++ b/config.yaml
@@ -0,0 +1,17 @@
1options:
2 enable-advanced-routing:
3 type: boolean
4 default: False
5 description: |
6 Wheter to use the custom routing configuration provided by the charm.
7 advanced-routing-config:
8 type: string
9 default: ""
10 description: |
11 A json array consisting of objects, e.g.
12 [ {
13 "type": "route",
14 "net": "192.170.1.0/24",
15 "device": "eth5"
16 }, { ... } ]
17 @ see exmaple_config.yaml for more examples
0\ No newline at end of file18\ No newline at end of file
diff --git a/example_config.yaml b/example_config.yaml
1new file mode 10064419new file mode 100644
index 0000000..6785806
--- /dev/null
+++ b/example_config.yaml
@@ -0,0 +1,27 @@
1settings:
2 advanced-routing-config:
3 value: |-
4 [ {
5 "type": "table",
6 "table": "SF1"
7 }, {
8 "type": "route",
9 "default_route": true,
10 "net": "192.170.1.0/24",
11 "gateway": "10.191.86.2",
12 "table": "SF1",
13 "metric": 101,
14 "device": "eth0"
15 }, {
16 "type": "route",
17 "net": "6.6.6.0/24",
18 "gateway": "10.191.86.2"
19 }, {
20 "type": "rule",
21 "from-net": "192.170.2.0/24",
22 "to-net": "192.170.2.0/24",
23 "table": "SF1",
24 "priority": 101
25 } ]
26 enable-advanced-routing:
27 value: true
diff --git a/icon.svg b/icon.svg
0new file mode 10064428new file mode 100644
index 0000000..e092eef
--- /dev/null
+++ b/icon.svg
@@ -0,0 +1,279 @@
1<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2<!-- Created with Inkscape (http://www.inkscape.org/) -->
3
4<svg
5 xmlns:dc="http://purl.org/dc/elements/1.1/"
6 xmlns:cc="http://creativecommons.org/ns#"
7 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
8 xmlns:svg="http://www.w3.org/2000/svg"
9 xmlns="http://www.w3.org/2000/svg"
10 xmlns:xlink="http://www.w3.org/1999/xlink"
11 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
12 xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
13 width="96"
14 height="96"
15 id="svg6517"
16 version="1.1"
17 inkscape:version="0.48+devel r12274"
18 sodipodi:docname="Juju_charm_icon_template.svg">
19 <defs
20 id="defs6519">
21 <linearGradient
22 inkscape:collect="always"
23 xlink:href="#Background"
24 id="linearGradient6461"
25 gradientUnits="userSpaceOnUse"
26 x1="0"
27 y1="970.29498"
28 x2="144"
29 y2="970.29498"
30 gradientTransform="matrix(0,-0.66666669,0.6660448,0,-866.25992,731.29077)" />
31 <linearGradient
32 id="Background">
33 <stop
34 id="stop4178"
35 offset="0"
36 style="stop-color:#b8b8b8;stop-opacity:1" />
37 <stop
38 id="stop4180"
39 offset="1"
40 style="stop-color:#c9c9c9;stop-opacity:1" />
41 </linearGradient>
42 <filter
43 style="color-interpolation-filters:sRGB;"
44 inkscape:label="Inner Shadow"
45 id="filter1121">
46 <feFlood
47 flood-opacity="0.59999999999999998"
48 flood-color="rgb(0,0,0)"
49 result="flood"
50 id="feFlood1123" />
51 <feComposite
52 in="flood"
53 in2="SourceGraphic"
54 operator="out"
55 result="composite1"
56 id="feComposite1125" />
57 <feGaussianBlur
58 in="composite1"
59 stdDeviation="1"
60 result="blur"
61 id="feGaussianBlur1127" />
62 <feOffset
63 dx="0"
64 dy="2"
65 result="offset"
66 id="feOffset1129" />
67 <feComposite
68 in="offset"
69 in2="SourceGraphic"
70 operator="atop"
71 result="composite2"
72 id="feComposite1131" />
73 </filter>
74 <filter
75 style="color-interpolation-filters:sRGB;"
76 inkscape:label="Drop Shadow"
77 id="filter950">
78 <feFlood
79 flood-opacity="0.25"
80 flood-color="rgb(0,0,0)"
81 result="flood"
82 id="feFlood952" />
83 <feComposite
84 in="flood"
85 in2="SourceGraphic"
86 operator="in"
87 result="composite1"
88 id="feComposite954" />
89 <feGaussianBlur
90 in="composite1"
91 stdDeviation="1"
92 result="blur"
93 id="feGaussianBlur956" />
94 <feOffset
95 dx="0"
96 dy="1"
97 result="offset"
98 id="feOffset958" />
99 <feComposite
100 in="SourceGraphic"
101 in2="offset"
102 operator="over"
103 result="composite2"
104 id="feComposite960" />
105 </filter>
106 <clipPath
107 clipPathUnits="userSpaceOnUse"
108 id="clipPath873">
109 <g
110 transform="matrix(0,-0.66666667,0.66604479,0,-258.25992,677.00001)"
111 id="g875"
112 inkscape:label="Layer 1"
113 style="fill:#ff00ff;fill-opacity:1;stroke:none;display:inline">
114 <path
115 style="fill:#ff00ff;fill-opacity:1;stroke:none;display:inline"
116 d="m 46.702703,898.22775 50.594594,0 C 138.16216,898.22775 144,904.06497 144,944.92583 l 0,50.73846 c 0,40.86071 -5.83784,46.69791 -46.702703,46.69791 l -50.594594,0 C 5.8378378,1042.3622 0,1036.525 0,995.66429 L 0,944.92583 C 0,904.06497 5.8378378,898.22775 46.702703,898.22775 Z"
117 id="path877"
118 inkscape:connector-curvature="0"
119 sodipodi:nodetypes="sssssssss" />
120 </g>
121 </clipPath>
122 <filter
123 inkscape:collect="always"
124 id="filter891"
125 inkscape:label="Badge Shadow">
126 <feGaussianBlur
127 inkscape:collect="always"
128 stdDeviation="0.71999962"
129 id="feGaussianBlur893" />
130 </filter>
131 </defs>
132 <sodipodi:namedview
133 id="base"
134 pagecolor="#ffffff"
135 bordercolor="#666666"
136 borderopacity="1.0"
137 inkscape:pageopacity="0.0"
138 inkscape:pageshadow="2"
139 inkscape:zoom="4.0745362"
140 inkscape:cx="18.514671"
141 inkscape:cy="49.018169"
142 inkscape:document-units="px"
143 inkscape:current-layer="layer1"
144 showgrid="true"
145 fit-margin-top="0"
146 fit-margin-left="0"
147 fit-margin-right="0"
148 fit-margin-bottom="0"
149 inkscape:window-width="1920"
150 inkscape:window-height="1029"
151 inkscape:window-x="0"
152 inkscape:window-y="24"
153 inkscape:window-maximized="1"
154 showborder="true"
155 showguides="true"
156 inkscape:guide-bbox="true"
157 inkscape:showpageshadow="false">
158 <inkscape:grid
159 type="xygrid"
160 id="grid821" />
161 <sodipodi:guide
162 orientation="1,0"
163 position="16,48"
164 id="guide823" />
165 <sodipodi:guide
166 orientation="0,1"
167 position="64,80"
168 id="guide825" />
169 <sodipodi:guide
170 orientation="1,0"
171 position="80,40"
172 id="guide827" />
173 <sodipodi:guide
174 orientation="0,1"
175 position="64,16"
176 id="guide829" />
177 </sodipodi:namedview>
178 <metadata
179 id="metadata6522">
180 <rdf:RDF>
181 <cc:Work
182 rdf:about="">
183 <dc:format>image/svg+xml</dc:format>
184 <dc:type
185 rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
186 <dc:title></dc:title>
187 </cc:Work>
188 </rdf:RDF>
189 </metadata>
190 <g
191 inkscape:label="BACKGROUND"
192 inkscape:groupmode="layer"
193 id="layer1"
194 transform="translate(268,-635.29076)"
195 style="display:inline">
196 <path
197 style="fill:url(#linearGradient6461);fill-opacity:1;stroke:none;display:inline;filter:url(#filter1121)"
198 d="m -268,700.15563 0,-33.72973 c 0,-27.24324 3.88785,-31.13513 31.10302,-31.13513 l 33.79408,0 c 27.21507,0 31.1029,3.89189 31.1029,31.13513 l 0,33.72973 c 0,27.24325 -3.88783,31.13514 -31.1029,31.13514 l -33.79408,0 C -264.11215,731.29077 -268,727.39888 -268,700.15563 Z"
199 id="path6455"
200 inkscape:connector-curvature="0"
201 sodipodi:nodetypes="sssssssss" />
202 </g>
203 <g
204 inkscape:groupmode="layer"
205 id="layer3"
206 inkscape:label="PLACE YOUR PICTOGRAM HERE"
207 style="display:inline" />
208 <g
209 inkscape:groupmode="layer"
210 id="layer2"
211 inkscape:label="BADGE"
212 style="display:none"
213 sodipodi:insensitive="true">
214 <g
215 style="display:inline"
216 transform="translate(-340.00001,-581)"
217 id="g4394"
218 clip-path="none">
219 <g
220 id="g855">
221 <g
222 inkscape:groupmode="maskhelper"
223 id="g870"
224 clip-path="url(#clipPath873)"
225 style="opacity:0.6;filter:url(#filter891)">
226 <path
227 transform="matrix(1.4999992,0,0,1.4999992,-29.999795,-237.54282)"
228 d="m 264,552.36218 a 12,12 0 1 1 -24,0 A 12,12 0 1 1 264,552.36218 Z"
229 sodipodi:ry="12"
230 sodipodi:rx="12"
231 sodipodi:cy="552.36218"
232 sodipodi:cx="252"
233 id="path844"
234 style="color:#000000;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"
235 sodipodi:type="arc" />
236 </g>
237 <g
238 id="g862">
239 <path
240 sodipodi:type="arc"
241 style="color:#000000;fill:#f5f5f5;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"
242 id="path4398"
243 sodipodi:cx="252"
244 sodipodi:cy="552.36218"
245 sodipodi:rx="12"
246 sodipodi:ry="12"
247 d="m 264,552.36218 a 12,12 0 1 1 -24,0 A 12,12 0 1 1 264,552.36218 Z"
248 transform="matrix(1.4999992,0,0,1.4999992,-29.999795,-238.54282)" />
249 <path
250 transform="matrix(1.25,0,0,1.25,33,-100.45273)"
251 d="m 264,552.36218 a 12,12 0 1 1 -24,0 A 12,12 0 1 1 264,552.36218 Z"
252 sodipodi:ry="12"
253 sodipodi:rx="12"
254 sodipodi:cy="552.36218"
255 sodipodi:cx="252"
256 id="path4400"
257 style="color:#000000;fill:#dd4814;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"
258 sodipodi:type="arc" />
259 <path
260 sodipodi:type="star"
261 style="color:#000000;fill:#f5f5f5;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"
262 id="path4459"
263 sodipodi:sides="5"
264 sodipodi:cx="666.19574"
265 sodipodi:cy="589.50385"
266 sodipodi:r1="7.2431178"
267 sodipodi:r2="4.3458705"
268 sodipodi:arg1="1.0471976"
269 sodipodi:arg2="1.6755161"
270 inkscape:flatsided="false"
271 inkscape:rounded="0.1"
272 inkscape:randomized="0"
273 d="m 669.8173,595.77657 c -0.39132,0.22593 -3.62645,-1.90343 -4.07583,-1.95066 -0.44938,-0.0472 -4.05653,1.36297 -4.39232,1.06062 -0.3358,-0.30235 0.68963,-4.03715 0.59569,-4.47913 -0.0939,-0.44198 -2.5498,-3.43681 -2.36602,-3.8496 0.18379,-0.41279 4.05267,-0.59166 4.44398,-0.81759 0.39132,-0.22593 2.48067,-3.48704 2.93005,-3.4398 0.44938,0.0472 1.81505,3.67147 2.15084,3.97382 0.3358,0.30236 4.08294,1.2817 4.17689,1.72369 0.0939,0.44198 -2.9309,2.86076 -3.11469,3.27355 C 669.9821,591.68426 670.20862,595.55064 669.8173,595.77657 Z"
274 transform="matrix(1.511423,-0.16366377,0.16366377,1.511423,-755.37346,-191.93651)" />
275 </g>
276 </g>
277 </g>
278 </g>
279</svg>
diff --git a/interfaces/.empty b/interfaces/.empty
0new file mode 100644280new file mode 100644
index 0000000..792d600
--- /dev/null
+++ b/interfaces/.empty
@@ -0,0 +1 @@
1#
diff --git a/layer.yaml b/layer.yaml
0new file mode 1006442new file mode 100644
index 0000000..3399b95
--- /dev/null
+++ b/layer.yaml
@@ -0,0 +1,2 @@
1includes:
2 - 'layer:basic'
diff --git a/layers/.empty b/layers/.empty
0new file mode 1006443new file mode 100644
index 0000000..792d600
--- /dev/null
+++ b/layers/.empty
@@ -0,0 +1 @@
1#
diff --git a/lib/AdvancedRoutingHelper.py b/lib/AdvancedRoutingHelper.py
0new file mode 1006442new file mode 100644
index 0000000..73526a5
--- /dev/null
+++ b/lib/AdvancedRoutingHelper.py
@@ -0,0 +1,132 @@
1"""Routing module."""
2import errno
3import os
4import subprocess
5
6from RoutingEntry import RoutingEntryType
7
8from RoutingValidator import RoutingConfigValidator
9
10from charmhelpers.core import hookenv
11from charmhelpers.core.host import (
12 CompareHostReleases,
13 lsb_release,
14)
15
16
17class AdvancedRoutingHelper:
18 """Helper class for routing."""
19
20 if_script = '95-juju_routing'
21 common_location = '/usr/local/sbin/' # trailing slash
22 net_tools_up_path = '/etc/network/if-up.d/' # trailing slash
23 net_tools_down_path = '/etc/network/if-down.d/' # trailing slash
24 netplan_up_path = '/etc/networkd-dispatcher/routable.d/' # trailing slash
25 netplan_down_path = '/etc/networkd-dispatcher/off.d/' # trailing slash
26 policy_routing_service_path = '/etc/systemd/system/' # trailing slash
27 ifup_path = None
28 ifdown_path = None
29
30 def __init__(self):
31 """Init function."""
32 hookenv.log('Init {}'.format(self.__class__.__name__), level=hookenv.INFO)
33 self.pre_setup()
34
35 def pre_setup(self):
36 """Create folder path for the ifup/down scripts."""
37 if not os.path.exists(self.common_location + 'if-up/'):
38 os.makedirs(self.common_location + 'if-up/')
39 hookenv.log('Created {}'.format(self.common_location + 'if-up/'), level=hookenv.INFO)
40 if not os.path.exists(self.common_location + 'if-down/'):
41 os.makedirs(self.common_location + 'if-down/')
42 hookenv.log('Created {}'.format(self.common_location + 'if-down/'), level=hookenv.INFO)
43
44 self.ifup_path = '{}if-up/{}'.format(self.common_location, self.if_script)
45 self.ifdown_path = '{}if-down/{}'.format(self.common_location, self.if_script)
46
47 # check for service file of charm-policy-routing, and block if its present
48 if os.path.exists(self.policy_routing_service_path + 'charm-pre-install-policy-routing.service'):
49 hookenv.log('It looks like charm-policy-routing is enabled. '
50 ' charm-pre-install-policy-routing.service', 'WARNING')
51 hookenv.status_set('blocked', 'Please disable charm-policy-routing')
52 raise Exception('Please disable charm-policy-routing')
53
54 def post_setup(self):
55 """Symlinks the up/down scripts from the if.up/down or netplan scripts location."""
56 hookenv.log('Symlinking into distro specific network manager', level=hookenv.INFO)
57 release = lsb_release()['DISTRIB_CODENAME'].lower()
58 if CompareHostReleases(release) < "bionic":
59 self.symlink_force(self.ifup_path, '{}{}'.format(self.net_tools_up_path, self.if_script))
60 self.symlink_force(self.ifdown_path, '{}{}'.format(self.net_tools_down_path, self.if_script))
61 else:
62 self.symlink_force(self.ifup_path, '{}{}'.format(self.netplan_up_path, self.if_script))
63 self.symlink_force(self.ifdown_path, '{}{}'.format(self.netplan_down_path, self.if_script))
64
65 def setup(self):
66 """Modify the interfaces configurations."""
67 RoutingConfigValidator()
68
69 hookenv.log('Writing {}'.format(self.ifup_path), level=hookenv.INFO)
70 # Modify if-up.d
71 with open(self.ifup_path, 'w') as ifup:
72 ifup.write("# This file is managed by Juju.\n")
73 ifup.write("ip route flush cache\n")
74 for entry in RoutingEntryType.entries:
75 ifup.write(entry.addline)
76 os.chmod(self.ifup_path, 0o755)
77
78 hookenv.log('Writing {}'.format(self.ifdown_path), level=hookenv.INFO)
79 # Modify if-down.d
80 with open(self.ifdown_path, 'w') as ifdown:
81 ifdown.write("# This file is managed by Juju.\n")
82 for entry in list(reversed(RoutingEntryType.entries)):
83 ifdown.write(entry.removeline)
84 ifdown.write("ip route flush cache\n")
85 os.chmod(self.ifdown_path, 0o755)
86
87 self.post_setup()
88
89 def apply_routes(self):
90 """Apply the new routes to the system."""
91 hookenv.log('Applying routing rules', level=hookenv.INFO)
92 for entry in RoutingEntryType.entries:
93 entry.apply()
94
95 def remove_routes(self):
96 """Cleanup job."""
97 hookenv.log('Removing routing rules', level=hookenv.INFO)
98 if os.path.exists(self.ifdown_path):
99 try:
100 subprocess.check_call(["sh", "-c", self.ifdown_path], shell=True)
101 except subprocess.CalledProcessError:
102 # Either rules are removed or not valid
103 hookenv.log('ifdown script failed. Maybe rules are already gone?', 'WARNING')
104
105 # remove files
106 if os.path.exists(self.ifup_path):
107 os.remove(self.ifup_path)
108 if os.path.exists(self.ifdown_path):
109 os.remove(self.ifdown_path)
110
111 # remove symlinks
112 release = lsb_release()['DISTRIB_CODENAME'].lower()
113 try:
114 if CompareHostReleases(release) < "bionic":
115 os.remove('{}{}'.format(self.net_tools_up_path, self.if_script))
116 os.remove('{}{}'.format(self.net_tools_down_path, self.if_script))
117 else:
118 os.remove('{}{}'.format(self.netplan_up_path, self.if_script))
119 os.remove('{}{}'.format(self.netplan_down_path, self.if_script))
120 except Exception:
121 hookenv.log('Nothing to clean up', 'WARNING')
122
123 def symlink_force(self, target, link_name):
124 """Ensures accute symlink by removing any existing links."""
125 try:
126 os.symlink(target, link_name)
127 except OSError as e:
128 if e.errno == errno.EEXIST:
129 os.remove(link_name)
130 os.symlink(target, link_name)
131 else:
132 raise e
diff --git a/lib/RoutingEntry.py b/lib/RoutingEntry.py
0new file mode 100644133new file mode 100644
index 0000000..a6d6ed8
--- /dev/null
+++ b/lib/RoutingEntry.py
@@ -0,0 +1,221 @@
1"""RoutingEntry Classes.
2
3This module contains the following abstract type and
4concrete implementations, thta model a routing table
5
6 RoutingEntryType
7 ---------------------------------------
8 | | |
9 RoutingEntryTable RoutingEntryRoute RoutingEntryRule
10"""
11import subprocess
12
13from charmhelpers.core import hookenv
14
15
16class RoutingEntryType(object):
17 """Abstract type RoutingEntryType."""
18
19 entries = [] # static <RoutingEntryType>[]
20 config = None # config entry
21
22 def __init__(self):
23 """Constructor."""
24 hookenv.log('Init {}'.format(self.__class__.__name__), level=hookenv.INFO)
25 pass
26
27 def exec_cmd(self, cmd, pipe=False):
28 """Runs a subprocess and returns True or False on success."""
29 try:
30 if pipe:
31 hookenv.log('Subprocess check shell: {} {}'.format(self.__class__.__name__, cmd), level=hookenv.INFO)
32 command = ' '.join(cmd)
33 ps = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
34 ps.communicate()
35 return True if ps.returncode == 0 else False
36 else:
37 hookenv.log('Subprocess check: {} {}'.format(self.__class__.__name__, cmd), level=hookenv.INFO)
38 subprocess.check_call(cmd)
39 return True
40 except subprocess.CalledProcessError as error:
41 hookenv.log(error, level=hookenv.ERROR)
42 return False
43
44 @staticmethod
45 def add_rule(newrule):
46 """Due to config-change/install etc hooks etc.
47
48 The validator may be called multiple times
49 The static list will get duplicate items added
50 """
51 for rule in RoutingEntryType.entries:
52 if rule.addline == newrule.addline:
53 return
54 RoutingEntryType.entries.append(newrule)
55
56 def apply(self):
57 """Not implemented, should override in strategy."""
58 raise NotImplementedError
59
60 def create_line(self):
61 """Not implemented, should override in strategy."""
62 raise NotImplementedError
63
64 @property
65 def addline(self):
66 """Not implemented, should override in strategy."""
67 raise NotImplementedError
68
69 @property
70 def removeline(self):
71 """Not implemented, should override in strategy."""
72 raise NotImplementedError
73
74
75class RoutingEntryTable(RoutingEntryType):
76 """Concrete RoutingEntryType Strategy."""
77
78 table_name_file = '/etc/iproute2/rt_tables.d/juju-managed.conf'
79 table_index_counter = 100 # static
80 tables = []
81
82 def __init__(self, config):
83 """Adds unique tables to the tables list."""
84 hookenv.log('Created {}'.format(self.__class__.__name__), level=hookenv.INFO)
85 super().__init__()
86 self.config = config
87
88 if self.config['table'] not in RoutingEntryTable.tables:
89 RoutingEntryTable.tables.append(self.config['table'])
90
91 def create_line(self):
92 """Not implemented in this base class."""
93 raise NotImplementedError
94
95 def apply(self):
96 """Opens iproute tables and adds the known list of tables into this file."""
97 with open(RoutingEntryTable.table_name_file, 'w') as rt_table_file:
98 num = RoutingEntryTable.table_index_counter
99 for tbl in RoutingEntryTable.tables:
100 rt_table_file.write("{} {}\n".format(num, tbl))
101 num += 1
102
103 @property
104 def addline(self):
105 """Returns the add line for the ifup script."""
106 return "# Table: name {}\n".format(self.config['table'])
107
108 @property
109 def removeline(self):
110 """Returns the remove line for the ifdown script."""
111 line = "ip route flush table {}\n".format(self.config['table'])
112 line += "ip rule del table {}\n".format(self.config['table'])
113 return line
114
115
116class RoutingEntryRoute(RoutingEntryType):
117 """Concrete RoutingEntryType Strategy."""
118
119 def __init__(self, config):
120 """Nothing special in this constructor."""
121 hookenv.log('Created {}'.format(self.__class__.__name__), level=hookenv.INFO)
122 super().__init__()
123 self.config = config
124
125 def create_line(self):
126 """Creates and returns the command line for this rule object."""
127 cmd = ["ip", "route", "replace"]
128
129 # default route in table
130 if 'default_route' in self.config.keys():
131 cmd.append("default")
132 cmd.append("via")
133 cmd.append(self.config['gateway'])
134 cmd.append("table")
135 cmd.append(self.config['table'])
136 if 'device' in self.config.keys():
137 cmd.append("dev")
138 cmd.append(self.config['device'])
139 # route in any given table or none
140 else:
141 cmd.append(self.config['net'])
142 if 'gateway' in self.config.keys():
143 cmd.append("via")
144 cmd.append(self.config['gateway'])
145 if 'device' in self.config.keys():
146 cmd.append("dev")
147 cmd.append(self.config['device'])
148 if 'table' in self.config.keys():
149 cmd.append("table")
150 cmd.append(self.config['table'])
151 if 'metric' in self.config.keys():
152 cmd.append("metric")
153 cmd.append(str(self.config['metric']))
154
155 return cmd
156
157 def apply(self):
158 """Applies this rule object to the system."""
159 super().exec_cmd(self.create_line())
160
161 @property
162 def addline(self):
163 """Returns the add line for the ifup script."""
164 return ' '.join(self.create_line()) + "\n"
165
166 @property
167 def removeline(self):
168 """Returns the remove line for the ifdown script."""
169 return ' '.join(self.create_line()).replace(" replace ", " del ") + "\n"
170
171
172class RoutingEntryRule(RoutingEntryType):
173 """Concrete RoutingEntryType Strategy."""
174
175 def __init__(self, config):
176 """Nothing special in this constructor."""
177 hookenv.log('Created {}'.format(self.__class__.__name__), level=hookenv.INFO)
178 super().__init__()
179 self.config = config
180
181 def create_line(self):
182 """Creates and returns the command line for this rule object."""
183 cmd = ["ip", "rule", "add", "from", self.config['from-net']]
184
185 if 'to-net' in self.config.keys():
186 cmd.append("to")
187 cmd.append(self.config['to-net'])
188 cmd.append("lookup")
189 if 'table' in self.config.keys():
190 cmd.append(self.config['table'])
191 else:
192 cmd.append('main')
193 else:
194 if 'table' in self.config.keys():
195 cmd.append(self.config['table'])
196 if 'priority' in self.config.keys():
197 cmd.append(self.config['priority'])
198
199 return cmd
200
201 def apply(self):
202 """Applies this rule object to the system."""
203 if self.is_duplicate() is False:
204 # ip rule replace not supported, check for duplicates
205 super().exec_cmd(self.create_line())
206
207 @property
208 def addline(self):
209 """Returns the add line for the ifup script."""
210 return ' '.join(self.create_line()) + "\n"
211
212 @property
213 def removeline(self):
214 """Returns the remove line for the ifdown script."""
215 return ' '.join(self.create_line()).replace(" add ", " del ") + "\n"
216
217 def is_duplicate(self):
218 """Ip rule add does not prevent duplicates in older kernel versions."""
219 # https://patchwork.ozlabs.org/patch/624553/
220 parts = ' '.join(self.create_line()).split("add ")
221 return self.exec_cmd(["ip", "rule", "|", "grep", "\"" + parts[1] + "\""], pipe=True)
diff --git a/lib/RoutingValidator.py b/lib/RoutingValidator.py
0new file mode 100644222new file mode 100644
index 0000000..24d780c
--- /dev/null
+++ b/lib/RoutingValidator.py
@@ -0,0 +1,182 @@
1"""RoutingValidator Class.
2
3Validates the entire json configuration constructing a model.
4"""
5import ipaddress
6import json
7import pprint
8import re
9import subprocess
10
11from RoutingEntry import (
12 RoutingEntryRoute,
13 RoutingEntryRule,
14 RoutingEntryTable,
15 RoutingEntryType,
16)
17
18from charmhelpers.core import hookenv
19
20
21class RoutingConfigValidator:
22 """Validates the enitre json configuration constructing model of rules."""
23
24 def __init__(self):
25 """Init function."""
26 hookenv.log('Init {}'.format(self.__class__.__name__), level=hookenv.INFO)
27
28 self.pattern = re.compile("^([a-zA-Z0-9-]+)$")
29 self.tables = []
30 self.config = self.read_configurations()
31 self.verify_config()
32
33 def read_configurations(self):
34 """Read and parse the JSON configuration file."""
35 json_decoded = []
36 conf = hookenv.config()
37
38 if conf['advanced-routing-config']:
39 try:
40 json_decoded = json.loads(conf['advanced-routing-config'])
41 hookenv.log('Read json config from juju config', level=hookenv.INFO)
42 except ValueError:
43 hookenv.status_set('blocked', 'JSON format invalid.')
44 raise Exception('JSON format invalid.')
45 else:
46 hookenv.status_set('blocked', 'JSON invalid or not set in charm config')
47 raise Exception('JSON format invalid.')
48 return json_decoded
49
50 def verify_config(self):
51 """Iterates the entries in the config checking each type for sanity."""
52 hookenv.log('Verifying json config', level=hookenv.INFO)
53 type_order = ['table', 'route', 'rule']
54
55 for entry_type in type_order:
56 # get all the tables together first, so that we can provide strict relations
57 for conf in self.config:
58 if 'type' not in conf:
59 hookenv.status_set('blocked', 'Bad config: \'type\' not found in routing entry')
60 hookenv.log('type not found in rule', level=hookenv.ERROR)
61 raise Exception('type not found in rule')
62 if entry_type == "table" and entry_type == conf['type']:
63 self.verify_table(conf)
64 if entry_type == "route" and entry_type == conf['type']:
65 self.verify_route(conf)
66 if entry_type == "rule" and entry_type == conf['type']:
67 self.verify_rule(conf)
68
69 def verify_table(self, conf):
70 """Verify rules."""
71 hookenv.log('Verifying table \n{}'.format(pprint.pformat(conf)), level=hookenv.INFO)
72 if 'table' not in conf:
73 hookenv.status_set('blocked', 'Bad network config: \'table\' missing in rule')
74 raise Exception('Bad network config: \'table\' missing in rule')
75
76 if self.pattern.match(conf['table']) is False:
77 hookenv.status_set('blocked', 'Bad network config: garbage table name in table [0-9a-zA-Z-]')
78 raise Exception('Bad network config: garbage table name in table [0-9a-zA-Z-]')
79
80 if conf['table'] in self.tables:
81 hookenv.status_set('blocked', 'Bad network config: duplicate table name')
82 raise Exception('Bad network config: duplicate table name')
83
84 self.tables.append(conf['table'])
85 RoutingEntryType.add_rule(RoutingEntryTable(conf))
86
87 def verify_route(self, conf):
88 """Verify routes."""
89 hookenv.log('Verifying route \n{}'.format(pprint.pformat(conf)), level=hookenv.INFO)
90 try:
91 ipaddress.ip_address(conf['gateway'])
92 except ValueError as error:
93 hookenv.log('Bad gateway IP: {} - {}'.format(conf['gateway'], error), level=hookenv.INFO)
94 hookenv.status_set('blocked', 'Bad gateway IP: {} - {}'.format(conf['gateway'], error))
95 raise Exception('Bad gateway IP: {} - {}'.format(conf['gateway'], error))
96
97 try:
98 ipaddress.ip_network(conf['net'])
99 except ValueError as error:
100 hookenv.log('Bad network config: {} - {}'.format(conf['net'], error), level=hookenv.INFO)
101 hookenv.status_set('blocked', 'Bad network config: {} - {}'.format(conf['net'], error))
102 raise Exception('Bad network config: {} - {}'.format(conf['net'], error))
103
104 if 'table' in conf:
105 if self.pattern.match(conf['table']) is False:
106 hookenv.log('Bad network config: garbage table name in rule [0-9a-zA-Z]', level=hookenv.INFO)
107 hookenv.status_set('blocked', 'Bad network config')
108 raise Exception('Bad network config: garbage table name in rule [0-9a-zA-Z]')
109 if conf['table'] not in self.tables:
110 hookenv.log('Bad network config: table reference not defined', level=hookenv.INFO)
111 hookenv.status_set('blocked', 'Bad network config')
112 raise Exception('Bad network config: table reference not defined')
113
114 if 'default_route' in conf:
115 if isinstance(conf['default_route'], bool):
116 hookenv.log('Bad network config: default_route should be bool', level=hookenv.INFO)
117 hookenv.status_set('blocked', 'Bad network config')
118 raise Exception('Bad network config: default_route should be bool')
119 if 'table' not in conf:
120 hookenv.log('Bad network config: replacing the default route in main table blocked', level=hookenv.INFO)
121 hookenv.status_set('blocked', 'Bad network config')
122 raise Exception('Bad network config: replacing the default route in main table blocked')
123
124 if 'device' in conf:
125 if self.pattern.match(conf['device']) is False:
126 hookenv.log('Bad network config: garbage device name in rule [0-9a-zA-Z-]', level=hookenv.INFO)
127 hookenv.status_set('blocked', 'Bad network config')
128 raise Exception('Bad network config: garbage device name in rule [0-9a-zA-Z-]')
129 try:
130 subprocess.check_call(["ip", "link", "show", conf['device']])
131 except subprocess.CalledProcessError as error:
132 hookenv.log('Device {} does not exist'.format(conf['device']), level=hookenv.INFO)
133 hookenv.status_set('blocked', 'Bad network config')
134 raise Exception('Device {} does not exist, {}'.format(conf['device'], error))
135
136 if 'metric' in conf:
137 try:
138 int(conf['metric'])
139 except ValueError:
140 hookenv.log('Bad network config: metric expected to be integer', level=hookenv.INFO)
141 hookenv.status_set('blocked', 'Bad network config')
142 raise Exception('Bad network config: metric expected to be integer')
143
144 RoutingEntryType.add_rule(RoutingEntryRoute(conf))
145
146 def verify_rule(self, conf):
147 """Verify rules."""
148 hookenv.log('Verifying rule \n{}'.format(pprint.pformat(conf)), level=hookenv.INFO)
149
150 try:
151 ipaddress.ip_network(conf['from-net'])
152 except ValueError as error:
153 hookenv.status_set('blocked', 'Bad network config: {} - {}'.format(conf['from-net'], error))
154 raise Exception('Bad network config: {} - {}'.format(conf['from-net'], error))
155
156 if 'to-net' in conf:
157 try:
158 ipaddress.ip_network(conf['to-net'])
159 except ValueError as error:
160 hookenv.status_set('blocked', 'Bad network config: {} - {}'.format(conf['to-net'], error))
161 raise Exception('Bad network config: {} - {}'.format(conf['to-net'], error))
162
163 if 'table' not in conf:
164 hookenv.status_set('blocked', 'Bad network config: \'table\' missing in rule')
165 raise Exception('Bad network config: \'table\' missing in rule')
166
167 if conf['table'] not in self.tables:
168 hookenv.status_set('blocked', 'Bad network config: table reference not defined')
169 raise Exception('Bad network config: table reference not defined')
170
171 if self.pattern.match(conf['table']) is False:
172 hookenv.status_set('blocked', 'Bad network config: garbage table name in rule [0-9a-zA-Z-]')
173 raise Exception('Bad network config: garbage table name in rule [0-9a-zA-Z-]')
174
175 if 'priority' in conf:
176 try:
177 int(conf['priority'])
178 except ValueError:
179 hookenv.status_set('blocked', 'Bad network config: priority expected to be integer')
180 raise Exception('Bad network config: priority expected to be integer')
181
182 RoutingEntryType.add_rule(RoutingEntryRule(conf))
diff --git a/metadata.yaml b/metadata.yaml
0new file mode 100644183new file mode 100644
index 0000000..8984ae6
--- /dev/null
+++ b/metadata.yaml
@@ -0,0 +1,16 @@
1name: advanced-routing
2maintainer: David O Neill <david.o.neill@canonical.com>
3summary: Routing configuration editor
4description: |
5 This charm can be deployed alongside principle charms to enable custom
6 network routes management.
7subordinate: true
8series:
9 - xenial
10 - bionic
11tags:
12 - misc
13requires:
14 juju-info:
15 interface: juju-info
16 scope: container
diff --git a/reactive/advanced_routing.py b/reactive/advanced_routing.py
0new file mode 10064417new file mode 100644
index 0000000..c1ddbb1
--- /dev/null
+++ b/reactive/advanced_routing.py
@@ -0,0 +1,56 @@
1"""Reactive charm hooks."""
2from charmhelpers.core.hookenv import (
3 Hooks,
4 config,
5 status_set,
6)
7
8from charms.reactive import (
9 hook,
10 set_flag,
11 when,
12 when_not,
13)
14
15from AdvancedRoutingHelper import AdvancedRoutingHelper
16
17hooks = Hooks()
18advanced_routing = AdvancedRoutingHelper()
19
20
21@when_not('advanced-routing.installed')
22def install_routing():
23 """Install the charm."""
24 if config('enable-advanced-routing'):
25 advanced_routing.setup()
26 advanced_routing.apply_routes()
27 set_flag('advanced-routing.installed')
28
29 status_set('active', 'Unit is ready.')
30
31
32@hook('upgrade-charm')
33def upgrade_charm():
34 """Handle resource-attach and config-changed events."""
35 if config('enable-advanced-routing'):
36 status_set('maintenance', 'Installing new static routes.')
37 advanced_routing.remove_routes()
38 advanced_routing.setup()
39 advanced_routing.apply_routes()
40
41 status_set('active', 'Unit is ready.')
42
43
44@when('config.changed.enable-advanced-routing')
45def reconfigure_routing():
46 """Handle routing configuration change."""
47 if config('enable-advanced-routing'):
48 status_set('maintenance', 'Installing routes.')
49 advanced_routing.remove_routes()
50 advanced_routing.setup()
51 advanced_routing.apply_routes()
52 else:
53 status_set('maintenance', 'Removing routes.')
54 advanced_routing.remove_routes()
55
56 status_set('active', 'Unit is ready.')
diff --git a/requirements.txt b/requirements.txt
0new file mode 10064457new file mode 100644
index 0000000..6dae81c
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
1# Include python requirements here
2charmhelpers
3charms.reactive
0\ No newline at end of file4\ No newline at end of file
diff --git a/revision b/revision
1new file mode 1006445new file mode 100644
index 0000000..d00491f
--- /dev/null
+++ b/revision
@@ -0,0 +1 @@
11
diff --git a/tests/bundles/bionic.yaml b/tests/bundles/bionic.yaml
0new file mode 1006442new file mode 100644
index 0000000..65ab84b
--- /dev/null
+++ b/tests/bundles/bionic.yaml
@@ -0,0 +1,45 @@
1series: bionic
2description: "charm-advanced-routing test bundle"
3machines:
4 "0":
5 constraints: "arch=amd64"
6relations:
7 - - "testhost:juju-info"
8 - "charm-advanced-routing:juju-info"
9applications:
10 testhost:
11 charm: "ubuntu"
12 options:
13 hostname: "testhost"
14 num_units: 1
15 to:
16 - "0"
17 charm-advanced-routing:
18 charm: "../.."
19 series: bionic
20 num_units: 0
21 options:
22 advanced-routing-config: |-
23 [ {
24 "type": "table",
25 "table": "SF1"
26 }, {
27 "type": "route",
28 "default_route": true,
29 "net": "192.170.1.0/24",
30 "gateway": "10.191.86.2",
31 "table": "SF1",
32 "metric": 101,
33 "device": "eth0"
34 }, {
35 "type": "route",
36 "net": "6.6.6.0/24",
37 "gateway": "10.191.86.2"
38 }, {
39 "type": "rule",
40 "from-net": "192.170.2.0/24",
41 "to-net": "192.170.2.0/24",
42 "table": "SF1",
43 "priority": 101
44 } ]
45 enable-advanced-routing: true
diff --git a/tests/bundles/overlays/bionic.yaml.j2 b/tests/bundles/overlays/bionic.yaml.j2
0new file mode 10064446new file mode 100644
index 0000000..5da69ca
--- /dev/null
+++ b/tests/bundles/overlays/bionic.yaml.j2
@@ -0,0 +1,2 @@
1applications:
2 {{ charm_name }}:
diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py
0new file mode 1006443new file mode 100644
index 0000000..1b22cb8
--- /dev/null
+++ b/tests/functional/conftest.py
@@ -0,0 +1,71 @@
1#!/usr/bin/python3
2"""
3Reusable pytest fixtures for functional testing.
4
5Environment variables
6---------------------
7
8test_preserve_model:
9if set, the testing model won't be torn down at the end of the testing session
10"""
11
12import asyncio
13import os
14import subprocess
15import uuid
16
17from juju.controller import Controller
18
19from juju_tools import JujuTools
20
21import pytest
22
23
24@pytest.fixture(scope='module')
25def event_loop():
26 """Override the default pytest event loop.
27
28 Do this too allow for fixtures using a broader scope.
29 """
30 loop = asyncio.get_event_loop_policy().new_event_loop()
31 asyncio.set_event_loop(loop)
32 loop.set_debug(True)
33 yield loop
34 loop.close()
35 asyncio.set_event_loop(None)
36
37
38@pytest.fixture(scope='module')
39async def controller():
40 """Connect to the current controller."""
41 _controller = Controller()
42 await _controller.connect_current()
43 yield _controller
44 await _controller.disconnect()
45
46
47@pytest.fixture(scope='module')
48async def model(controller):
49 """Live only for the duration of the test."""
50 model_name = "functest-{}".format(str(uuid.uuid4())[-12:])
51 _model = await controller.add_model(model_name,
52 cloud_name=os.getenv('PYTEST_CLOUD_NAME'),
53 region=os.getenv('PYTEST_CLOUD_REGION'),
54 )
55 # https://github.com/juju/python-libjuju/issues/267
56 subprocess.check_call(['juju', 'models'])
57 while model_name not in await controller.list_models():
58 await asyncio.sleep(1)
59 yield _model
60 await _model.disconnect()
61 if not os.getenv('PYTEST_KEEP_MODEL'):
62 await controller.destroy_model(model_name)
63 while model_name in await controller.list_models():
64 await asyncio.sleep(1)
65
66
67@pytest.fixture(scope='module')
68async def jujutools(controller, model):
69 """Juju tools."""
70 tools = JujuTools(controller, model)
71 return tools
diff --git a/tests/functional/juju_tools.py b/tests/functional/juju_tools.py
0new file mode 10064472new file mode 100644
index 0000000..5e4a1cc
--- /dev/null
+++ b/tests/functional/juju_tools.py
@@ -0,0 +1,69 @@
1"""Juju tools."""
2import base64
3import pickle
4
5import juju
6
7# from juju.errors import JujuError
8
9
10class JujuTools:
11 """Juju tools."""
12
13 def __init__(self, controller, model):
14 """Init."""
15 self.controller = controller
16 self.model = model
17
18 async def run_command(self, cmd, target):
19 """Run a command on a unit.
20
21 :param cmd: Command to be run
22 :param unit: Unit object or unit name string
23 """
24 unit = (
25 target
26 if isinstance(target, juju.unit.Unit)
27 else await self.get_unit(target)
28 )
29 action = await unit.run(cmd)
30 return action.results
31
32 async def remote_object(self, imports, remote_cmd, target):
33 """Run command on target machine and returns a python object of the result.
34
35 :param imports: Imports needed for the command to run
36 :param remote_cmd: The python command to execute
37 :param target: Unit object or unit name string
38 """
39 python3 = "python3 -c '{}'"
40 python_cmd = ('import pickle;'
41 'import base64;'
42 '{}'
43 'print(base64.b64encode(pickle.dumps({})), end="")'
44 .format(imports, remote_cmd))
45 cmd = python3.format(python_cmd)
46 results = await self.run_command(cmd, target)
47 return pickle.loads(base64.b64decode(bytes(results['Stdout'][2:-1], 'utf8')))
48
49 async def file_stat(self, path, target):
50 """Run stat on a file.
51
52 :param path: File path
53 :param target: Unit object or unit name string
54 """
55 imports = 'import os;'
56 python_cmd = ('os.stat("{}")'
57 .format(path))
58 print("Calling remote cmd: " + python_cmd)
59 return await self.remote_object(imports, python_cmd, target)
60
61 async def file_contents(self, path, target):
62 """Return the contents of a file.
63
64 :param path: File path
65 :param target: Unit object or unit name string
66 """
67 cmd = 'cat {}'.format(path)
68 result = await self.run_command(cmd, target)
69 return result['Stdout']
diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt
0new file mode 10064470new file mode 100644
index 0000000..f76bfbb
--- /dev/null
+++ b/tests/functional/requirements.txt
@@ -0,0 +1,6 @@
1flake8
2juju
3mock
4pytest
5pytest-asyncio
6requests
diff --git a/tests/functional/test_routing.py b/tests/functional/test_routing.py
0new file mode 1006447new file mode 100644
index 0000000..96a0235
--- /dev/null
+++ b/tests/functional/test_routing.py
@@ -0,0 +1,54 @@
1#!/usr/bin/python3.6
2"""Main module for functional testing."""
3
4import os
5
6import pytest
7
8pytestmark = pytest.mark.asyncio
9SERIES = ['bionic', 'xenial']
10
11############
12# FIXTURES #
13############
14
15
16@pytest.fixture(scope='module', params=SERIES)
17async def deploy_app(request, model):
18 """Deploy the advanced-routing charm as a subordinate of ubuntu."""
19 release = request.param
20
21 await model.deploy(
22 'ubuntu',
23 application_name='ubuntu-' + release,
24 series=release,
25 channel='stable'
26 )
27 advanced_routing = await model.deploy(
28 '{}/builds/advanced-routing'.format(os.getenv('JUJU_REPOSITORY')),
29 application_name='advanced-routing-' + release,
30 series=release,
31 num_units=0,
32 )
33 await model.add_relation(
34 'ubuntu-' + release,
35 'advanced-routing-' + release
36 )
37
38 await model.block_until(lambda: advanced_routing.status == 'active')
39 yield advanced_routing
40
41
42@pytest.fixture(scope='module')
43async def unit(deploy_app):
44 """Return the advanced-routing unit we've deployed."""
45 return deploy_app.units.pop()
46
47#########
48# TESTS #
49#########
50
51
52async def test_deploy(deploy_app):
53 """Test the deployment."""
54 assert deploy_app.status == 'active'
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
0new file mode 10064455new file mode 100644
index 0000000..fbf31d7
--- /dev/null
+++ b/tests/unit/conftest.py
@@ -0,0 +1,80 @@
1#!/usr/bin/python3
2"""Configurations for tests."""
3import subprocess
4import unittest.mock as mock
5import pytest
6
7from charmhelpers.core.host import (
8 CompareHostReleases,
9 lsb_release,
10)
11
12@pytest.fixture
13def mock_layers(monkeypatch):
14 """Layers mock."""
15 import sys
16 sys.modules['charms.layer'] = mock.Mock()
17 sys.modules['reactive'] = mock.Mock()
18
19 # Mock any functions in layers that need to be mocked here
20 def options(layer):
21 # mock options for layers here
22 if layer == 'example-layer':
23 options = {'port': 9999}
24 return options
25 else:
26 return None
27
28 monkeypatch.setattr('AdvancedRoutingHelper.layer.options', options)
29
30
31@pytest.fixture
32def mock_hookenv_config(monkeypatch):
33 """Hookenv mock."""
34 import yaml
35
36 def mock_config():
37 cfg = {}
38 yml = yaml.load(open('./config.yaml'))
39
40 # Load all defaults
41 for key, value in yml['options'].items():
42 cfg[key] = value['default']
43
44 # Manually add cfg from other layers
45 # cfg['my-other-layer'] = 'mock'
46 return cfg
47
48 monkeypatch.setattr('AdvancedRoutingHelper.hookenv.config', mock_config)
49
50
51@pytest.fixture
52def mock_remote_unit(monkeypatch):
53 """Remote unit mock."""
54 monkeypatch.setattr('AdvancedRoutingHelper.hookenv.remote_unit', lambda: 'unit-mock/0')
55
56
57@pytest.fixture
58def mock_charm_dir(monkeypatch):
59 """Charm dir mock."""
60 monkeypatch.setattr('AdvancedRoutingHelper.hookenv.charm_dir', lambda: '/mock/charm/dir')
61
62
63@pytest.fixture
64def advanced_routing_helper(tmpdir, mock_hookenv_config, mock_charm_dir, monkeypatch):
65 """Routing fixture."""
66 from AdvancedRoutingHelper import AdvancedRoutingHelper
67 helper = AdvancedRoutingHelper
68
69 monkeypatch.setattr('AdvancedRoutingHelper.AdvancedRoutingHelper', lambda: helper)
70
71 return helper
72
73@pytest.fixture
74def mock_check_call(monkeypatch):
75 """Requests.get() mocked to return {'mock_key':'mock_response'}."""
76
77 def mock_get(*args, **kwargs):
78 return True
79
80 monkeypatch.setattr(subprocess, "check_call", mock_get)
0\ No newline at end of file81\ No newline at end of file
diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt
1new file mode 10064482new file mode 100644
index 0000000..566d7c1
--- /dev/null
+++ b/tests/unit/requirements.txt
@@ -0,0 +1,8 @@
1# Include python requirements here
2charmhelpers
3charms.reactive
4mock
5pytest
6pytest-cov
7os-client-config
8os-testr>=0.4.1
0\ No newline at end of file9\ No newline at end of file
diff --git a/tests/unit/test_AdvancedRoutingHelper.py b/tests/unit/test_AdvancedRoutingHelper.py
1new file mode 10064410new file mode 100644
index 0000000..c24d1dc
--- /dev/null
+++ b/tests/unit/test_AdvancedRoutingHelper.py
@@ -0,0 +1,154 @@
1import os
2import pytest
3import shutil
4import subprocess
5
6from unittest import mock
7
8import RoutingValidator
9
10class TestAdvancedRoutingHelper():
11 """Main test class."""
12
13 test_dir = '/tmp/test/charm-advanced-routing/' # trailing slash
14 test_ifup_path = test_dir + 'symlink_test/ifup/'
15 test_ifdown_path = test_dir + 'symlink_test/ifdown/'
16 test_netplanup_path = test_dir + 'symlink_test/netplanup/'
17 test_netplandown_path = test_dir + 'symlink_test/netplandown/'
18 test_script = 'test-script'
19
20 def setup_class(self):
21 try:
22 shutil.rmtree(self.test_dir)
23 except:
24 pass
25
26 os.makedirs(self.test_dir)
27 os.makedirs(self.test_ifdown_path)
28 os.makedirs(self.test_ifup_path)
29 os.makedirs(self.test_netplandown_path)
30 os.makedirs(self.test_netplanup_path)
31
32 def test_pytest(self, advanced_routing_helper):
33 """Simple pytest sanity test."""
34 assert True
35
36 def test_constructor(self, advanced_routing_helper):
37 """No test required"""
38 pass
39
40 def test_pre_setup(self, advanced_routing_helper):
41 """Test pre_setup"""
42
43 test_obj = advanced_routing_helper
44
45 test_obj.common_location = self.test_dir
46 test_obj.if_script = self.test_script
47 test_obj.policy_routing_service_path = self.test_dir
48
49 try:
50 os.remove(test_obj.policy_routing_service_path + 'charm-pre-install-policy-routing.service')
51 except:
52 pass
53
54 test_obj.pre_setup(test_obj)
55
56 uppath = test_obj.common_location + 'if-up/'
57 downpath = test_obj.common_location + 'if-down/'
58
59 assert os.path.exists(uppath)
60 assert os.path.exists(downpath)
61
62 assert test_obj.ifup_path == uppath + test_obj.if_script
63 assert test_obj.ifdown_path == downpath + test_obj.if_script
64
65 # touch file to cause the exception
66 try:
67 with open(test_obj.policy_routing_service_path + 'charm-pre-install-policy-routing.service', "w+") as f:
68 f.write('dont care\n')
69 except:
70 pass # dont care
71
72 with pytest.raises(Exception):
73 test_obj.pre_setup(test_obj)
74
75 def test_post_setup(self, advanced_routing_helper):
76 """Test post_setup"""
77 #test_obj = advanced_routing_helper
78 assert True
79
80 def test_setup(self, advanced_routing_helper):
81 """Test setup"""
82
83 def noop():
84 pass
85
86 test_obj = advanced_routing_helper
87 test_obj.common_location = self.test_dir
88 test_obj.if_script = self.test_script
89 test_obj.ifup_path = '{}if-up/{}'.format(self.test_dir, self.test_script)
90 test_obj.ifdown_path = '{}if-down/{}'.format(self.test_dir, self.test_script)
91
92 test_obj.post_setup = noop
93 RoutingValidator.RoutingConfigValidator.__init__ = mock.Mock(return_value=None)
94 test_obj.setup(test_obj)
95
96 assert os.path.exists(test_obj.ifup_path)
97 assert os.path.exists(test_obj.ifdown_path)
98
99 assert True
100
101 def test_apply_routes(self, advanced_routing_helper):
102 """Test post_setup"""
103 #test_obj = advanced_routing_helper
104 assert True
105
106 def test_remove_routes(self, advanced_routing_helper, mock_check_call):
107 """Test post_setup"""
108 test_obj = advanced_routing_helper
109
110 with mock.patch('charmhelpers.core.host.lsb_release') as lsbrelBionic:
111 test_obj.netplan_up_path = self.test_netplanup_path
112 test_obj.netplan_down_path = self.test_netplandown_path
113 lsbrelBionic.return_value = "bionic"
114 test_obj.remove_routes(test_obj)
115
116 with mock.patch('charmhelpers.core.host.lsb_release') as lsbrelArtful:
117 test_obj.net_tools_up_path = self.test_ifup_path
118 test_obj.net_tools_down_path = self.test_ifdown_path
119 lsbrelArtful.return_value = "artful"
120 test_obj.remove_routes(test_obj)
121
122 assert os.path.exists(test_obj.ifup_path) == False
123 assert os.path.exists(test_obj.ifdown_path) == False
124
125 assert True
126
127 def test_symlink_force(self, advanced_routing_helper):
128 """Test symlink_force"""
129 test_obj = advanced_routing_helper
130
131 target = self.test_dir + 'testfile'
132 link = self.test_dir + 'testlink'
133
134 try:
135 os.remove(target)
136 except:
137 pass # dont care
138
139 # touch target file to link to
140 try:
141 with open(target, "w+") as f:
142 f.write('dont care\n')
143 except:
144 pass # dont care
145
146 assert os.path.exists(target)
147
148 # link it
149 test_obj.symlink_force(test_obj, target, link)
150 assert os.path.exists(link)
151
152 # link it again
153 test_obj.symlink_force(test_obj, target, link)
154 assert os.path.exists(link)
diff --git a/tox.ini b/tox.ini
0new file mode 100644155new file mode 100644
index 0000000..5351ddd
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,46 @@
1[tox]
2skipsdist=True
3envlist = unit, functional
4skip_missing_interpreters = True
5
6[testenv]
7basepython = python3
8setenv =
9 PYTHONPATH = .
10
11[testenv:unit]
12commands = pytest -o log_cli=true --full-trace -v --ignore {toxinidir}/tests/functional
13deps = -r{toxinidir}/tests/unit/requirements.txt
14 -r{toxinidir}/requirements.txt
15setenv = PYTHONPATH={toxinidir}/lib
16whitelist_externals = pytest
17
18[testenv:functional]
19passenv =
20 HOME
21 JUJU_REPOSITORY
22 PATH
23 PYTEST_KEEP_MODEL
24 PYTEST_CLOUD_NAME
25 PYTEST_CLOUD_REGION
26commands = pytest -v --ignore {toxinidir}/tests/unit
27deps = -r{toxinidir}/tests/functional/requirements.txt
28 -r{toxinidir}/requirements.txt
29
30[testenv:lint]
31commands = flake8
32deps =
33 flake8
34 flake8-docstrings
35 flake8-import-order
36 pep8-naming
37 flake8-colors
38
39[flake8]
40exclude =
41 .git,
42 __pycache__,
43 .tox,
44ignore=D401,C901
45max-line-length = 120
46max-complexity = 10

Subscribers

People subscribed via source and target branches

to all changes: