Merge ~ec0/juju-lint:modular into juju-lint:master
- Git
- lp:~ec0/juju-lint
- modular
- Merge into master
Status: | Merged |
---|---|
Approved by: | James Troup |
Approved revision: | 7e8d5b71a41235ed8a98f19bb33962df74b3a26f |
Merged at revision: | 020be3e78f7964d1ce6934e82c690c0e1d4462dd |
Proposed branch: | ~ec0/juju-lint:modular |
Merge into: | juju-lint:master |
Diff against target: |
3252 lines (+2334/-527) 27 files modified
.gitignore (+14/-0) .python-version (+1/-0) Pipfile (+17/-0) Pipfile.lock (+199/-0) README.md (+30/-7) contrib/canonical-rules.yaml (+46/-0) dev/null (+0/-457) example-lint-rules.yaml (+34/-0) juju-lint (+1/-1) jujulint/__init__.py (+1/-0) jujulint/cli.py (+136/-0) jujulint/cloud.py (+376/-0) jujulint/config.py (+101/-0) jujulint/config_default.yaml (+34/-0) jujulint/k8s.py (+65/-0) jujulint/lint.py (+882/-0) jujulint/logging.py (+106/-0) jujulint/openstack.py (+71/-0) jujulint/util.py (+38/-0) requirements.txt (+6/-0) setup.py (+12/-11) snap/snapcraft.yaml (+17/-13) tests/conftest.py (+60/-0) tests/requirements.txt (+11/-2) tests/test_cli.py (+14/-0) tests/test_jujulint.py (+28/-25) tox.ini (+34/-11) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
James Troup | Pending | ||
Review via email: mp+384517@code.launchpad.net |
Commit message
This is a large merge with the following goals
* Move to snap-only distribution and usage
* Support remote-auditing live clouds via SSH (Fabric2)
* Support multiple cloud types (OpenStack, Kubernetes)
* Support auditing configuration in addition to the current support for auditing placement
* Add support for XDG-compliant configuration file locations
* Merge in additional configuration checks from ua-reviewkit and add support for regex and basic value tests such as greater-
* Move to pytest for testing and add coverage reporting to eventually get good test coverage
* Move to setuptools_scm (and have the snap track this) to ensure consistent versioning following PEP440
All requirements have also been updated and a Pipfile and python-version file for using with Pipenv and pyenv have been added. setup.py has been adjusted to provide an entry-point instead of the previous symlink, and setuptools can be used to install an entrypoint binary, which the snap has been tested with successfully.
Description of the change
This is a large merge with the following goals
* Move to snap-only distribution and usage
* Support remote-auditing live clouds via SSH (Fabric2)
* Support multiple cloud types (OpenStack, Kubernetes)
* Support auditing configuration in addition to the current support for auditing placement
* Add support for XDG-compliant configuration file locations
* Merge in additional configuration checks from ua-reviewkit and add support for regex and basic value tests such as greater-
* Move to pytest for testing and add coverage reporting to eventually get good test coverage
* Move to setuptools_scm (and have the snap track this) to ensure consistent versioning following PEP440
All requirements have also been updated and a Pipfile and python-version file for using with Pipenv and pyenv have been added. setup.py has been adjusted to provide an entry-point instead of the previous symlink, and setuptools can be used to install an entrypoint binary, which the snap has been tested with successfully.
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
Unable to determine commit message from repository - please click "Set commit message" and enter the commit message manually.
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
Change successfully merged at revision 020be3e78f7964d
Preview Diff
1 | diff --git a/.gitignore b/.gitignore |
2 | index 60a29ef..1f49ca6 100644 |
3 | --- a/.gitignore |
4 | +++ b/.gitignore |
5 | @@ -4,6 +4,17 @@ |
6 | .tox/ |
7 | juju_lint.egg-info/ |
8 | dist/ |
9 | +tests/report/ |
10 | + |
11 | +# python artefacts |
12 | +__pycache__ |
13 | +*.pyc |
14 | +.eggs |
15 | +*.egg-info |
16 | + |
17 | +# test artefacts |
18 | +tests/report |
19 | +.coverage |
20 | |
21 | # debuild cruft |
22 | debian/files |
23 | @@ -14,3 +25,6 @@ parts/ |
24 | prime/ |
25 | snap/.snapcraft/ |
26 | stage/ |
27 | + |
28 | +# runtime |
29 | +output/ |
30 | diff --git a/.python-version b/.python-version |
31 | new file mode 100644 |
32 | index 0000000..a08ffae |
33 | --- /dev/null |
34 | +++ b/.python-version |
35 | @@ -0,0 +1 @@ |
36 | +3.8.2 |
37 | diff --git a/3rdparty/attrs-18.1.0.tar.gz b/3rdparty/attrs-18.1.0.tar.gz |
38 | deleted file mode 100644 |
39 | index 0d7c713..0000000 |
40 | Binary files a/3rdparty/attrs-18.1.0.tar.gz and /dev/null differ |
41 | diff --git a/3rdparty/flake8-3.5.0.tar.gz b/3rdparty/flake8-3.5.0.tar.gz |
42 | deleted file mode 100644 |
43 | index 78e01fd..0000000 |
44 | Binary files a/3rdparty/flake8-3.5.0.tar.gz and /dev/null differ |
45 | diff --git a/3rdparty/pycodestyle-2.3.1.tar.gz b/3rdparty/pycodestyle-2.3.1.tar.gz |
46 | deleted file mode 100644 |
47 | index 76de222..0000000 |
48 | Binary files a/3rdparty/pycodestyle-2.3.1.tar.gz and /dev/null differ |
49 | diff --git a/3rdparty/pyflakes-2.0.0.tar.gz b/3rdparty/pyflakes-2.0.0.tar.gz |
50 | deleted file mode 100644 |
51 | index 95d6d3e..0000000 |
52 | Binary files a/3rdparty/pyflakes-2.0.0.tar.gz and /dev/null differ |
53 | diff --git a/MANIFEST.in b/MANIFEST.in |
54 | deleted file mode 100644 |
55 | index 3bb71b3..0000000 |
56 | --- a/MANIFEST.in |
57 | +++ /dev/null |
58 | @@ -1,2 +0,0 @@ |
59 | -# Additional files to include in any distutils source packages. |
60 | -include debian/changelog # required by setup.py |
61 | diff --git a/Pipfile b/Pipfile |
62 | new file mode 100644 |
63 | index 0000000..d2acf80 |
64 | --- /dev/null |
65 | +++ b/Pipfile |
66 | @@ -0,0 +1,17 @@ |
67 | +[[source]] |
68 | +url = "https://pypi.python.org/simple" |
69 | +verify_ssl = true |
70 | +name = "pypi" |
71 | + |
72 | +[packages] |
73 | +attrs = ">=18.1.0" |
74 | +colorlog = ">=4.1.0" |
75 | +confuse = ">=1.1.0" |
76 | +fabric2 = ">=2.5.0" |
77 | +setuptools = ">=46.1.3" |
78 | +PyYAML = ">=5.3.1" |
79 | + |
80 | +[dev-packages] |
81 | + |
82 | +[requires] |
83 | +python_version = "3.6" |
84 | diff --git a/Pipfile.lock b/Pipfile.lock |
85 | new file mode 100644 |
86 | index 0000000..c43f7b6 |
87 | --- /dev/null |
88 | +++ b/Pipfile.lock |
89 | @@ -0,0 +1,199 @@ |
90 | +{ |
91 | + "_meta": { |
92 | + "hash": { |
93 | + "sha256": "e1534fcda4a023c68f70df4e53b34a9bafcd9ec48c4f8d93e20dda25b3c43cd4" |
94 | + }, |
95 | + "pipfile-spec": 6, |
96 | + "requires": { |
97 | + "python_version": "3.6" |
98 | + }, |
99 | + "sources": [ |
100 | + { |
101 | + "name": "pypi", |
102 | + "url": "https://pypi.python.org/simple", |
103 | + "verify_ssl": true |
104 | + } |
105 | + ] |
106 | + }, |
107 | + "default": { |
108 | + "attrs": { |
109 | + "hashes": [ |
110 | + "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", |
111 | + "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" |
112 | + ], |
113 | + "index": "pypi", |
114 | + "version": "==19.3.0" |
115 | + }, |
116 | + "bcrypt": { |
117 | + "hashes": [ |
118 | + "sha256:0258f143f3de96b7c14f762c770f5fc56ccd72f8a1857a451c1cd9a655d9ac89", |
119 | + "sha256:0b0069c752ec14172c5f78208f1863d7ad6755a6fae6fe76ec2c80d13be41e42", |
120 | + "sha256:19a4b72a6ae5bb467fea018b825f0a7d917789bcfe893e53f15c92805d187294", |
121 | + "sha256:5432dd7b34107ae8ed6c10a71b4397f1c853bd39a4d6ffa7e35f40584cffd161", |
122 | + "sha256:6305557019906466fc42dbc53b46da004e72fd7a551c044a827e572c82191752", |
123 | + "sha256:69361315039878c0680be456640f8705d76cb4a3a3fe1e057e0f261b74be4b31", |
124 | + "sha256:6fe49a60b25b584e2f4ef175b29d3a83ba63b3a4df1b4c0605b826668d1b6be5", |
125 | + "sha256:74a015102e877d0ccd02cdeaa18b32aa7273746914a6c5d0456dd442cb65b99c", |
126 | + "sha256:763669a367869786bb4c8fcf731f4175775a5b43f070f50f46f0b59da45375d0", |
127 | + "sha256:8b10acde4e1919d6015e1df86d4c217d3b5b01bb7744c36113ea43d529e1c3de", |
128 | + "sha256:9fe92406c857409b70a38729dbdf6578caf9228de0aef5bc44f859ffe971a39e", |
129 | + "sha256:a190f2a5dbbdbff4b74e3103cef44344bc30e61255beb27310e2aec407766052", |
130 | + "sha256:a595c12c618119255c90deb4b046e1ca3bcfad64667c43d1166f2b04bc72db09", |
131 | + "sha256:c9457fa5c121e94a58d6505cadca8bed1c64444b83b3204928a866ca2e599105", |
132 | + "sha256:cb93f6b2ab0f6853550b74e051d297c27a638719753eb9ff66d1e4072be67133", |
133 | + "sha256:ce4e4f0deb51d38b1611a27f330426154f2980e66582dc5f438aad38b5f24fc1", |
134 | + "sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7", |
135 | + "sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc" |
136 | + ], |
137 | + "version": "==3.1.7" |
138 | + }, |
139 | + "cffi": { |
140 | + "hashes": [ |
141 | + "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff", |
142 | + "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b", |
143 | + "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac", |
144 | + "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0", |
145 | + "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384", |
146 | + "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26", |
147 | + "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6", |
148 | + "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b", |
149 | + "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e", |
150 | + "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd", |
151 | + "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2", |
152 | + "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66", |
153 | + "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc", |
154 | + "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8", |
155 | + "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55", |
156 | + "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4", |
157 | + "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5", |
158 | + "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d", |
159 | + "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78", |
160 | + "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa", |
161 | + "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793", |
162 | + "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f", |
163 | + "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a", |
164 | + "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f", |
165 | + "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30", |
166 | + "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f", |
167 | + "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3", |
168 | + "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c" |
169 | + ], |
170 | + "version": "==1.14.0" |
171 | + }, |
172 | + "colorlog": { |
173 | + "hashes": [ |
174 | + "sha256:30aaef5ab2a1873dec5da38fd6ba568fa761c9fa10b40241027fa3edea47f3d2", |
175 | + "sha256:732c191ebbe9a353ec160d043d02c64ddef9028de8caae4cfa8bd49b6afed53e" |
176 | + ], |
177 | + "index": "pypi", |
178 | + "version": "==4.1.0" |
179 | + }, |
180 | + "confuse": { |
181 | + "hashes": [ |
182 | + "sha256:adc1979ea6f4c0dd3d6fe06020c189843a649082ab8f6fb54db16f4ac5e5e1da" |
183 | + ], |
184 | + "index": "pypi", |
185 | + "version": "==1.1.0" |
186 | + }, |
187 | + "cryptography": { |
188 | + "hashes": [ |
189 | + "sha256:091d31c42f444c6f519485ed528d8b451d1a0c7bf30e8ca583a0cac44b8a0df6", |
190 | + "sha256:18452582a3c85b96014b45686af264563e3e5d99d226589f057ace56196ec78b", |
191 | + "sha256:1dfa985f62b137909496e7fc182dac687206d8d089dd03eaeb28ae16eec8e7d5", |
192 | + "sha256:1e4014639d3d73fbc5ceff206049c5a9a849cefd106a49fa7aaaa25cc0ce35cf", |
193 | + "sha256:22e91636a51170df0ae4dcbd250d318fd28c9f491c4e50b625a49964b24fe46e", |
194 | + "sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b", |
195 | + "sha256:651448cd2e3a6bc2bb76c3663785133c40d5e1a8c1a9c5429e4354201c6024ae", |
196 | + "sha256:726086c17f94747cedbee6efa77e99ae170caebeb1116353c6cf0ab67ea6829b", |
197 | + "sha256:844a76bc04472e5135b909da6aed84360f522ff5dfa47f93e3dd2a0b84a89fa0", |
198 | + "sha256:88c881dd5a147e08d1bdcf2315c04972381d026cdb803325c03fe2b4a8ed858b", |
199 | + "sha256:96c080ae7118c10fcbe6229ab43eb8b090fccd31a09ef55f83f690d1ef619a1d", |
200 | + "sha256:a0c30272fb4ddda5f5ffc1089d7405b7a71b0b0f51993cb4e5dbb4590b2fc229", |
201 | + "sha256:bb1f0281887d89617b4c68e8db9a2c42b9efebf2702a3c5bf70599421a8623e3", |
202 | + "sha256:c447cf087cf2dbddc1add6987bbe2f767ed5317adb2d08af940db517dd704365", |
203 | + "sha256:c4fd17d92e9d55b84707f4fd09992081ba872d1a0c610c109c18e062e06a2e55", |
204 | + "sha256:d0d5aeaedd29be304848f1c5059074a740fa9f6f26b84c5b63e8b29e73dfc270", |
205 | + "sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e", |
206 | + "sha256:e993468c859d084d5579e2ebee101de8f5a27ce8e2159959b6673b418fd8c785", |
207 | + "sha256:f118a95c7480f5be0df8afeb9a11bd199aa20afab7a96bcf20409b411a3a85f0" |
208 | + ], |
209 | + "version": "==2.9.2" |
210 | + }, |
211 | + "fabric2": { |
212 | + "hashes": [ |
213 | + "sha256:29edd7848420df589a49743394a0ae6874ccb6a9fe6413c0076d42cc290dcad6", |
214 | + "sha256:8838d9641fd4e95bfc2568aa16fc683a600de860ac52a1dc9675a4db3c6cef7c" |
215 | + ], |
216 | + "index": "pypi", |
217 | + "version": "==2.5.0" |
218 | + }, |
219 | + "invoke": { |
220 | + "hashes": [ |
221 | + "sha256:87b3ef9d72a1667e104f89b159eaf8a514dbf2f3576885b2bbdefe74c3fb2132", |
222 | + "sha256:93e12876d88130c8e0d7fd6618dd5387d6b36da55ad541481dfa5e001656f134", |
223 | + "sha256:de3f23bfe669e3db1085789fd859eb8ca8e0c5d9c20811e2407fa042e8a5e15d" |
224 | + ], |
225 | + "version": "==1.4.1" |
226 | + }, |
227 | + "paramiko": { |
228 | + "hashes": [ |
229 | + "sha256:920492895db8013f6cc0179293147f830b8c7b21fdfc839b6bad760c27459d9f", |
230 | + "sha256:9c980875fa4d2cb751604664e9a2d0f69096643f5be4db1b99599fe114a97b2f" |
231 | + ], |
232 | + "version": "==2.7.1" |
233 | + }, |
234 | + "pycparser": { |
235 | + "hashes": [ |
236 | + "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", |
237 | + "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" |
238 | + ], |
239 | + "version": "==2.20" |
240 | + }, |
241 | + "pynacl": { |
242 | + "hashes": [ |
243 | + "sha256:06cbb4d9b2c4bd3c8dc0d267416aaed79906e7b33f114ddbf0911969794b1cc4", |
244 | + "sha256:11335f09060af52c97137d4ac54285bcb7df0cef29014a1a4efe64ac065434c4", |
245 | + "sha256:2fe0fc5a2480361dcaf4e6e7cea00e078fcda07ba45f811b167e3f99e8cff574", |
246 | + "sha256:30f9b96db44e09b3304f9ea95079b1b7316b2b4f3744fe3aaecccd95d547063d", |
247 | + "sha256:511d269ee845037b95c9781aa702f90ccc36036f95d0f31373a6a79bd8242e25", |
248 | + "sha256:537a7ccbea22905a0ab36ea58577b39d1fa9b1884869d173b5cf111f006f689f", |
249 | + "sha256:54e9a2c849c742006516ad56a88f5c74bf2ce92c9f67435187c3c5953b346505", |
250 | + "sha256:757250ddb3bff1eecd7e41e65f7f833a8405fede0194319f87899690624f2122", |
251 | + "sha256:7757ae33dae81c300487591c68790dfb5145c7d03324000433d9a2c141f82af7", |
252 | + "sha256:7c6092102219f59ff29788860ccb021e80fffd953920c4a8653889c029b2d420", |
253 | + "sha256:8122ba5f2a2169ca5da936b2e5a511740ffb73979381b4229d9188f6dcb22f1f", |
254 | + "sha256:9c4a7ea4fb81536c1b1f5cc44d54a296f96ae78c1ebd2311bd0b60be45a48d96", |
255 | + "sha256:cd401ccbc2a249a47a3a1724c2918fcd04be1f7b54eb2a5a71ff915db0ac51c6", |
256 | + "sha256:d452a6746f0a7e11121e64625109bc4468fc3100452817001dbe018bb8b08514", |
257 | + "sha256:ea6841bc3a76fa4942ce00f3bda7d436fda21e2d91602b9e21b7ca9ecab8f3ff", |
258 | + "sha256:f8851ab9041756003119368c1e6cd0b9c631f46d686b3904b18c0139f4419f80" |
259 | + ], |
260 | + "version": "==1.4.0" |
261 | + }, |
262 | + "pyyaml": { |
263 | + "hashes": [ |
264 | + "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", |
265 | + "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", |
266 | + "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", |
267 | + "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", |
268 | + "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", |
269 | + "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", |
270 | + "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", |
271 | + "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", |
272 | + "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", |
273 | + "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", |
274 | + "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" |
275 | + ], |
276 | + "index": "pypi", |
277 | + "version": "==5.3.1" |
278 | + }, |
279 | + "six": { |
280 | + "hashes": [ |
281 | + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", |
282 | + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" |
283 | + ], |
284 | + "version": "==1.15.0" |
285 | + } |
286 | + }, |
287 | + "develop": {} |
288 | +} |
289 | diff --git a/README.md b/README.md |
290 | index 534395d..00d737d 100644 |
291 | --- a/README.md |
292 | +++ b/README.md |
293 | @@ -1,21 +1,42 @@ |
294 | = Juju Lint = |
295 | |
296 | -/!\ This is alpha software and backwards incompatible changes are expected. |
297 | - |
298 | == Introduction == |
299 | |
300 | -This is intended to be run against a yaml dump of Juju status, which can be |
301 | -generated as follows: |
302 | +This is intended to be run against a yaml dump of Juju status, a YAML dump of |
303 | +a juju bundle or a remote cloud or clouds via SSH. |
304 | + |
305 | +To generate a status if you just want to audit placement: |
306 | |
307 | juju status --format yaml > status.yaml |
308 | |
309 | +For auditing configuration, you would want: |
310 | + |
311 | + juju export-bundle > bundle.yaml |
312 | + |
313 | Then run `juju-lint` (using a rules file of `lint-rules.yaml`): |
314 | |
315 | - ./juju-lint status.yaml |
316 | + juju-lint -f status.yaml (or bundle.yaml) |
317 | + |
318 | +You can also enable additional checks for specific cloud types by specifying |
319 | +the cloud type with `-t` as such: |
320 | + |
321 | + juju-lint -f bundle.yaml -t openstack |
322 | + |
323 | +For remote or mass audits, you can remote audit clouds via SSH. |
324 | +To do this, you will need to add the clouds to your config file in: |
325 | + |
326 | + ~/.config/juju-lint/config.yaml |
327 | + |
328 | +See the example config file in the `jujulint` directory of this repo. |
329 | +This tool will use your existing SSH keys, SSH agent, and SSH config. |
330 | +If you are running from the snap, you will need to connect the `ssh-keys` |
331 | +interface in order to grant access to your SSH configuation. |
332 | |
333 | To use a different rules file: |
334 | |
335 | - ./juju-lint -c my-rules.yaml status.yaml |
336 | + juju-lint -c my-rules.yaml |
337 | + |
338 | +For all other options, consult `juju-lint --help` |
339 | |
340 | == Rules File == |
341 | |
342 | @@ -27,10 +48,12 @@ Supported top-level options for your rules file: |
343 | 2. `known charms` - all primary charms should be in this list. |
344 | 3. `operations [mandatory|optional|subordinate]` |
345 | 4. `openstack [mandatory|optional|subordinate]` |
346 | + 5. `config` - application configuration auditing |
347 | + 5. `[openstack|kubernetes] config` - config auditing for specific cloud types. |
348 | |
349 | == License == |
350 | |
351 | -Copyright 2018 Canonical Limited. |
352 | +Copyright 2020 Canonical Limited. |
353 | License granted by Canonical Limited. |
354 | |
355 | This program is free software: you can redistribute it and/or modify |
356 | diff --git a/contrib/canonical-openstack-rules.yaml b/contrib/canonical-rules.yaml |
357 | similarity index 75% |
358 | rename from contrib/canonical-openstack-rules.yaml |
359 | rename to contrib/canonical-rules.yaml |
360 | index 1dc8830..3a3f666 100644 |
361 | --- a/contrib/canonical-openstack-rules.yaml |
362 | +++ b/contrib/canonical-rules.yaml |
363 | @@ -1,3 +1,49 @@ |
364 | +kubernetes config: |
365 | + kubernetes-master: |
366 | + authorization-mode: |
367 | + eq: "RBAC,Node" |
368 | + canal: |
369 | + cidr: |
370 | + isset: false |
371 | + |
372 | +openstack config: |
373 | + neutron-api: |
374 | + path-mtu: |
375 | + eq: 9000 |
376 | + global-physnet-mtu: |
377 | + eq: 9000 |
378 | + nova-compute: |
379 | + live-migration-permit-auto-converge: |
380 | + eq: true |
381 | + live-migration-permit-post-copy: |
382 | + eq: true |
383 | + cpu-model: |
384 | + isset: true |
385 | + percona-cluster: |
386 | + innodb-buffer-pool-size: |
387 | + gte: 6G |
388 | + max-connections: |
389 | + gte: 2000 |
390 | + mysql-innodb-cluster: |
391 | + innodb-buffer-pool-size: |
392 | + gte: 6G |
393 | + max-connections: |
394 | + gte: 2000 |
395 | + rabbitmq-server: |
396 | + cluster-partition-handling: |
397 | + eq: "pause_minority" |
398 | + keystone: |
399 | + token-expiration: |
400 | + gte: 86400 |
401 | + |
402 | +config: |
403 | + hacluster: |
404 | + cluster_count: |
405 | + gte: 3 |
406 | + ntp: |
407 | + auto_peers: |
408 | + eq: true |
409 | + |
410 | subordinates: |
411 | telegraf: |
412 | where: all except prometheus # and prometheus-ceph-exporter and prometheus-openstack-exporter |
413 | diff --git a/debian/changelog b/debian/changelog |
414 | deleted file mode 100644 |
415 | index c45b19e..0000000 |
416 | --- a/debian/changelog |
417 | +++ /dev/null |
418 | @@ -1,11 +0,0 @@ |
419 | -juju-lint (1.0.0.dev2) xenial; urgency=medium |
420 | - |
421 | - * Add canonical-openstack-rules.yaml in contrib directory. |
422 | - |
423 | - -- Tom Haddon <tom.haddon@canonical.com> Thu, 02 Aug 2018 18:08:27 +0100 |
424 | - |
425 | -juju-lint (1.0.0.dev1) xenial; urgency=medium |
426 | - |
427 | - * Initial release. |
428 | - |
429 | - -- Stuart Bishop (Work) <stuart.bishop@canonical.com> Tue, 24 Jul 2018 17:36:40 +0700 |
430 | diff --git a/debian/compat b/debian/compat |
431 | deleted file mode 100644 |
432 | index 7f8f011..0000000 |
433 | --- a/debian/compat |
434 | +++ /dev/null |
435 | @@ -1 +0,0 @@ |
436 | -7 |
437 | diff --git a/debian/control b/debian/control |
438 | deleted file mode 100644 |
439 | index a434417..0000000 |
440 | --- a/debian/control |
441 | +++ /dev/null |
442 | @@ -1,19 +0,0 @@ |
443 | -Source: juju-lint |
444 | -Section: admin |
445 | -Priority: extra |
446 | -Maintainer: Juju Linters <juju@lists.ubuntu.com> |
447 | -Build-Depends: |
448 | - debhelper, |
449 | - python3-all, |
450 | - python3-setuptools |
451 | -Standards-Version: 3.9.3 |
452 | -X-Python-Version: >= 3.4 |
453 | - |
454 | -Package: juju-lint |
455 | -Architecture: all |
456 | -Depends: |
457 | - ${python3:Depends}, |
458 | - ${misc:Depends}, |
459 | - python3-yaml, |
460 | - python3-attr (>= 18.1) |
461 | -Description: Linter for Juju models to compare deployments with configurable policy |
462 | diff --git a/debian/copyright b/debian/copyright |
463 | deleted file mode 100644 |
464 | index 4270311..0000000 |
465 | --- a/debian/copyright |
466 | +++ /dev/null |
467 | @@ -1,25 +0,0 @@ |
468 | -Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ |
469 | -Upstream-Name: juju-lint |
470 | -Source: https://code.launchpad.net/juju-lint |
471 | - |
472 | -Files: * |
473 | -Copyright: 2018 Canonical Limited <juju@lists.ubuntu.com> |
474 | -License: GPL-3 |
475 | - Copyright 2018 Canonical Limited. |
476 | - License granted by Canonical Limited. |
477 | - . |
478 | - This program is free software: you can redistribute it and/or modify |
479 | - it under the terms of the GNU General Public License version 3, as |
480 | - published by the Free Software Foundation. |
481 | - . |
482 | - This program is distributed in the hope that it will be useful, but |
483 | - WITHOUT ANY WARRANTY; without even the implied warranties of |
484 | - MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
485 | - PURPOSE. See the GNU General Public License for more details. |
486 | - . |
487 | - You should have received a copy of the GNU General Public License |
488 | - along with this program. If not, see <http://www.gnu.org/licenses/>. |
489 | - . |
490 | - On Debian systems, the full text of the GNU General Public |
491 | - License version 3 can be found in the file |
492 | - `/usr/share/common-licenses/GPL-3'. |
493 | diff --git a/debian/install b/debian/install |
494 | deleted file mode 100644 |
495 | index 2def499..0000000 |
496 | --- a/debian/install |
497 | +++ /dev/null |
498 | @@ -1 +0,0 @@ |
499 | -contrib/* usr/share/juju-lint/contrib |
500 | diff --git a/debian/rules b/debian/rules |
501 | deleted file mode 100755 |
502 | index 43797a6..0000000 |
503 | --- a/debian/rules |
504 | +++ /dev/null |
505 | @@ -1,15 +0,0 @@ |
506 | -#!/usr/bin/make -f |
507 | -# -*- makefile -*- |
508 | -# Sample debian/rules that uses debhelper. |
509 | -# This file was originally written by Joey Hess and Craig Small. |
510 | -# As a special exception, when this file is copied by dh-make into a |
511 | -# dh-make output file, you may use that output file without restriction. |
512 | -# This special exception was added by Craig Small in version 0.37 of dh-make. |
513 | - |
514 | -# Uncomment this to turn on verbose mode. |
515 | -#export DH_VERBOSE=1 |
516 | - |
517 | -export PYBUILD_NAME=juju_lint |
518 | - |
519 | -%: |
520 | - dh $@ --with=python3 --without-python2 --buildsystem=pybuild |
521 | diff --git a/example-lint-rules.yaml b/example-lint-rules.yaml |
522 | index 70776d4..e0c6830 100644 |
523 | --- a/example-lint-rules.yaml |
524 | +++ b/example-lint-rules.yaml |
525 | @@ -1,5 +1,39 @@ |
526 | +--- |
527 | +config: |
528 | + example-charm: |
529 | + example-setting: |
530 | + eq: true |
531 | + |
532 | subordinates: |
533 | telegraf: |
534 | where: all |
535 | landscape-client: |
536 | where: all |
537 | + ntp: |
538 | + where: all |
539 | + |
540 | +# the below "example" cloud depicts an application running on apache2 |
541 | +# which relies on mysql and rabbitmq, and should have ntp deployed |
542 | +# across all notes. etcd may also be deployed on some machines. |
543 | +example mandatory: &example-mandatory-charms |
544 | + - apache2 |
545 | + |
546 | +example mandatory deps: &example-mandatory-deps |
547 | + - rabbitmq-server |
548 | + - mysql |
549 | + |
550 | +example mandatory subordinates: &example-mandatory-subs |
551 | + - ntp |
552 | + |
553 | +example optional charms: &example-optional-charms |
554 | + - etcd |
555 | + |
556 | +example charms: &openstack-charms |
557 | + - *example-mandatory-charms |
558 | + - *example-mandatory-deps |
559 | + - *example-mandatory-subs |
560 | + - *example-optional-charms |
561 | + |
562 | +known charms: |
563 | + - ubuntu |
564 | + - *example-charms |
565 | diff --git a/juju-lint b/juju-lint |
566 | index 6759e60..3df527a 120000 |
567 | --- a/juju-lint |
568 | +++ b/juju-lint |
569 | @@ -1 +1 @@ |
570 | -jujulint.py |
571 | \ No newline at end of file |
572 | +jujulint/cli.py |
573 | \ No newline at end of file |
574 | diff --git a/jujulint.py b/jujulint.py |
575 | deleted file mode 100755 |
576 | index 47806f3..0000000 |
577 | --- a/jujulint.py |
578 | +++ /dev/null |
579 | @@ -1,457 +0,0 @@ |
580 | -#!/usr/bin/env python3 |
581 | - |
582 | -# This file is part of juju-lint, a tool for validating that Juju |
583 | -# deloyments meet configurable site policies. |
584 | -# |
585 | -# Copyright 2018-2019 Canonical Limited. |
586 | -# License granted by Canonical Limited. |
587 | -# |
588 | -# This program is free software: you can redistribute it and/or modify |
589 | -# it under the terms of the GNU General Public License version 3, as |
590 | -# published by the Free Software Foundation. |
591 | -# |
592 | -# This program is distributed in the hope that it will be useful, but |
593 | -# WITHOUT ANY WARRANTY; without even the implied warranties of |
594 | -# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
595 | -# PURPOSE. See the GNU General Public License for more details. |
596 | -# |
597 | -# You should have received a copy of the GNU General Public License |
598 | -# along with this program. If not, see <http://www.gnu.org/licenses/>. |
599 | - |
600 | -import collections |
601 | -import logging |
602 | -import optparse |
603 | -import pprint |
604 | -import re |
605 | -import sys |
606 | - |
607 | -import yaml |
608 | - |
609 | -from attr import attrs, attrib |
610 | -import attr |
611 | - |
612 | -# TODO: |
613 | -# - tests |
614 | -# - non-OK statuses? |
615 | -# - missing relations for mandatory subordinates |
616 | -# - info mode, e.g. num of machines, version (e.g. look at ceph), architecture |
617 | - |
618 | - |
619 | -class InvalidCharmNameError(Exception): |
620 | - pass |
621 | - |
622 | - |
623 | -@attrs |
624 | -class ModelInfo(object): |
625 | - # Info obtained from juju status data |
626 | - charms = attrib(default=attr.Factory(set)) |
627 | - app_to_charm = attrib(default=attr.Factory(dict)) |
628 | - subs_on_machines = attrib(default=attr.Factory(dict)) |
629 | - apps_on_machines = attrib(default=attr.Factory(dict)) |
630 | - machines_to_az = attrib(default=attr.Factory(dict)) |
631 | - |
632 | - # Output of our linting |
633 | - missing_subs = attrib(default=attr.Factory(dict)) |
634 | - extraneous_subs = attrib(default=attr.Factory(dict)) |
635 | - duelling_subs = attrib(default=attr.Factory(dict)) |
636 | - az_unbalanced_apps = attrib(default=attr.Factory(dict)) |
637 | - |
638 | - |
639 | -def fubar(msg, exit_code=1): |
640 | - sys.stderr.write("E: %s\n" % (msg)) |
641 | - sys.exit(exit_code) |
642 | - |
643 | - |
644 | -def setup_logging(loglevel, logfile): |
645 | - logFormatter = logging.Formatter( |
646 | - fmt="%(asctime)s [%(levelname)s] %(message)s", |
647 | - datefmt="%Y-%m-%d %H:%M:%S") |
648 | - rootLogger = logging.getLogger() |
649 | - rootLogger.setLevel(loglevel) |
650 | - |
651 | - consoleHandler = logging.StreamHandler() |
652 | - consoleHandler.setFormatter(logFormatter) |
653 | - rootLogger.addHandler(consoleHandler) |
654 | - |
655 | - if logfile: |
656 | - try: |
657 | - fileLogger = logging.getLogger('file') |
658 | - # If we send output to the file logger specifically, don't propagate it |
659 | - # to the root logger as well to avoid duplicate output. So if we want |
660 | - # to only send logging output to the file, you would do this: |
661 | - # logging.getLogger('file').info("message for logfile only") |
662 | - # rather than this: |
663 | - # logging.info("message for console and logfile") |
664 | - fileLogger.propagate = False |
665 | - |
666 | - fileHandler = logging.FileHandler(logfile) |
667 | - fileHandler.setFormatter(logFormatter) |
668 | - rootLogger.addHandler(fileHandler) |
669 | - fileLogger.addHandler(fileHandler) |
670 | - except IOError: |
671 | - logging.error("Unable to write to logfile: {}".format(logfile)) |
672 | - |
673 | - |
674 | -def flatten_list(l): |
675 | - t = [] |
676 | - for i in l: |
677 | - if not isinstance(i, list): |
678 | - t.append(i) |
679 | - else: |
680 | - t.extend(flatten_list(i)) |
681 | - return t |
682 | - |
683 | - |
684 | -def read_rules(options): |
685 | - with open(options.config, 'r') as yaml_file: |
686 | - lint_rules = yaml.safe_load(yaml_file) |
687 | - if options.override: |
688 | - for override in options.override.split("#"): |
689 | - (name, where) = override.split(":") |
690 | - logging.info("Overriding %s with %s" % (name, where)) |
691 | - lint_rules["subordinates"][name] = dict(where=where) |
692 | - lint_rules["known charms"] = flatten_list(lint_rules["known charms"]) |
693 | - logging.debug("Lint Rules: {}".format(pprint.pformat(lint_rules))) |
694 | - return lint_rules |
695 | - |
696 | - |
697 | -def process_subordinates(app_d, app_name, model): |
698 | - # If this is a subordinate we have nothing else to do ATM |
699 | - if "units" not in app_d: |
700 | - return |
701 | - for unit in app_d["units"]: |
702 | - # juju_status = app_d["units"][unit]["juju-status"] |
703 | - # workload_status = app_d["units"][unit]["workload-status"] |
704 | - if "subordinates" in app_d["units"][unit]: |
705 | - subordinates = app_d["units"][unit]["subordinates"].keys() |
706 | - subordinates = [i.split("/")[0] for i in subordinates] |
707 | - else: |
708 | - subordinates = [] |
709 | - logging.debug("%s: %s" % (unit, subordinates)) |
710 | - machine = app_d["units"][unit]["machine"] |
711 | - model.subs_on_machines.setdefault(machine, set()) |
712 | - for sub in subordinates: |
713 | - if sub in model.subs_on_machines[machine]: |
714 | - model.duelling_subs.setdefault(sub, set()) |
715 | - model.duelling_subs[sub].add(machine) |
716 | - model.subs_on_machines[machine] = (set(subordinates) | |
717 | - model.subs_on_machines[machine]) |
718 | - model.apps_on_machines.setdefault(machine, set()) |
719 | - model.apps_on_machines[machine].add(app_name) |
720 | - |
721 | - return |
722 | - |
723 | - |
724 | -def is_container(machine): |
725 | - if "/" in machine: |
726 | - return True |
727 | - else: |
728 | - return False |
729 | - |
730 | - |
731 | -def check_subs(model, lint_rules): |
732 | - all_or_nothing = set() |
733 | - for machine in model.subs_on_machines: |
734 | - for sub in model.subs_on_machines[machine]: |
735 | - all_or_nothing.add(sub) |
736 | - |
737 | - for required_sub in lint_rules["subordinates"]: |
738 | - model.missing_subs.setdefault(required_sub, set()) |
739 | - model.extraneous_subs.setdefault(required_sub, set()) |
740 | - logging.debug("Checking for sub %s" % (required_sub)) |
741 | - where = lint_rules["subordinates"][required_sub]["where"] |
742 | - for machine in model.subs_on_machines: |
743 | - logging.debug("Checking on %s" % (machine)) |
744 | - present_subs = model.subs_on_machines[machine] |
745 | - apps = model.apps_on_machines[machine] |
746 | - if where.startswith("on "): # only on specific apps |
747 | - logging.debug("requirement is = form...") |
748 | - required_on = where[3:] |
749 | - if required_on not in apps: |
750 | - logging.debug("... NOT matched") |
751 | - continue |
752 | - logging.debug("... matched") |
753 | - # TODO this needs to be not just one app, but a list |
754 | - elif where.startswith("all except "): # not next to this app |
755 | - logging.debug("requirement is != form...") |
756 | - not_on = where[11:] |
757 | - if not_on in apps: |
758 | - logging.debug("... matched, not wanted on this host") |
759 | - continue |
760 | - elif where == "host only": |
761 | - logging.debug("requirement is 'host only' form....") |
762 | - if is_container(machine): |
763 | - logging.debug("... and we are a container, checking") |
764 | - # XXX check alternate names? |
765 | - if required_sub in present_subs: |
766 | - logging.debug("... found extraneous sub") |
767 | - for app in model.apps_on_machines[machine]: |
768 | - model.extraneous_subs[required_sub].add(app) |
769 | - continue |
770 | - logging.debug("... and we are a host, will fallthrough") |
771 | - elif (where == "all or nothing" and |
772 | - required_sub not in all_or_nothing): |
773 | - logging.debug("requirement is 'all or nothing' and was 'nothing'.") |
774 | - continue |
775 | - # At this point we know we require the subordinate - we might just |
776 | - # need to change the name we expect to see it as |
777 | - elif where == "container aware": |
778 | - logging.debug("requirement is 'container aware'.") |
779 | - if is_container(machine): |
780 | - suffixes = lint_rules["subordinates"][required_sub]["container-suffixes"] |
781 | - else: |
782 | - suffixes = lint_rules["subordinates"][required_sub]["host-suffixes"] |
783 | - logging.debug("-> suffixes == %s" % (suffixes)) |
784 | - found = False |
785 | - for suffix in suffixes: |
786 | - looking_for = "%s-%s" % (required_sub, suffix) |
787 | - logging.debug("-> Looking for %s" % (looking_for)) |
788 | - if looking_for in present_subs: |
789 | - logging.debug("-> FOUND!!!") |
790 | - found = True |
791 | - if not found: |
792 | - for sub in present_subs: |
793 | - if model.app_to_charm[sub] == required_sub: |
794 | - logging.debug("!!: winner winner chicken dinner %s" % (sub)) |
795 | - found = True |
796 | - if not found: |
797 | - logging.debug("-> NOT FOUND") |
798 | - for app in model.apps_on_machines[machine]: |
799 | - model.missing_subs[required_sub].add(app) |
800 | - logging.debug("-> continue-ing back out...") |
801 | - continue |
802 | - elif where not in ["all", "all or nothing"]: |
803 | - fubar("invalid requirement '%s' on %s" % (where, required_sub)) |
804 | - logging.debug("requirement is 'all' OR we fell through.") |
805 | - if required_sub not in present_subs: |
806 | - for sub in present_subs: |
807 | - if model.app_to_charm[sub] == required_sub: |
808 | - logging.debug("!!!: winner winner chicken dinner %s" % (sub)) |
809 | - continue |
810 | - logging.debug("not found.") |
811 | - for app in model.apps_on_machines[machine]: |
812 | - model.missing_subs[required_sub].add(app) |
813 | - |
814 | - for sub in list(model.missing_subs.keys()): |
815 | - if not model.missing_subs[sub]: |
816 | - del model.missing_subs[sub] |
817 | - for sub in list(model.extraneous_subs.keys()): |
818 | - if not model.extraneous_subs[sub]: |
819 | - del model.extraneous_subs[sub] |
820 | - |
821 | - |
822 | -def check_charms(model, lint_rules): |
823 | - # Check we recognise the charms which are there |
824 | - for charm in model.charms: |
825 | - if charm not in lint_rules["known charms"]: |
826 | - logging.error("charm '%s' not recognised" % (charm)) |
827 | - # Then look for charms we require |
828 | - for charm in lint_rules["operations mandatory"]: |
829 | - if charm not in model.charms: |
830 | - logging.error("ops charm '%s' not found" % (charm)) |
831 | - for charm in lint_rules["openstack mandatory"]: |
832 | - if charm not in model.charms: |
833 | - logging.error("OpenStack charm '%s' not found" % (charm)) |
834 | - |
835 | - |
836 | -def results(model): |
837 | - if model.missing_subs: |
838 | - logging.info("The following subordinates couldn't be found:") |
839 | - for sub in model.missing_subs: |
840 | - logging.error(" -> %s [%s]" % (sub, ", ".join(sorted(model.missing_subs[sub])))) |
841 | - if model.extraneous_subs: |
842 | - logging.info("following subordinates where found unexpectedly:") |
843 | - for sub in model.extraneous_subs: |
844 | - logging.error(" -> %s [%s]" % (sub, ", ".join(sorted(model.extraneous_subs[sub])))) |
845 | - if model.duelling_subs: |
846 | - logging.info("following subordinates where found on machines more than once:") |
847 | - for sub in model.duelling_subs: |
848 | - logging.error(" -> %s [%s]" % (sub, ", ".join(sorted(model.duelling_subs[sub])))) |
849 | - if model.az_unbalanced_apps: |
850 | - logging.error("The following apps are unbalanced across AZs: ") |
851 | - for app in model.az_unbalanced_apps: |
852 | - (num_units, az_counter) = model.az_unbalanced_apps[app] |
853 | - az_map = ", ".join(["%s: %s" % (az, az_counter[az]) for az in az_counter]) |
854 | - logging.error(" -> %s: %s units, deployed as: %s" % (app, num_units, az_map)) |
855 | - |
856 | - |
857 | -def map_charms(applications, model): |
858 | - for app in applications: |
859 | - charm = applications[app]["charm"] |
860 | - match = re.match(r'^(?:\w+:)?(?:~[\w-]+/)?(?:\w+/)?([a-zA-Z0-9-]+?)(?:-\d+)?$', charm) |
861 | - if not match: |
862 | - raise InvalidCharmNameError("charm name '{}' is invalid".format(charm)) |
863 | - charm = match.group(1) |
864 | - model.charms.add(charm) |
865 | - model.app_to_charm[app] = charm |
866 | - |
867 | - |
868 | -def map_machines_to_az(machines, model): |
869 | - for machine in machines: |
870 | - if "hardware" not in machines[machine]: |
871 | - logging.error("I: Machine %s has no hardware info; skipping." % (machine)) |
872 | - continue |
873 | - |
874 | - hardware = machines[machine]["hardware"] |
875 | - found_az = False |
876 | - for entry in hardware.split(): |
877 | - if entry.startswith("availability-zone="): |
878 | - found_az = True |
879 | - az = entry.split("=")[1] |
880 | - model.machines_to_az[machine] = az |
881 | - break |
882 | - if not found_az: |
883 | - logging.error("I: Machine %s has no availability-zone info in hardware field; skipping." % (machine)) |
884 | - |
885 | - |
886 | -def check_status(what, status, expected): |
887 | - if isinstance(expected, str): |
888 | - expected = [expected] |
889 | - if status.get("current") not in expected: |
890 | - logging.error("%s has status '%s' (since: %s, message: %s); {We expected: %s}" |
891 | - % (what, status.get("current"), status.get("since"), |
892 | - status.get("message"), expected)) |
893 | - |
894 | - |
895 | -def check_status_pair(name, status_type, data_d): |
896 | - if status_type in ["machine", "container"]: |
897 | - primary = "machine-status" |
898 | - primary_expected = "running" |
899 | - juju_expected = "started" |
900 | - elif status_type in ["unit", "subordinate"]: |
901 | - primary = "workload-status" |
902 | - primary_expected = ["active", "unknown"] |
903 | - juju_expected = "idle" |
904 | - elif status_type in ["application"]: |
905 | - primary = "application-status" |
906 | - primary_expected = ["active", "unknown"] |
907 | - juju_expected = None |
908 | - |
909 | - check_status("%s %s" % (status_type.title(), name), data_d[primary], |
910 | - expected=primary_expected) |
911 | - if juju_expected: |
912 | - check_status("Juju on %s %s" % (status_type, name), |
913 | - data_d["juju-status"], |
914 | - expected=juju_expected) |
915 | - |
916 | - |
917 | -def check_statuses(juju_status, applications): |
918 | - for machine_name in juju_status["machines"]: |
919 | - check_status_pair(machine_name, "machine", juju_status["machines"][machine_name]) |
920 | - for container_name in juju_status["machines"][machine_name].get("container", []): |
921 | - check_status_pair(container_name, "container", |
922 | - juju_status["machines"][machine_name][container_name]) |
923 | - |
924 | - for app_name in juju_status[applications]: |
925 | - check_status_pair(app_name, "application", |
926 | - juju_status[applications][app_name]) |
927 | - for unit_name in juju_status[applications][app_name].get("units", []): |
928 | - check_status_pair(unit_name, "unit", |
929 | - juju_status[applications][app_name]["units"][unit_name]) |
930 | - # This is noisy and only covers a very theoretical corner case |
931 | - # where a misbehaving or malicious leader unit sets the |
932 | - # application-status to OK despite one or more units being in error |
933 | - # state. |
934 | - # |
935 | - # We could revisit this later by splitting it into two passes and |
936 | - # only warning about individual subordinate units if the |
937 | - # application-status for the subordinate claims to be OK. |
938 | - # |
939 | - # for subordinate_name in juju_status[applications][app_name]["units"][unit_name].get("subordinates", []): |
940 | - # check_status_pair(subordinate_name, "subordinate", |
941 | - # juju_status[applications][app_name]["units"][unit_name]["subordinates"][subordinate_name]) |
942 | - |
943 | - |
944 | -def check_azs(applications, model): |
945 | - # Figure out how many AZs we have |
946 | - azs = set() |
947 | - for machine in model.machines_to_az: |
948 | - azs.add(model.machines_to_az[machine]) |
949 | - num_azs = len(azs) |
950 | - if num_azs != 3: |
951 | - logging.error("E: Found %s AZs (not 3); and I don't currently know how to lint that." % (num_azs)) |
952 | - return |
953 | - |
954 | - for app_name in applications: |
955 | - az_counter = collections.Counter() |
956 | - for az in azs: |
957 | - az_counter[az] = 0 |
958 | - num_units = len(applications[app_name].get("units", [])) |
959 | - if num_units <= 1: |
960 | - continue |
961 | - min_per_az = num_units // num_azs |
962 | - for unit in applications[app_name]["units"]: |
963 | - machine = applications[app_name]["units"][unit]["machine"] |
964 | - machine = machine.split("/")[0] |
965 | - if machine not in model.machines_to_az: |
966 | - logging.error("E: [%s] Can't find machine %s in machine to AZ mapping data" % (app_name, machine)) |
967 | - continue |
968 | - az_counter[model.machines_to_az[machine]] += 1 |
969 | - for az in az_counter: |
970 | - num_this_az = az_counter[az] |
971 | - if num_this_az < min_per_az: |
972 | - model.az_unbalanced_apps[app_name] = [num_units, az_counter] |
973 | - |
974 | - |
975 | -def lint(filename, lint_rules): |
976 | - model = ModelInfo() |
977 | - |
978 | - with open(filename, 'r') as infile: |
979 | - j = yaml.safe_load(infile.read()) |
980 | - |
981 | - # Handle Juju 2 vs Juju 1 |
982 | - applications = "applications" |
983 | - if applications not in j: |
984 | - applications = "services" |
985 | - |
986 | - # Build a list of deployed charms and mapping of charms <-> applications |
987 | - map_charms(j[applications], model) |
988 | - |
989 | - # Then map out subordinates to applications |
990 | - for app in j[applications]: |
991 | - process_subordinates(j[applications][app], app, model) |
992 | - |
993 | - map_machines_to_az(j["machines"], model) |
994 | - check_azs(j[applications], model) |
995 | - |
996 | - check_subs(model, lint_rules) |
997 | - check_charms(model, lint_rules) |
998 | - |
999 | - if j.get('machines'): |
1000 | - check_statuses(j, applications) |
1001 | - else: |
1002 | - logging.info("Not checking status, this is a bundle") |
1003 | - |
1004 | - results(model) |
1005 | - |
1006 | - |
1007 | -def init(): |
1008 | - """Initalization, including parsing of options.""" |
1009 | - |
1010 | - usage = """usage: %prog [OPTIONS] |
1011 | - Sanity check a Juju model""" |
1012 | - parser = optparse.OptionParser(usage) |
1013 | - parser.add_option("-c", "--config", default="lint-rules.yaml", |
1014 | - help="File to read lint rules from. Defaults to `lint-rules.yaml`") |
1015 | - parser.add_option("-o", "--override-subordinate", |
1016 | - dest="override", |
1017 | - help="override lint-rules.yaml, e.g. -o canonical-livepatch:all") |
1018 | - parser.add_option("--loglevel", "-l", default='INFO', |
1019 | - help="Log level. Defaults to INFO") |
1020 | - parser.add_option("--logfile", "-L", default=None, |
1021 | - help="File to log to in addition to stdout") |
1022 | - (options, args) = parser.parse_args() |
1023 | - |
1024 | - return (options, args) |
1025 | - |
1026 | - |
1027 | -def main(): |
1028 | - (options, args) = init() |
1029 | - setup_logging(options.loglevel, options.logfile) |
1030 | - lint_rules = read_rules(options) |
1031 | - for filename in args: |
1032 | - lint(filename, lint_rules) |
1033 | - |
1034 | - |
1035 | -if __name__ == "__main__": |
1036 | - main() |
1037 | diff --git a/jujulint/__init__.py b/jujulint/__init__.py |
1038 | new file mode 100644 |
1039 | index 0000000..3bb8cbc |
1040 | --- /dev/null |
1041 | +++ b/jujulint/__init__.py |
1042 | @@ -0,0 +1 @@ |
1043 | +"""Import this library to fetch and lint the configuration and status of Juju environments.""" |
1044 | diff --git a/jujulint/cli.py b/jujulint/cli.py |
1045 | new file mode 100755 |
1046 | index 0000000..a75a7c3 |
1047 | --- /dev/null |
1048 | +++ b/jujulint/cli.py |
1049 | @@ -0,0 +1,136 @@ |
1050 | +#!/usr/bin/env python3 |
1051 | +# This file is part of juju-lint, a tool for validating that Juju |
1052 | +# deloyments meet configurable site policies. |
1053 | +# |
1054 | +# Copyright 2018-2020 Canonical Limited. |
1055 | +# License granted by Canonical Limited. |
1056 | +# |
1057 | +# This program is free software: you can redistribute it and/or modify |
1058 | +# it under the terms of the GNU General Public License version 3, as |
1059 | +# published by the Free Software Foundation. |
1060 | +# |
1061 | +# This program is distributed in the hope that it will be useful, but |
1062 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
1063 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
1064 | +# PURPOSE. See the GNU General Public License for more details. |
1065 | +# |
1066 | +# You should have received a copy of the GNU General Public License |
1067 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
1068 | +"""Main entrypoint for the juju-lint CLI.""" |
1069 | +from jujulint.config import Config |
1070 | +from jujulint.lint import Linter |
1071 | +from jujulint.logging import Logger |
1072 | +from jujulint.openstack import OpenStack |
1073 | +import pkg_resources |
1074 | +import yaml |
1075 | + |
1076 | + |
1077 | +class Cli: |
1078 | + """Core class of the CLI for juju-lint.""" |
1079 | + |
1080 | + clouds = {} |
1081 | + |
1082 | + def __init__(self): |
1083 | + """Create new CLI and configure runtime environment.""" |
1084 | + self.config = Config() |
1085 | + self.logger = Logger(self.config["logging"]["loglevel"].get()) |
1086 | + self.version = pkg_resources.require("jujulint")[0].version |
1087 | + self.lint_rules = "{}/{}".format( |
1088 | + self.config.config_dir(), self.config["rules"]["file"].get() |
1089 | + ) |
1090 | + |
1091 | + def startup_message(self): |
1092 | + """Print startup message to log.""" |
1093 | + self.logger.info( |
1094 | + ( |
1095 | + "juju-lint version {} starting...\n" |
1096 | + "\t* Config directory: {}\n" |
1097 | + "\t* Log level: {}\n" |
1098 | + ).format( |
1099 | + self.version, |
1100 | + self.config.config_dir(), |
1101 | + self.config["logging"]["loglevel"].get(), |
1102 | + ) |
1103 | + ) |
1104 | + |
1105 | + def audit_file(self, filename, cloud_type=None): |
1106 | + """Directly audit a YAML file.""" |
1107 | + self.logger.debug("Starting audit of file {}".format(filename)) |
1108 | + linter = Linter(filename, self.lint_rules, cloud_type=cloud_type,) |
1109 | + linter.read_rules() |
1110 | + self.logger.info("[{}] Linting manual file...".format(filename)) |
1111 | + linter.lint_yaml_file(filename) |
1112 | + |
1113 | + def audit_all(self): |
1114 | + """Iterate over clouds and run audit.""" |
1115 | + self.logger.debug("Starting audit") |
1116 | + for cloud_name in self.config["clouds"].get(): |
1117 | + self.audit(cloud_name) |
1118 | + # serialise state |
1119 | + if self.clouds: |
1120 | + self.write_yaml(self.clouds, "all-data.yaml") |
1121 | + |
1122 | + def audit(self, cloud_name): |
1123 | + """Run the main audit process process each cloud.""" |
1124 | + # load clouds and loop through each defined cloud |
1125 | + if cloud_name not in self.clouds.keys(): |
1126 | + self.clouds[cloud_name] = {} |
1127 | + cloud = self.config["clouds"][cloud_name].get() |
1128 | + access_method = "local" |
1129 | + ssh_host = None |
1130 | + sudo_user = None |
1131 | + if "access" in cloud: |
1132 | + access_method = cloud["access"] |
1133 | + if "sudo" in cloud: |
1134 | + sudo_user = cloud["sudo"] |
1135 | + if "host" in cloud: |
1136 | + ssh_host = cloud["host"] |
1137 | + self.logger.debug(cloud) |
1138 | + # load correct handler (OpenStack) |
1139 | + if cloud["type"] == "openstack": |
1140 | + cloud_instance = OpenStack( |
1141 | + cloud_name, |
1142 | + access_method=access_method, |
1143 | + ssh_host=ssh_host, |
1144 | + sudo_user=sudo_user, |
1145 | + lint_rules=self.lint_rules, |
1146 | + ) |
1147 | + # refresh information |
1148 | + result = cloud_instance.refresh() |
1149 | + if result: |
1150 | + self.clouds[cloud_name] = cloud_instance.cloud_state |
1151 | + self.logger.debug( |
1152 | + "Cloud state for {} after refresh: {}".format( |
1153 | + cloud_name, cloud_instance.cloud_state |
1154 | + ) |
1155 | + ) |
1156 | + self.write_yaml( |
1157 | + cloud_instance.cloud_state, "{}-state.yaml".format(cloud_name) |
1158 | + ) |
1159 | + # run audit checks |
1160 | + cloud_instance.audit() |
1161 | + else: |
1162 | + self.logger.error("[{}] Failed getting cloud state".format(cloud_name)) |
1163 | + |
1164 | + def write_yaml(self, data, file_name): |
1165 | + """Write collected information to YAML.""" |
1166 | + if "dump" in self.config["output"]: |
1167 | + if self.config["output"]["dump"]: |
1168 | + folder_name = self.config["output"]["folder"].get() |
1169 | + file_handle = open("{}/{}".format(folder_name, file_name), "w") |
1170 | + yaml.dump(data, file_handle) |
1171 | + |
1172 | + |
1173 | +def main(): |
1174 | + """Program entry point.""" |
1175 | + cli = Cli() |
1176 | + cli.startup_message() |
1177 | + if "manual-file" in cli.config: |
1178 | + manual_file = cli.config["manual-file"].get() |
1179 | + if "manual-type" in cli.config: |
1180 | + manual_type = cli.config["manual-type"].get() |
1181 | + cli.audit_file(manual_file, cloud_type=manual_type) |
1182 | + else: |
1183 | + cli.audit_file(manual_file) |
1184 | + else: |
1185 | + cli.audit_all() |
1186 | diff --git a/jujulint/cloud.py b/jujulint/cloud.py |
1187 | new file mode 100644 |
1188 | index 0000000..b3cde24 |
1189 | --- /dev/null |
1190 | +++ b/jujulint/cloud.py |
1191 | @@ -0,0 +1,376 @@ |
1192 | +#!/usr/bin/env python3 |
1193 | +# This file is part of juju-lint, a tool for validating that Juju |
1194 | +# deloyments meet configurable site policies. |
1195 | +# |
1196 | +# Copyright 2018-2020 Canonical Limited. |
1197 | +# License granted by Canonical Limited. |
1198 | +# |
1199 | +# This program is free software: you can redistribute it and/or modify |
1200 | +# it under the terms of the GNU General Public License version 3, as |
1201 | +# published by the Free Software Foundation. |
1202 | +# |
1203 | +# This program is distributed in the hope that it will be useful, but |
1204 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
1205 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
1206 | +# PURPOSE. See the GNU General Public License for more details. |
1207 | +# |
1208 | +# You should have received a copy of the GNU General Public License |
1209 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
1210 | +"""Cloud access module. |
1211 | + |
1212 | +Runs locally or uses Fabric to parse a cloud's Juju bundle and provide SSH access to the units |
1213 | + |
1214 | +Attributes: |
1215 | + access_method (string, optional): Set the access method (local/ssh). Defaults to local. |
1216 | + ssh_host (string, optional): Configuration to pass to Cloud module for accessing the cloud via SSH |
1217 | + sudo_user (string, optional): User to switch to via sudo when accessing the cloud, passed to the Cloud module |
1218 | + |
1219 | +Todo: |
1220 | + * SSH to remote host honouring SSH config |
1221 | + * Sudo to desired user |
1222 | + * Get bundle from remote host |
1223 | + * Parse bundle into dict |
1224 | + * Add function to run command on a unit, via fabric and jump host if configured |
1225 | + |
1226 | +""" |
1227 | +from fabric2 import Connection, Config |
1228 | +from paramiko.ssh_exception import SSHException |
1229 | +from jujulint.logging import Logger |
1230 | +from jujulint.lint import Linter |
1231 | +from subprocess import check_output |
1232 | +import socket |
1233 | +import yaml |
1234 | + |
1235 | + |
1236 | +class Cloud: |
1237 | + """Cloud helper class.""" |
1238 | + |
1239 | + def __init__( |
1240 | + self, |
1241 | + name, |
1242 | + lint_rules=None, |
1243 | + access_method="local", |
1244 | + ssh_host=None, |
1245 | + sudo_user=None, |
1246 | + lint_overrides=None, |
1247 | + cloud_type=None, |
1248 | + ): |
1249 | + """Instantiate Cloud configuration and state.""" |
1250 | + # instance variables |
1251 | + self.cloud_state = {} |
1252 | + self.access_method = "local" |
1253 | + self.ssh_host = "" |
1254 | + self.sudo_user = "" |
1255 | + self.hostname = "" |
1256 | + self.name = "" |
1257 | + self.fabric_config = {} |
1258 | + self.lint_rules = lint_rules |
1259 | + self.lint_overrides = lint_overrides |
1260 | + self.cloud_type = cloud_type |
1261 | + |
1262 | + # process variables |
1263 | + self.logger = Logger() |
1264 | + self.logger.debug("Configuring {} cloud.".format(access_method)) |
1265 | + if sudo_user: |
1266 | + self.sudo_user = sudo_user |
1267 | + self.fabric_config = {"sudo": {"user": sudo_user}} |
1268 | + if access_method == "ssh": |
1269 | + if ssh_host: |
1270 | + self.logger.debug("SSH host: {}".format(ssh_host)) |
1271 | + self.hostname = ssh_host |
1272 | + self.connection = Connection( |
1273 | + ssh_host, config=Config(overrides=self.fabric_config) |
1274 | + ) |
1275 | + self.access_method = "ssh" |
1276 | + elif access_method == "local": |
1277 | + self.hostname = socket.getfqdn() |
1278 | + self.name = name |
1279 | + |
1280 | + def run_command(self, command): |
1281 | + """Run a command via fabric on the local or remote host.""" |
1282 | + if self.access_method == "local": |
1283 | + self.logger.debug("Running local command: {}".format(command)) |
1284 | + args = command.split(" ") |
1285 | + return check_output(args) |
1286 | + elif self.access_method == "ssh": |
1287 | + if self.sudo_user: |
1288 | + self.logger.debug( |
1289 | + "Running SSH command {} on {} as {}...".format( |
1290 | + command, self.hostname, self.sudo_user |
1291 | + ) |
1292 | + ) |
1293 | + try: |
1294 | + result = self.connection.sudo(command, hide=True, warn=True) |
1295 | + except SSHException as e: |
1296 | + self.logger.error( |
1297 | + "[{}] SSH command {} failed: {}".format(self.name, command, e) |
1298 | + ) |
1299 | + return None |
1300 | + return result.stdout |
1301 | + else: |
1302 | + self.logger.debug( |
1303 | + "Running SSH command {} on {}...".format(command, self.hostname) |
1304 | + ) |
1305 | + try: |
1306 | + result = self.connection.run(command, hide=True, warn=True) |
1307 | + except SSHException as e: |
1308 | + self.logger.error( |
1309 | + "[{}] SSH command {} failed: {}".format(self.name, command, e) |
1310 | + ) |
1311 | + return None |
1312 | + return result.stdout |
1313 | + |
1314 | + def run_unit_command(self, target, command): |
1315 | + """Run a command on a Juju unit and return the output.""" |
1316 | + |
1317 | + def parse_yaml(self, yaml_string): |
1318 | + """Parse YAML using PyYAML.""" |
1319 | + data = yaml.safe_load_all(yaml_string) |
1320 | + return list(data) |
1321 | + |
1322 | + def get_juju_controllers(self): |
1323 | + """Get a list of Juju controllers.""" |
1324 | + controller_output = self.run_command("juju controllers --format yaml") |
1325 | + if controller_output: |
1326 | + controllers = self.parse_yaml(controller_output) |
1327 | + |
1328 | + if len(controllers) > 0: |
1329 | + self.logger.debug("Juju controller list: {}".format(controllers[0])) |
1330 | + if "controllers" in controllers[0]: |
1331 | + for controller in controllers[0]["controllers"].keys(): |
1332 | + self.logger.info( |
1333 | + "[{}] Found Juju controller: {}".format( |
1334 | + self.name, controller |
1335 | + ) |
1336 | + ) |
1337 | + if controller not in self.cloud_state.keys(): |
1338 | + self.cloud_state[controller] = {} |
1339 | + self.cloud_state[controller]["config"] = controllers[0][ |
1340 | + "controllers" |
1341 | + ][controller] |
1342 | + return True |
1343 | + self.logger.error("[{}] Could not get controller list".format(self.name)) |
1344 | + return False |
1345 | + |
1346 | + def get_juju_models(self): |
1347 | + """Get a list of Juju models.""" |
1348 | + result = self.get_juju_controllers() |
1349 | + if result: |
1350 | + for controller in self.cloud_state.keys(): |
1351 | + self.logger.info( |
1352 | + "[{}] Getting models for controller: {}".format( |
1353 | + self.name, controller |
1354 | + ) |
1355 | + ) |
1356 | + models_data = self.run_command( |
1357 | + "juju models -c {} --format yaml".format(controller) |
1358 | + ) |
1359 | + self.logger.debug("Getting models from: {}".format(models_data)) |
1360 | + models = self.parse_yaml(models_data) |
1361 | + if len(models) > 0: |
1362 | + if "models" in models[0]: |
1363 | + for model in models[0]["models"]: |
1364 | + model_name = model["short-name"] |
1365 | + self.logger.info( |
1366 | + "[{}] Processing model {} for controller: {}".format( |
1367 | + self.name, model_name, controller |
1368 | + ) |
1369 | + ) |
1370 | + self.logger.debug( |
1371 | + "Processing model {} for controller {}: {}".format( |
1372 | + model_name, controller, model |
1373 | + ) |
1374 | + ) |
1375 | + if "models" not in self.cloud_state[controller].keys(): |
1376 | + self.cloud_state[controller]["models"] = {} |
1377 | + if ( |
1378 | + model_name |
1379 | + not in self.cloud_state[controller]["models"].keys() |
1380 | + ): |
1381 | + self.cloud_state[controller]["models"][model_name] = {} |
1382 | + self.cloud_state[controller]["models"][model_name][ |
1383 | + "config" |
1384 | + ] = model |
1385 | + return True |
1386 | + self.logger.error("[{}] Could not get model list".format(self.name)) |
1387 | + return False |
1388 | + |
1389 | + def get_juju_status(self, controller, model): |
1390 | + """Get a view of juju status for a given model.""" |
1391 | + status_data = self.run_command( |
1392 | + "juju status -m {}:{} --format yaml".format(controller, model) |
1393 | + ) |
1394 | + status = self.parse_yaml(status_data) |
1395 | + self.logger.info( |
1396 | + "[{}] Processing Juju status for model {} on controller {}".format( |
1397 | + self.name, model, controller |
1398 | + ) |
1399 | + ) |
1400 | + if len(status) > 0: |
1401 | + if "model" in status[0].keys(): |
1402 | + self.cloud_state[controller]["models"][model]["version"] = status[0][ |
1403 | + "model" |
1404 | + ]["version"] |
1405 | + if "machines" in status[0].keys(): |
1406 | + for machine in status[0]["machines"].keys(): |
1407 | + machine_data = status[0]["machines"][machine] |
1408 | + self.logger.debug( |
1409 | + "Parsing status for machine {} in model {}: {}".format( |
1410 | + machine, model, machine_data |
1411 | + ) |
1412 | + ) |
1413 | + if "display-name" in machine_data: |
1414 | + machine_name = machine_data["display-name"] |
1415 | + else: |
1416 | + machine_name = machine |
1417 | + if "machines" not in self.cloud_state[controller]["models"][model]: |
1418 | + self.cloud_state[controller]["models"][model]["machines"] = {} |
1419 | + if ( |
1420 | + "machine_name" |
1421 | + not in self.cloud_state[controller]["models"][model][ |
1422 | + "machines" |
1423 | + ].keys() |
1424 | + ): |
1425 | + self.cloud_state[controller]["models"][model]["machines"][ |
1426 | + machine_name |
1427 | + ] = {} |
1428 | + self.cloud_state[controller]["models"][model]["machines"][ |
1429 | + machine_name |
1430 | + ].update(machine_data) |
1431 | + self.cloud_state[controller]["models"][model]["machines"][ |
1432 | + machine_name |
1433 | + ]["machine_id"] = machine |
1434 | + if "applications" in status[0].keys(): |
1435 | + for application in status[0]["applications"].keys(): |
1436 | + application_data = status[0]["applications"][application] |
1437 | + self.logger.debug( |
1438 | + "Parsing status for application {} in model {}: {}".format( |
1439 | + application, model, application_data |
1440 | + ) |
1441 | + ) |
1442 | + if ( |
1443 | + "applications" |
1444 | + not in self.cloud_state[controller]["models"][model] |
1445 | + ): |
1446 | + self.cloud_state[controller]["models"][model][ |
1447 | + "applications" |
1448 | + ] = {} |
1449 | + if ( |
1450 | + application |
1451 | + not in self.cloud_state[controller]["models"][model][ |
1452 | + "applications" |
1453 | + ].keys() |
1454 | + ): |
1455 | + self.cloud_state[controller]["models"][model]["applications"][ |
1456 | + application |
1457 | + ] = {} |
1458 | + self.cloud_state[controller]["models"][model]["applications"][ |
1459 | + application |
1460 | + ].update(application_data) |
1461 | + |
1462 | + def get_juju_bundle(self, controller, model): |
1463 | + """Get an export of the juju bundle for the provided model.""" |
1464 | + bundle_data = self.run_command( |
1465 | + "juju export-bundle -m {}:{}".format(controller, model) |
1466 | + ) |
1467 | + bundles = self.parse_yaml(bundle_data) |
1468 | + self.logger.info( |
1469 | + "[{}] Processing Juju bundle export for model {} on controller {}".format( |
1470 | + self.name, model, controller |
1471 | + ) |
1472 | + ) |
1473 | + self.logger.debug( |
1474 | + "Juju bundle for model {} on controller {}: {}".format( |
1475 | + model, controller, bundles |
1476 | + ) |
1477 | + ) |
1478 | + if len(bundles) > 0: |
1479 | + combined = {} |
1480 | + for bundle in bundles: |
1481 | + combined.update(bundle) |
1482 | + if "applications" in combined: |
1483 | + for application in combined["applications"].keys(): |
1484 | + self.logger.debug( |
1485 | + "Parsing configuration for application {} in model {}: {}".format( |
1486 | + application, model, combined |
1487 | + ) |
1488 | + ) |
1489 | + application_config = combined["applications"][application] |
1490 | + if ( |
1491 | + "applications" |
1492 | + not in self.cloud_state[controller]["models"][model] |
1493 | + ): |
1494 | + self.cloud_state[controller]["models"][model][ |
1495 | + "applications" |
1496 | + ] = {} |
1497 | + if ( |
1498 | + application |
1499 | + not in self.cloud_state[controller]["models"][model][ |
1500 | + "applications" |
1501 | + ].keys() |
1502 | + ): |
1503 | + self.cloud_state[controller]["models"][model]["applications"][ |
1504 | + application |
1505 | + ] = {} |
1506 | + self.cloud_state[controller]["models"][model]["applications"][ |
1507 | + application |
1508 | + ].update(application_config) |
1509 | + |
1510 | + def get_juju_state(self): |
1511 | + """Update our view of Juju-managed application state.""" |
1512 | + self.logger.info( |
1513 | + "[{}] Getting Juju state for {}".format(self.name, self.hostname) |
1514 | + ) |
1515 | + result = self.get_juju_models() |
1516 | + if result: |
1517 | + self.logger.debug( |
1518 | + "Cloud state for {} after gathering models:\n{}".format( |
1519 | + self.name, yaml.dump(self.cloud_state) |
1520 | + ) |
1521 | + ) |
1522 | + for controller in self.cloud_state.keys(): |
1523 | + for model in self.cloud_state[controller]["models"].keys(): |
1524 | + self.get_juju_status(controller, model) |
1525 | + self.get_juju_bundle(controller, model) |
1526 | + self.logger.debug( |
1527 | + "Cloud state for {} after gathering apps:\n{}".format( |
1528 | + self.name, yaml.dump(self.cloud_state) |
1529 | + ) |
1530 | + ) |
1531 | + return True |
1532 | + return False |
1533 | + |
1534 | + def refresh(self): |
1535 | + """Refresh all information about the Juju cloud.""" |
1536 | + self.logger.info( |
1537 | + "[{}] Refreshing cloud information for {}".format(self.name, self.hostname) |
1538 | + ) |
1539 | + self.logger.debug("Running cloud-agnostic cloud refresh steps." "") |
1540 | + state = self.get_juju_state() |
1541 | + return state |
1542 | + |
1543 | + def audit(self): |
1544 | + """Run cloud-type agnostic audit steps.""" |
1545 | + self.logger.info( |
1546 | + "[{}] Auditing information for {}".format(self.name, self.hostname) |
1547 | + ) |
1548 | + # run lint rules |
1549 | + self.logger.debug("Running cloud-agnostic Juju audits.") |
1550 | + if self.lint_rules: |
1551 | + for controller in self.cloud_state.keys(): |
1552 | + for model in self.cloud_state[controller]["models"].keys(): |
1553 | + linter = Linter( |
1554 | + self.name, |
1555 | + self.lint_rules, |
1556 | + overrides=self.lint_overrides, |
1557 | + cloud_type=self.cloud_type, |
1558 | + controller_name=controller, |
1559 | + model_name=model, |
1560 | + ) |
1561 | + linter.read_rules() |
1562 | + self.logger.info( |
1563 | + "[{}] Linting model information for {}, controller {}, model {}...".format( |
1564 | + self.name, self.hostname, controller, model |
1565 | + ) |
1566 | + ) |
1567 | + linter.do_lint(self.cloud_state[controller]["models"][model]) |
1568 | diff --git a/jujulint/config.py b/jujulint/config.py |
1569 | new file mode 100644 |
1570 | index 0000000..b4d1e1c |
1571 | --- /dev/null |
1572 | +++ b/jujulint/config.py |
1573 | @@ -0,0 +1,101 @@ |
1574 | +#!/usr/bin/env python3 |
1575 | +# This file is part of juju-lint, a tool for validating that Juju |
1576 | +# deloyments meet configurable site policies. |
1577 | +# |
1578 | +# Copyright 2018-2020 Canonical Limited. |
1579 | +# License granted by Canonical Limited. |
1580 | +# |
1581 | +# This program is free software: you can redistribute it and/or modify |
1582 | +# it under the terms of the GNU General Public License version 3, as |
1583 | +# published by the Free Software Foundation. |
1584 | +# |
1585 | +# This program is distributed in the hope that it will be useful, but |
1586 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
1587 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
1588 | +# PURPOSE. See the GNU General Public License for more details. |
1589 | +# |
1590 | +# You should have received a copy of the GNU General Public License |
1591 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
1592 | +"""Config handling routines.""" |
1593 | + |
1594 | +from confuse import Configuration |
1595 | +from argparse import ArgumentParser |
1596 | + |
1597 | + |
1598 | +class Config(Configuration): |
1599 | + """Helper class for holding parsed config, extending confuse's BaseConfiguraion class.""" |
1600 | + |
1601 | + def __init__(self): |
1602 | + """Wrap the initialisation of confuse's Configuration object providing defaults for our application.""" |
1603 | + super().__init__("juju-lint", __name__) |
1604 | + |
1605 | + parser = ArgumentParser(description="Sanity check one or more Juju models") |
1606 | + parser.add_argument( |
1607 | + "-l", |
1608 | + "--log-level", |
1609 | + type=str, |
1610 | + default="info", |
1611 | + nargs="?", |
1612 | + help="The default log level, valid options are info, warn, error or debug", |
1613 | + dest="logging.loglevel", |
1614 | + ) |
1615 | + parser.add_argument( |
1616 | + "-d", |
1617 | + "--output-dir", |
1618 | + type=str, |
1619 | + default="output", |
1620 | + nargs="?", |
1621 | + help="The folder to use when saving gathered cloud data and lint reports.", |
1622 | + dest="output.folder", |
1623 | + ) |
1624 | + parser.add_argument( |
1625 | + "--dump-state", |
1626 | + type=str, |
1627 | + help=( |
1628 | + "Optionally, dump cloud state as YAML into --output-dir." |
1629 | + "Use with caution, as dumps will contain sensitve data." |
1630 | + ), |
1631 | + dest="output.dump", |
1632 | + ) |
1633 | + parser.add_argument( |
1634 | + "-c", |
1635 | + "--config", |
1636 | + default="lint-rules.yaml", |
1637 | + help="File to read lint rules from. Defaults to `lint-rules.yaml`", |
1638 | + dest="rules.file", |
1639 | + ) |
1640 | + parser.add_argument( |
1641 | + "manual-file", |
1642 | + metavar="manual-file", |
1643 | + nargs='?', |
1644 | + type=str, |
1645 | + default=None, |
1646 | + help=( |
1647 | + "File to read state from. Supports bundles and status output in YAML format." |
1648 | + "Setting this disables collection of data from remote or local clouds configured via config.yaml." |
1649 | + ), |
1650 | + ) |
1651 | + parser.add_argument( |
1652 | + "-t", |
1653 | + "--cloud-type", |
1654 | + help=( |
1655 | + "Sets the cloud type when specifying a YAML file to audit with -f or --cloud-file." |
1656 | + ), |
1657 | + dest="manual-type", |
1658 | + ) |
1659 | + parser.add_argument( |
1660 | + "-o", |
1661 | + "--override-subordinate", |
1662 | + dest="override.subordinate", |
1663 | + help="override lint-rules.yaml, e.g. -o canonical-livepatch:all", |
1664 | + ) |
1665 | + parser.add_argument( |
1666 | + "--logfile", |
1667 | + "-L", |
1668 | + default=None, |
1669 | + help="File to log to in addition to stdout", |
1670 | + dest="logging.file", |
1671 | + ) |
1672 | + |
1673 | + args = parser.parse_args() |
1674 | + self.set_args(args, dots=True) |
1675 | diff --git a/jujulint/config_default.yaml b/jujulint/config_default.yaml |
1676 | new file mode 100644 |
1677 | index 0000000..19cbe91 |
1678 | --- /dev/null |
1679 | +++ b/jujulint/config_default.yaml |
1680 | @@ -0,0 +1,34 @@ |
1681 | +--- |
1682 | +clouds: |
1683 | + # an example local Juju-deployed OpenStack |
1684 | + cloud1: |
1685 | + type: openstack |
1686 | + access: local |
1687 | + |
1688 | + # an example remote Juju-deployed OpenStack |
1689 | + cloud2: |
1690 | + type: openstack |
1691 | + access: ssh |
1692 | + host: 'ubuntu@openstack.example.fake' |
1693 | + |
1694 | + # an exported bundle |
1695 | + yamlfile: |
1696 | + type: openstack |
1697 | + access: dump |
1698 | + file: 'export.yaml' |
1699 | + |
1700 | + # an example remote Juju-deployed OpenStack where |
1701 | + # Juju controllers are registered under user 'juju-user |
1702 | + # in this example cloud, so we sudo to that user |
1703 | + cloud3: |
1704 | + type: openstack |
1705 | + access: ssh |
1706 | + host: 'ubuntu@openstack2.example.fake' |
1707 | + sudo: 'juju-user' |
1708 | + |
1709 | +logging: |
1710 | + level: INFO |
1711 | + file: jujulint.log |
1712 | + |
1713 | +rules: |
1714 | + file: lint-rules.yaml |
1715 | diff --git a/jujulint/k8s.py b/jujulint/k8s.py |
1716 | new file mode 100644 |
1717 | index 0000000..53b8c30 |
1718 | --- /dev/null |
1719 | +++ b/jujulint/k8s.py |
1720 | @@ -0,0 +1,65 @@ |
1721 | +#!/usr/bin/env python3 |
1722 | +# This file is part of juju-lint, a tool for validating that Juju |
1723 | +# deloyments meet configurable site policies. |
1724 | +# |
1725 | +# Copyright 2018-2020 Canonical Limited. |
1726 | +# License granted by Canonical Limited. |
1727 | +# |
1728 | +# This program is free software: you can redistribute it and/or modify |
1729 | +# it under the terms of the GNU General Public License version 3, as |
1730 | +# published by the Free Software Foundation. |
1731 | +# |
1732 | +# This program is distributed in the hope that it will be useful, but |
1733 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
1734 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
1735 | +# PURPOSE. See the GNU General Public License for more details. |
1736 | +# |
1737 | +# You should have received a copy of the GNU General Public License |
1738 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
1739 | +"""Kubernetes checks module. |
1740 | + |
1741 | +This module provides checks for Kubernetes clouds. |
1742 | + |
1743 | +Attributes: |
1744 | + access (string): Set the access method (local/ssh) |
1745 | + ssh_host (string, optional): Configuration to pass to Cloud module for accessing the cloud via SSH |
1746 | + ssh_jump (string, optional): Jump/Bastion configuration to pass to Cloud module for access the cloud via SSH |
1747 | + sudo_user (string, optional): User to switch to via sudo when accessing the cloud, passed to the Cloud module |
1748 | + |
1749 | +Todo: |
1750 | + * Add processing of kubectl information |
1751 | + * Pass cloud type back to lint module |
1752 | + * Add rules for k8s |
1753 | + * Check OpenStack integrator charm configuration |
1754 | + * Check distribution of k8s workloads to workers |
1755 | + |
1756 | +""" |
1757 | + |
1758 | +from jujulint.cloud import Cloud |
1759 | + |
1760 | + |
1761 | +class OpenStack(Cloud): |
1762 | + """Helper class for interacting with Nagios via the livestatus socket.""" |
1763 | + |
1764 | + def __init__(self, *args, **kwargs): |
1765 | + """Initialise class-local variables and configuration and pass to super.""" |
1766 | + super(OpenStack, self).__init__(*args, **kwargs) |
1767 | + |
1768 | + def get_neutron_ports(self): |
1769 | + """Get a list of neutron ports.""" |
1770 | + |
1771 | + def get_neutron_routers(self): |
1772 | + """Get a list of neutron routers.""" |
1773 | + |
1774 | + def get_neutron_networks(self): |
1775 | + """Get a list of neutron networks.""" |
1776 | + |
1777 | + def refresh(self): |
1778 | + """Refresh cloud information.""" |
1779 | + return super(OpenStack, self).refresh() |
1780 | + |
1781 | + def audit(self): |
1782 | + """Audit OpenStack cloud and run base Cloud audits.""" |
1783 | + # add specific OpenStack checks here |
1784 | + self.logger.debug("Running OpenStack-specific audit steps.") |
1785 | + super(OpenStack, self).audit() |
1786 | diff --git a/jujulint/lint.py b/jujulint/lint.py |
1787 | new file mode 100755 |
1788 | index 0000000..92e56d6 |
1789 | --- /dev/null |
1790 | +++ b/jujulint/lint.py |
1791 | @@ -0,0 +1,882 @@ |
1792 | +#!/usr/bin/env python3 |
1793 | + |
1794 | +# This file is part of juju-lint, a tool for validating that Juju |
1795 | +# deloyments meet configurable site policies. |
1796 | +# |
1797 | +# Copyright 2018-2020 Canonical Limited. |
1798 | +# License granted by Canonical Limited. |
1799 | +# |
1800 | +# This program is free software: you can redistribute it and/or modify |
1801 | +# it under the terms of the GNU General Public License version 3, as |
1802 | +# published by the Free Software Foundation. |
1803 | +# |
1804 | +# This program is distributed in the hope that it will be useful, but |
1805 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
1806 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
1807 | +# PURPOSE. See the GNU General Public License for more details. |
1808 | +# |
1809 | +# You should have received a copy of the GNU General Public License |
1810 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
1811 | +"""Lint operations and rule processing engine.""" |
1812 | +import collections |
1813 | +import pprint |
1814 | +import re |
1815 | + |
1816 | +import yaml |
1817 | + |
1818 | +from attr import attrs, attrib |
1819 | +import attr |
1820 | + |
1821 | +from jujulint.util import flatten_list, is_container |
1822 | +from jujulint.logging import Logger |
1823 | + |
1824 | +# TODO: |
1825 | +# - tests |
1826 | +# - missing relations for mandatory subordinates |
1827 | +# - info mode, e.g. num of machines, version (e.g. look at ceph), architecture |
1828 | + |
1829 | + |
1830 | +class InvalidCharmNameError(Exception): |
1831 | + """Represents an invalid charm name being processed.""" |
1832 | + |
1833 | + pass |
1834 | + |
1835 | + |
1836 | +@attrs |
1837 | +class ModelInfo(object): |
1838 | + """Represent information obtained from juju status data.""" |
1839 | + |
1840 | + charms = attrib(default=attr.Factory(set)) |
1841 | + app_to_charm = attrib(default=attr.Factory(dict)) |
1842 | + subs_on_machines = attrib(default=attr.Factory(dict)) |
1843 | + apps_on_machines = attrib(default=attr.Factory(dict)) |
1844 | + machines_to_az = attrib(default=attr.Factory(dict)) |
1845 | + |
1846 | + # Output of our linting |
1847 | + missing_subs = attrib(default=attr.Factory(dict)) |
1848 | + extraneous_subs = attrib(default=attr.Factory(dict)) |
1849 | + duelling_subs = attrib(default=attr.Factory(dict)) |
1850 | + az_unbalanced_apps = attrib(default=attr.Factory(dict)) |
1851 | + |
1852 | + |
1853 | +class Linter: |
1854 | + """Linter for a Juju model, instantiate a new class for each model.""" |
1855 | + |
1856 | + def __init__( |
1857 | + self, |
1858 | + name, |
1859 | + filename, |
1860 | + controller_name="manual", |
1861 | + model_name="manual", |
1862 | + overrides=None, |
1863 | + cloud_type=None, |
1864 | + ): |
1865 | + """Instantiate linter.""" |
1866 | + self.logger = Logger() |
1867 | + self.lint_rules = {} |
1868 | + self.model = ModelInfo() |
1869 | + self.filename = filename |
1870 | + self.overrides = overrides |
1871 | + self.cloud_name = name |
1872 | + self.cloud_type = cloud_type |
1873 | + self.controller_name = controller_name |
1874 | + self.model_name = model_name |
1875 | + |
1876 | + def read_rules(self): |
1877 | + """Read and parse rules from YAML, optionally processing provided overrides.""" |
1878 | + with open(self.filename, "r") as yaml_file: |
1879 | + self.lint_rules = yaml.safe_load(yaml_file) |
1880 | + if self.overrides: |
1881 | + for override in self.overrides.split("#"): |
1882 | + (name, where) = override.split(":") |
1883 | + self.logger.info( |
1884 | + "[{}] [{}/{}] Overriding {} with {}".format( |
1885 | + self.cloud_name, |
1886 | + self.controller_name, |
1887 | + self.model_name, |
1888 | + name, |
1889 | + where, |
1890 | + ) |
1891 | + ) |
1892 | + self.lint_rules["subordinates"][name] = dict(where=where) |
1893 | + self.lint_rules["known charms"] = flatten_list(self.lint_rules["known charms"]) |
1894 | + self.logger.debug( |
1895 | + "[{}] [{}/{}] Lint Rules: {}".format( |
1896 | + self.cloud_name, |
1897 | + self.controller_name, |
1898 | + self.model_name, |
1899 | + pprint.pformat(self.lint_rules), |
1900 | + ) |
1901 | + ) |
1902 | + |
1903 | + def process_subordinates(self, app_d, app_name): |
1904 | + """Iterate over subordinates and run subordinate checks.""" |
1905 | + # If this is a subordinate we have nothing else to do ATM |
1906 | + if "units" not in app_d: |
1907 | + return |
1908 | + for unit in app_d["units"]: |
1909 | + # juju_status = app_d["units"][unit]["juju-status"] |
1910 | + # workload_status = app_d["units"][unit]["workload-status"] |
1911 | + if "subordinates" in app_d["units"][unit]: |
1912 | + subordinates = app_d["units"][unit]["subordinates"].keys() |
1913 | + subordinates = [i.split("/")[0] for i in subordinates] |
1914 | + else: |
1915 | + subordinates = [] |
1916 | + self.logger.debug( |
1917 | + "[{}] [{}/{}] {}: {}".format( |
1918 | + self.cloud_name, |
1919 | + self.controller_name, |
1920 | + self.model_name, |
1921 | + unit, |
1922 | + subordinates, |
1923 | + ) |
1924 | + ) |
1925 | + machine = app_d["units"][unit]["machine"] |
1926 | + self.model.subs_on_machines.setdefault(machine, set()) |
1927 | + for sub in subordinates: |
1928 | + if sub in self.model.subs_on_machines[machine]: |
1929 | + self.model.duelling_subs.setdefault(sub, set()) |
1930 | + self.model.duelling_subs[sub].add(machine) |
1931 | + self.model.subs_on_machines[machine] = ( |
1932 | + set(subordinates) | self.model.subs_on_machines[machine] |
1933 | + ) |
1934 | + self.model.apps_on_machines.setdefault(machine, set()) |
1935 | + self.model.apps_on_machines[machine].add(app_name) |
1936 | + |
1937 | + return |
1938 | + |
1939 | + def atoi(val): |
1940 | + """Deal with complex number representations as strings, returning a number.""" |
1941 | + if type(val) != str: |
1942 | + return val |
1943 | + |
1944 | + if type(val[-1]) != str: |
1945 | + return val |
1946 | + |
1947 | + try: |
1948 | + _int = int(val[0:-1]) |
1949 | + except Exception: |
1950 | + return val |
1951 | + |
1952 | + quotient = 1024 |
1953 | + if val[-1].lower() == val[-1]: |
1954 | + quotient = 1000 |
1955 | + |
1956 | + conv = {"g": quotient ** 3, "m": quotient ** 2, "k": quotient} |
1957 | + |
1958 | + return _int * conv[val[-1].lower()] |
1959 | + |
1960 | + def gte(self, name, check_value, rule, config): |
1961 | + """Check if value is greater than or equal to the check value.""" |
1962 | + if rule in config: |
1963 | + current = self.atoi(config[rule]) |
1964 | + expected = self.atoi(check_value) |
1965 | + if current >= expected: |
1966 | + self.logger.info( |
1967 | + "[{}] [{}/{}] (PASS) Application {} has config for {} which is >= {}: {}.".format( |
1968 | + self.cloud_name, |
1969 | + self.controller_name, |
1970 | + self.model_name, |
1971 | + name, |
1972 | + rule, |
1973 | + check_value, |
1974 | + config[rule], |
1975 | + ) |
1976 | + ) |
1977 | + return True |
1978 | + self.logger.error( |
1979 | + "[{}] [{}/{}] (FAIL) Application {} has config for {} which is less than {}: {}.".format( |
1980 | + self.cloud_name, |
1981 | + self.controller_name, |
1982 | + self.model_name, |
1983 | + name, |
1984 | + rule, |
1985 | + check_value, |
1986 | + config[rule], |
1987 | + ) |
1988 | + ) |
1989 | + return False |
1990 | + self.logger.warn( |
1991 | + "[{}] [{}/{}] When checking if application {} has no config for {}, can't determine if >= than {}.".format( |
1992 | + self.cloud_name, |
1993 | + self.controller_name, |
1994 | + self.model_name, |
1995 | + name, |
1996 | + rule, |
1997 | + check_value, |
1998 | + ) |
1999 | + ) |
2000 | + return False |
2001 | + |
2002 | + def isset(self, name, check_value, rule, config): |
2003 | + """Check if value is set per rule constraints.""" |
2004 | + if rule in config: |
2005 | + if check_value is True: |
2006 | + self.logger.info( |
2007 | + "[{}] [{}/{}] (PASS) Application {} correctly has manual config for {}: {}.".format( |
2008 | + self.cloud_name, |
2009 | + self.controller_name, |
2010 | + self.model_name, |
2011 | + name, |
2012 | + rule, |
2013 | + config[rule], |
2014 | + ) |
2015 | + ) |
2016 | + return True |
2017 | + self.logger.error( |
2018 | + "[{}] [{}/{}] (FAIL) Application {} has manual config for {}: {}.".format( |
2019 | + self.cloud_name, |
2020 | + self.controller_name, |
2021 | + self.model_name, |
2022 | + name, |
2023 | + rule, |
2024 | + config[rule], |
2025 | + ) |
2026 | + ) |
2027 | + return False |
2028 | + if check_value is False: |
2029 | + self.logger.info( |
2030 | + "[{}] [{}/{}] (PASS) Application {} is correctly using default config for {}.".format( |
2031 | + self.cloud_name, self.controller_name, self.model_name, name, rule, |
2032 | + ) |
2033 | + ) |
2034 | + return True |
2035 | + self.logger.error( |
2036 | + "[{}] [{}/{}] (FAIL) Application {} has no manual config for {}.".format( |
2037 | + self.cloud_name, self.controller_name, self.model_name, name, rule, |
2038 | + ) |
2039 | + ) |
2040 | + return False |
2041 | + |
2042 | + def eq(self, name, check_value, rule, config): |
2043 | + """Check if value is matches the provided value or regex, autodetecting regex.""" |
2044 | + if rule in config: |
2045 | + match = False |
2046 | + try: |
2047 | + match = re.match(re.compile(str(check_value)), str(config[rule])) |
2048 | + except re.error: |
2049 | + match = check_value == config[rule] |
2050 | + if match: |
2051 | + self.logger.info( |
2052 | + "[{}] [{}/{}] Application {} has correct setting for {}: Expected {}, got {}.".format( |
2053 | + self.cloud_name, |
2054 | + self.controller_name, |
2055 | + self.model_name, |
2056 | + name, |
2057 | + rule, |
2058 | + check_value, |
2059 | + config[rule], |
2060 | + ) |
2061 | + ) |
2062 | + return True |
2063 | + self.logger.error( |
2064 | + "[{}] [{}/{}] Application {} has incorrect setting for {}: Expected {}, got {}.".format( |
2065 | + self.cloud_name, |
2066 | + self.controller_name, |
2067 | + self.model_name, |
2068 | + name, |
2069 | + rule, |
2070 | + check_value, |
2071 | + config[rule], |
2072 | + ) |
2073 | + ) |
2074 | + |
2075 | + def check_config(self, name, config, rules): |
2076 | + """Check application against provided rules.""" |
2077 | + rules = dict(rules) |
2078 | + for rule in rules: |
2079 | + self.logger.debug( |
2080 | + "[{}] [{}/{}] Checking {} for configuration {}".format( |
2081 | + self.cloud_name, self.controller_name, self.model_name, name, rule |
2082 | + ) |
2083 | + ) |
2084 | + for check_op, check_value in rules[rule].items(): |
2085 | + if check_op == "isset": |
2086 | + self.isset(name, check_value, rule, config) |
2087 | + elif check_op == "eq": |
2088 | + self.eq(name, check_value, rule, config) |
2089 | + elif check_op == "gte": |
2090 | + self.eq(name, check_value, rule, config) |
2091 | + else: |
2092 | + self.logger.warn( |
2093 | + "[{}] [{}/{}] Application {} has unknown check operation for {}: {}.".format( |
2094 | + self.cloud_name, |
2095 | + self.controller_name, |
2096 | + self.model_name, |
2097 | + name, |
2098 | + rule, |
2099 | + check_op, |
2100 | + ) |
2101 | + ) |
2102 | + |
2103 | + def check_configuration(self, applications): |
2104 | + """Check applicaton configs in the model.""" |
2105 | + for application in applications.keys(): |
2106 | + # look for config rules for this application |
2107 | + lint_rules = [] |
2108 | + if "charm-name" in applications[application]: |
2109 | + charm_name = applications[application]["charm-name"] |
2110 | + if "config" in self.lint_rules: |
2111 | + if charm_name in self.lint_rules["config"]: |
2112 | + lint_rules = self.lint_rules["config"][charm_name].items() |
2113 | + |
2114 | + if self.cloud_type == "openstack": |
2115 | + # process openstack config rules |
2116 | + if "openstack config" in self.lint_rules: |
2117 | + if charm_name in self.lint_rules["openstack config"]: |
2118 | + lint_rules.extend( |
2119 | + self.lint_rules["openstack config"][charm_name].items() |
2120 | + ) |
2121 | + |
2122 | + if lint_rules: |
2123 | + if "options" in applications[application]: |
2124 | + self.check_config( |
2125 | + application, |
2126 | + applications[application]["options"], |
2127 | + lint_rules, |
2128 | + ) |
2129 | + |
2130 | + def check_subs(self): |
2131 | + """Check the subordinates in the model.""" |
2132 | + all_or_nothing = set() |
2133 | + for machine in self.model.subs_on_machines: |
2134 | + for sub in self.model.subs_on_machines[machine]: |
2135 | + all_or_nothing.add(sub) |
2136 | + |
2137 | + for required_sub in self.lint_rules["subordinates"]: |
2138 | + self.model.missing_subs.setdefault(required_sub, set()) |
2139 | + self.model.extraneous_subs.setdefault(required_sub, set()) |
2140 | + self.logger.debug( |
2141 | + "[{}] [{}/{}] Checking for sub {}".format( |
2142 | + self.cloud_name, self.controller_name, self.model_name, required_sub |
2143 | + ) |
2144 | + ) |
2145 | + where = self.lint_rules["subordinates"][required_sub]["where"] |
2146 | + for machine in self.model.subs_on_machines: |
2147 | + self.logger.debug( |
2148 | + "[{}] [{}/{}] Checking on {}".format( |
2149 | + self.cloud_name, self.controller_name, self.model_name, machine |
2150 | + ) |
2151 | + ) |
2152 | + present_subs = self.model.subs_on_machines[machine] |
2153 | + apps = self.model.apps_on_machines[machine] |
2154 | + if where.startswith("on "): # only on specific apps |
2155 | + required_on = where[3:] |
2156 | + self.logger.debug( |
2157 | + "[{}] [{}/{}] Requirement {} is = from...".format( |
2158 | + self.cloud_name, |
2159 | + self.controller_name, |
2160 | + self.model_name, |
2161 | + required_on, |
2162 | + ) |
2163 | + ) |
2164 | + if required_on not in apps: |
2165 | + self.logger.debug( |
2166 | + "[{}] [{}/{}] ... NOT matched".format( |
2167 | + self.cloud_name, self.controller_name, self.model_name |
2168 | + ) |
2169 | + ) |
2170 | + continue |
2171 | + self.logger.debug("[{}] [{}/{}] ... matched") |
2172 | + # TODO this needs to be not just one app, but a list |
2173 | + elif where.startswith("all except "): # not next to this app |
2174 | + self.logger.debug( |
2175 | + "[{}] [{}/{}] requirement is != form...".format( |
2176 | + self.cloud_name, self.controller_name, self.model_name |
2177 | + ) |
2178 | + ) |
2179 | + not_on = where[11:] |
2180 | + if not_on in apps: |
2181 | + self.logger.debug( |
2182 | + "[{}] [{}/{}] ... matched, not wanted on this host".format( |
2183 | + self.cloud_name, self.controller_name, self.model_name |
2184 | + ) |
2185 | + ) |
2186 | + continue |
2187 | + elif where == "host only": |
2188 | + self.logger.debug( |
2189 | + "[{}] [{}/{}] requirement is 'host only' form....".format( |
2190 | + self.cloud_name, self.controller_name, self.model_name |
2191 | + ) |
2192 | + ) |
2193 | + if is_container(machine): |
2194 | + self.logger.debug( |
2195 | + "[{}] [{}/{}] ... and we are a container, checking".format( |
2196 | + self.cloud_name, self.controller_name, self.model_name |
2197 | + ) |
2198 | + ) |
2199 | + # XXX check alternate names? |
2200 | + if required_sub in present_subs: |
2201 | + self.logger.debug( |
2202 | + "[{}] [{}/{}] ... found extraneous sub".format( |
2203 | + self.cloud_name, |
2204 | + self.controller_name, |
2205 | + self.model_name, |
2206 | + ) |
2207 | + ) |
2208 | + for app in self.model.apps_on_machines[machine]: |
2209 | + self.model.extraneous_subs[required_sub].add(app) |
2210 | + continue |
2211 | + self.logger.debug( |
2212 | + "[{}] [{}/{}] ... and we are a host, will fallthrough".format( |
2213 | + self.cloud_name, self.controller_name, self.model_name, |
2214 | + ) |
2215 | + ) |
2216 | + elif where == "all or nothing" and required_sub not in all_or_nothing: |
2217 | + self.logger.debug( |
2218 | + "[{}] [{}/{}] requirement is 'all or nothing' and was 'nothing'.".format( |
2219 | + self.cloud_name, self.controller_name, self.model_name, |
2220 | + ) |
2221 | + ) |
2222 | + continue |
2223 | + # At this point we know we require the subordinate - we might just |
2224 | + # need to change the name we expect to see it as |
2225 | + elif where == "container aware": |
2226 | + self.logger.debug( |
2227 | + "[{}] [{}/{}] requirement is 'container aware'.".format( |
2228 | + self.cloud_name, self.controller_name, self.model_name, |
2229 | + ) |
2230 | + ) |
2231 | + if is_container(machine): |
2232 | + suffixes = self.lint_rules["subordinates"][required_sub][ |
2233 | + "container-suffixes" |
2234 | + ] |
2235 | + else: |
2236 | + suffixes = self.lint_rules["subordinates"][required_sub][ |
2237 | + "host-suffixes" |
2238 | + ] |
2239 | + self.logger.debug( |
2240 | + "[{}] [{}/{}] -> suffixes == {}".format( |
2241 | + self.cloud_name, |
2242 | + self.controller_name, |
2243 | + self.model_name, |
2244 | + suffixes, |
2245 | + ) |
2246 | + ) |
2247 | + found = False |
2248 | + for suffix in suffixes: |
2249 | + looking_for = "{}-{}".format(required_sub, suffix) |
2250 | + self.logger.debug( |
2251 | + "[{}] [{}/{}] -> Looking for {}".format( |
2252 | + self.cloud_name, |
2253 | + self.controller_name, |
2254 | + self.model_name, |
2255 | + looking_for, |
2256 | + ) |
2257 | + ) |
2258 | + if looking_for in present_subs: |
2259 | + self.logger.debug("-> FOUND!!!") |
2260 | + self.cloud_name, |
2261 | + self.controller_name, |
2262 | + self.model_name, |
2263 | + found = True |
2264 | + if not found: |
2265 | + for sub in present_subs: |
2266 | + if self.model.app_to_charm[sub] == required_sub: |
2267 | + self.logger.debug( |
2268 | + "[{}] [{}/{}] Winner winner, chicken dinner! 🍗 {}".format( |
2269 | + self.cloud_name, |
2270 | + self.controller_name, |
2271 | + self.model_name, |
2272 | + sub, |
2273 | + ) |
2274 | + ) |
2275 | + found = True |
2276 | + if not found: |
2277 | + self.logger.debug( |
2278 | + "[{}] [{}/{}] -> NOT FOUND".format( |
2279 | + self.cloud_name, self.controller_name, self.model_name, |
2280 | + ) |
2281 | + ) |
2282 | + for app in self.model.apps_on_machines[machine]: |
2283 | + self.model.missing_subs[required_sub].add(app) |
2284 | + self.logger.debug( |
2285 | + "[{}] [{}/{}] -> continue-ing back out...".format( |
2286 | + self.cloud_name, self.controller_name, self.model_name, |
2287 | + ) |
2288 | + ) |
2289 | + continue |
2290 | + elif where not in ["all", "all or nothing"]: |
2291 | + self.logger.fubar( |
2292 | + "[{}] [{}/{}] Invalid requirement '{}' on {}".format( |
2293 | + self.cloud_name, |
2294 | + self.controller_name, |
2295 | + self.model_name, |
2296 | + where, |
2297 | + required_sub, |
2298 | + ) |
2299 | + ) |
2300 | + self.logger.debug( |
2301 | + "[{}] [{}/{}] requirement is 'all' OR we fell through.".format( |
2302 | + self.cloud_name, self.controller_name, self.model_name, |
2303 | + ) |
2304 | + ) |
2305 | + if required_sub not in present_subs: |
2306 | + for sub in present_subs: |
2307 | + if self.model.app_to_charm[sub] == required_sub: |
2308 | + self.logger.debug( |
2309 | + "Winner winner, chicken dinner! 🍗 {}".format(sub) |
2310 | + ) |
2311 | + self.cloud_name, |
2312 | + self.controller_name, |
2313 | + self.model_name, |
2314 | + continue |
2315 | + self.logger.debug( |
2316 | + "[{}] [{}/{}] not found.".format( |
2317 | + self.cloud_name, self.controller_name, self.model_name, |
2318 | + ) |
2319 | + ) |
2320 | + for app in self.model.apps_on_machines[machine]: |
2321 | + self.model.missing_subs[required_sub].add(app) |
2322 | + |
2323 | + for sub in list(self.model.missing_subs.keys()): |
2324 | + if not self.model.missing_subs[sub]: |
2325 | + del self.model.missing_subs[sub] |
2326 | + for sub in list(self.model.extraneous_subs.keys()): |
2327 | + if not self.model.extraneous_subs[sub]: |
2328 | + del self.model.extraneous_subs[sub] |
2329 | + |
2330 | + def check_charms(self): |
2331 | + """Check we recognise the charms which are in the model.""" |
2332 | + for charm in self.model.charms: |
2333 | + if charm not in self.lint_rules["known charms"]: |
2334 | + self.logger.error( |
2335 | + "[{}] Charm '{}' in model {} on controller {} not recognised".format( |
2336 | + self.cloud_name, charm, self.model_name, self.controller_name |
2337 | + ) |
2338 | + ) |
2339 | + # Then look for charms we require |
2340 | + for charm in self.lint_rules["operations mandatory"]: |
2341 | + if charm not in self.model.charms: |
2342 | + self.logger.error( |
2343 | + "[{}] Ops charm '{}' in model {} on controller {} not found".format( |
2344 | + self.cloud_name, charm, self.model_name, self.controller_name |
2345 | + ) |
2346 | + ) |
2347 | + if self.cloud_type == "openstack": |
2348 | + for charm in self.lint_rules["openstack mandatory"]: |
2349 | + if charm not in self.model.charms: |
2350 | + self.logger.error( |
2351 | + "[{}] OpenStack charm '{}' in model {} on controller {} not found".format( |
2352 | + self.cloud_name, |
2353 | + charm, |
2354 | + self.model_name, |
2355 | + self.controller_name, |
2356 | + ) |
2357 | + ) |
2358 | + elif self.cloud_type == "kubernetes": |
2359 | + for charm in self.lint_rules["kubernetes mandatory"]: |
2360 | + if charm not in self.model.charms: |
2361 | + self.logger.error( |
2362 | + "[{}] [{}/{}] Kubernetes charm '{}' not found".format( |
2363 | + self.cloud_name, |
2364 | + self.controller_name, |
2365 | + self.model_name, |
2366 | + charm, |
2367 | + ) |
2368 | + ) |
2369 | + |
2370 | + def results(self): |
2371 | + """Provide results of the linting process.""" |
2372 | + if self.model.missing_subs: |
2373 | + self.logger.error("The following subordinates couldn't be found:") |
2374 | + for sub in self.model.missing_subs: |
2375 | + self.logger.error( |
2376 | + "[{}] [{}/{}] -> {} [{}]".format( |
2377 | + self.cloud_name, |
2378 | + self.controller_name, |
2379 | + self.model_name, |
2380 | + sub, |
2381 | + ", ".join(sorted(self.model.missing_subs[sub])), |
2382 | + ) |
2383 | + ) |
2384 | + if self.model.extraneous_subs: |
2385 | + self.logger.error("following subordinates where found unexpectedly:") |
2386 | + for sub in self.model.extraneous_subs: |
2387 | + self.logger.error( |
2388 | + "[{}] [{}/{}] -> {} [{}]".format( |
2389 | + self.cloud_name, |
2390 | + self.controller_name, |
2391 | + self.model_name, |
2392 | + sub, |
2393 | + ", ".join(sorted(self.model.extraneous_subs[sub])), |
2394 | + ) |
2395 | + ) |
2396 | + if self.model.duelling_subs: |
2397 | + self.logger.error( |
2398 | + "[{}] [{}/{}] following subordinates where found on machines more than once:".format( |
2399 | + self.cloud_name, self.controller_name, self.model_name, |
2400 | + ) |
2401 | + ) |
2402 | + for sub in self.model.duelling_subs: |
2403 | + self.logger.error( |
2404 | + "[{}] [{}/{}] -> {} [{}]".format( |
2405 | + self.cloud_name, |
2406 | + self.controller_name, |
2407 | + self.model_name, |
2408 | + sub, |
2409 | + ", ".join(sorted(self.model.duelling_subs[sub])), |
2410 | + ) |
2411 | + ) |
2412 | + if self.model.az_unbalanced_apps: |
2413 | + self.logger.error("The following apps are unbalanced across AZs: ") |
2414 | + for app in self.model.az_unbalanced_apps: |
2415 | + (num_units, az_counter) = self.model.az_unbalanced_apps[app] |
2416 | + az_map = ", ".join( |
2417 | + ["{}: {}".format(az, az_counter[az]) for az in az_counter] |
2418 | + ) |
2419 | + self.logger.error( |
2420 | + "[{}] [{}/{}] -> {}: {} units, deployed as: {}".format( |
2421 | + self.cloud_name, |
2422 | + self.controller_name, |
2423 | + self.model_name, |
2424 | + app, |
2425 | + num_units, |
2426 | + az_map, |
2427 | + ) |
2428 | + ) |
2429 | + |
2430 | + def map_charms(self, applications): |
2431 | + """Process applications in the model, validating and normalising the names.""" |
2432 | + for app in applications: |
2433 | + if "charm" in applications[app]: |
2434 | + charm = applications[app]["charm"] |
2435 | + match = re.match( |
2436 | + r"^(?:\w+:)?(?:~[\w-]+/)?(?:\w+/)?([a-zA-Z0-9-]+?)(?:-\d+)?$", charm |
2437 | + ) |
2438 | + if not match: |
2439 | + raise InvalidCharmNameError( |
2440 | + "charm name '{}' is invalid".format(charm) |
2441 | + ) |
2442 | + charm = match.group(1) |
2443 | + self.model.charms.add(charm) |
2444 | + self.model.app_to_charm[app] = charm |
2445 | + else: |
2446 | + self.logger.error( |
2447 | + "[{}] [{}/{}] Could not detect which charm is used for application {}".format( |
2448 | + self.cloud_name, self.controller_name, self.model_name, app |
2449 | + ) |
2450 | + ) |
2451 | + |
2452 | + def map_machines_to_az(self, machines): |
2453 | + """Map machines in the model to their availability zone.""" |
2454 | + for machine in machines: |
2455 | + if "hardware" not in machines[machine]: |
2456 | + self.logger.warn( |
2457 | + "[{}] [{}/{}] Machine {} has no hardware info; skipping.".format( |
2458 | + self.cloud_name, self.controller_name, self.model_name, machine |
2459 | + ) |
2460 | + ) |
2461 | + continue |
2462 | + |
2463 | + hardware = machines[machine]["hardware"] |
2464 | + found_az = False |
2465 | + for entry in hardware.split(): |
2466 | + if entry.startswith("availability-zone="): |
2467 | + found_az = True |
2468 | + az = entry.split("=")[1] |
2469 | + self.model.machines_to_az[machine] = az |
2470 | + break |
2471 | + if not found_az: |
2472 | + self.logger.warn( |
2473 | + "[{}] [{}/{}] Machine {} has no availability-zone info in hardware field; skipping.".format( |
2474 | + self.cloud_name, self.controller_name, self.model_name, machine |
2475 | + ) |
2476 | + ) |
2477 | + |
2478 | + def check_status(self, what, status, expected): |
2479 | + """Lint the status of a unit.""" |
2480 | + if isinstance(expected, str): |
2481 | + expected = [expected] |
2482 | + if status.get("current") not in expected: |
2483 | + self.logger.error( |
2484 | + "[{}] [{}/{}] {} has status '{}' (since: {}, message: {}); (We expected: {})".format( |
2485 | + self.cloud_name, |
2486 | + self.controller_name, |
2487 | + self.model_name, |
2488 | + what, |
2489 | + status.get("current"), |
2490 | + status.get("since"), |
2491 | + status.get("message"), |
2492 | + expected, |
2493 | + ) |
2494 | + ) |
2495 | + |
2496 | + def check_status_pair(self, name, status_type, data_d): |
2497 | + """Cross reference satus of paired constructs, like machines and units.""" |
2498 | + if status_type in ["machine", "container"]: |
2499 | + primary = "machine-status" |
2500 | + primary_expected = "running" |
2501 | + juju_expected = "started" |
2502 | + elif status_type in ["unit", "subordinate"]: |
2503 | + primary = "workload-status" |
2504 | + primary_expected = ["active", "unknown"] |
2505 | + juju_expected = "idle" |
2506 | + elif status_type in ["application"]: |
2507 | + primary = "application-status" |
2508 | + primary_expected = ["active", "unknown"] |
2509 | + juju_expected = None |
2510 | + |
2511 | + if primary in data_d: |
2512 | + self.check_status( |
2513 | + "{} {}".format(status_type.title(), name), |
2514 | + data_d[primary], |
2515 | + expected=primary_expected, |
2516 | + ) |
2517 | + if juju_expected: |
2518 | + if "juju-status" in data_d: |
2519 | + self.check_status( |
2520 | + "Juju on {} {}".format(status_type, name), |
2521 | + data_d["juju-status"], |
2522 | + expected=juju_expected, |
2523 | + ) |
2524 | + else: |
2525 | + self.logger.warn( |
2526 | + "[{}] [{}/{}] Could not determine Juju status for {}.".format( |
2527 | + self.cloud_name, self.controller_name, self.model_name, name |
2528 | + ) |
2529 | + ) |
2530 | + else: |
2531 | + self.logger.warn( |
2532 | + "[{}] [{}/{}] Could not determine appropriate status key for {}.".format( |
2533 | + self.cloud_name, self.controller_name, self.model_name, name, |
2534 | + ) |
2535 | + ) |
2536 | + |
2537 | + def check_statuses(self, juju_status, applications): |
2538 | + """Check all statuses in juju status output.""" |
2539 | + for machine_name in juju_status["machines"]: |
2540 | + self.check_status_pair( |
2541 | + machine_name, "machine", juju_status["machines"][machine_name] |
2542 | + ) |
2543 | + for container_name in juju_status["machines"][machine_name].get( |
2544 | + "container", [] |
2545 | + ): |
2546 | + self.check_status_pair( |
2547 | + container_name, |
2548 | + "container", |
2549 | + juju_status["machines"][machine_name][container_name], |
2550 | + ) |
2551 | + |
2552 | + for app_name in juju_status[applications]: |
2553 | + self.check_status_pair( |
2554 | + app_name, "application", juju_status[applications][app_name] |
2555 | + ) |
2556 | + for unit_name in juju_status[applications][app_name].get("units", []): |
2557 | + self.check_status_pair( |
2558 | + unit_name, |
2559 | + "unit", |
2560 | + juju_status[applications][app_name]["units"][unit_name], |
2561 | + ) |
2562 | + |
2563 | + # This is noisy and only covers a very theoretical corner case |
2564 | + # where a misbehaving or malicious leader unit sets the |
2565 | + # application-status to OK despite one or more units being in error |
2566 | + # state. |
2567 | + # |
2568 | + # We could revisit this later by splitting it into two passes and |
2569 | + # only warning about individual subordinate units if the |
2570 | + # application-status for the subordinate claims to be OK. |
2571 | + # |
2572 | + # for subordinate_name in juju_status[applications][app_name]["units"][unit_name].get("subordinates", []): |
2573 | + # check_status_pair(subordinate_name, "subordinate", |
2574 | + # juju_status[applications][app_name]["units"][unit_name]["subordinates"][subordinate_name]) |
2575 | + |
2576 | + def check_azs(self, applications): |
2577 | + """Lint AZ distribution.""" |
2578 | + azs = set() |
2579 | + for machine in self.model.machines_to_az: |
2580 | + azs.add(self.model.machines_to_az[machine]) |
2581 | + num_azs = len(azs) |
2582 | + if num_azs != 3: |
2583 | + self.logger.error( |
2584 | + "[{}] [{}/{}] Found {} AZs (not 3); and I don't currently know how to lint that.".format( |
2585 | + self.cloud_name, self.controller_name, self.model_name, num_azs |
2586 | + ) |
2587 | + ) |
2588 | + return |
2589 | + |
2590 | + for app_name in applications: |
2591 | + az_counter = collections.Counter() |
2592 | + for az in azs: |
2593 | + az_counter[az] = 0 |
2594 | + num_units = len(applications[app_name].get("units", [])) |
2595 | + if num_units <= 1: |
2596 | + continue |
2597 | + min_per_az = num_units // num_azs |
2598 | + for unit in applications[app_name]["units"]: |
2599 | + machine = applications[app_name]["units"][unit]["machine"] |
2600 | + machine = machine.split("/")[0] |
2601 | + if machine not in self.model.machines_to_az: |
2602 | + self.logger.error( |
2603 | + "[{}] [{}/{}] {}: Can't find machine {} in machine to AZ mapping data".format( |
2604 | + self.cloud_name, |
2605 | + self.controller_name, |
2606 | + self.model_name, |
2607 | + app_name, |
2608 | + machine, |
2609 | + ) |
2610 | + ) |
2611 | + continue |
2612 | + az_counter[self.model.machines_to_az[machine]] += 1 |
2613 | + for az in az_counter: |
2614 | + num_this_az = az_counter[az] |
2615 | + if num_this_az < min_per_az: |
2616 | + self.model.az_unbalanced_apps[app_name] = [num_units, az_counter] |
2617 | + |
2618 | + def lint_yaml_string(self, yaml): |
2619 | + """Lint provided YAML string.""" |
2620 | + parsed_yaml = yaml.safe_load(yaml) |
2621 | + return self.do_lint(parsed_yaml) |
2622 | + |
2623 | + def lint_yaml_file(self, filename): |
2624 | + """Load and lint provided YAML file.""" |
2625 | + if filename: |
2626 | + with open(filename, "r") as infile: |
2627 | + parsed_yaml = yaml.safe_load(infile.read()) |
2628 | + if parsed_yaml: |
2629 | + return self.do_lint(parsed_yaml) |
2630 | + self.logger.fubar("Failed to parse YAML from file {}".format(filename)) |
2631 | + |
2632 | + def do_lint(self, parsed_yaml): |
2633 | + """Lint parsed YAML.""" |
2634 | + # Handle Juju 2 vs Juju 1 |
2635 | + applications = "applications" |
2636 | + if applications not in parsed_yaml: |
2637 | + applications = "services" |
2638 | + |
2639 | + if applications in parsed_yaml: |
2640 | + |
2641 | + # Build a list of deployed charms and mapping of charms <-> applications |
2642 | + self.map_charms(parsed_yaml[applications]) |
2643 | + |
2644 | + # Check configuration |
2645 | + self.check_configuration(parsed_yaml[applications]) |
2646 | + |
2647 | + # Then map out subordinates to applications |
2648 | + for app in parsed_yaml[applications]: |
2649 | + self.process_subordinates(parsed_yaml[applications][app], app) |
2650 | + |
2651 | + self.check_subs() |
2652 | + self.check_charms() |
2653 | + |
2654 | + if parsed_yaml.get("machines"): |
2655 | + self.map_machines_to_az(parsed_yaml["machines"]) |
2656 | + self.check_azs(parsed_yaml[applications]) |
2657 | + self.check_statuses(parsed_yaml, applications) |
2658 | + else: |
2659 | + self.logger.warn( |
2660 | + ( |
2661 | + "[{}] [{}/{}] No machine status present in model." |
2662 | + "possibly a bundle without status, skipping AZ checks" |
2663 | + ).format( |
2664 | + self.cloud_name, self.model_name, self.controller_name, |
2665 | + ) |
2666 | + ) |
2667 | + |
2668 | + self.results() |
2669 | + self.logger.warn( |
2670 | + "[{}] [{}/{}] Model contains no applications, skipping.".format( |
2671 | + self.cloud_name, self.controller_name, self.model_name, |
2672 | + ) |
2673 | + ) |
2674 | diff --git a/jujulint/logging.py b/jujulint/logging.py |
2675 | new file mode 100644 |
2676 | index 0000000..00dd200 |
2677 | --- /dev/null |
2678 | +++ b/jujulint/logging.py |
2679 | @@ -0,0 +1,106 @@ |
2680 | +#!/usr/bin/env python3 |
2681 | +# This file is part of juju-lint, a tool for validating that Juju |
2682 | +# deloyments meet configurable site policies. |
2683 | +# |
2684 | +# Copyright 2018-2020 Canonical Limited. |
2685 | +# License granted by Canonical Limited. |
2686 | +# |
2687 | +# This program is free software: you can redistribute it and/or modify |
2688 | +# it under the terms of the GNU General Public License version 3, as |
2689 | +# published by the Free Software Foundation. |
2690 | +# |
2691 | +# This program is distributed in the hope that it will be useful, but |
2692 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
2693 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
2694 | +# PURPOSE. See the GNU General Public License for more details. |
2695 | +# |
2696 | +# You should have received a copy of the GNU General Public License |
2697 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
2698 | +"""Logging helper functions.""" |
2699 | +import colorlog |
2700 | +import logging |
2701 | +import sys |
2702 | + |
2703 | + |
2704 | +class Logger: |
2705 | + """Helper class for logging.""" |
2706 | + |
2707 | + def __init__(self, level=None, logfile=None): |
2708 | + """Set up logging instance and set log level.""" |
2709 | + self.logger = colorlog.getLogger() |
2710 | + self.set_level(level) |
2711 | + if not len(self.logger.handlers): |
2712 | + format_string = "%(log_color)s%(asctime)s [%(levelname)s] %(message)s" |
2713 | + date_format = "%Y-%m-%d %H:%M:%S" |
2714 | + colour_formatter = colorlog.ColoredFormatter( |
2715 | + format_string, |
2716 | + datefmt=date_format, |
2717 | + log_colors={ |
2718 | + "DEBUG": "cyan", |
2719 | + "INFO": "green", |
2720 | + "WARNING": "yellow", |
2721 | + "ERROR": "red", |
2722 | + "CRITICAL": "red,bg_white", |
2723 | + }, |
2724 | + ) |
2725 | + console = colorlog.StreamHandler() |
2726 | + console.setFormatter(colour_formatter) |
2727 | + self.logger.addHandler(console) |
2728 | + if logfile: |
2729 | + try: |
2730 | + file_logger = colorlog.getLogger("file") |
2731 | + plain_formatter = logging.Formatter( |
2732 | + format_string, datefmt=date_format |
2733 | + ) |
2734 | + # If we send output to the file logger specifically, don't propagate it |
2735 | + # to the root logger as well to avoid duplicate output. So if we want |
2736 | + # to only send logging output to the file, you would do this: |
2737 | + # logging.getLogger('file').info("message for logfile only") |
2738 | + # rather than this: |
2739 | + # logging.info("message for console and logfile") |
2740 | + file_logger.propagate = False |
2741 | + |
2742 | + file_handler = logging.FileHandler(logfile) |
2743 | + file_handler.setFormatter(plain_formatter) |
2744 | + self.logger.addHandler(file_handler) |
2745 | + file_logger.addHandler(file_handler) |
2746 | + except IOError: |
2747 | + logging.error("Unable to write to logfile: {}".format(logfile)) |
2748 | + |
2749 | + def fubar(self, msg, exit_code=1): |
2750 | + """Exit and print to stderr because everything is FUBAR.""" |
2751 | + sys.stderr.write("E: %s\n" % (msg)) |
2752 | + sys.exit(exit_code) |
2753 | + |
2754 | + def set_level(self, level="info"): |
2755 | + """Set the level to the provided level.""" |
2756 | + if level: |
2757 | + level = level.lower() |
2758 | + else: |
2759 | + return False |
2760 | + |
2761 | + if level == "debug": |
2762 | + logging.basicConfig(level=logging.DEBUG) |
2763 | + elif level == "warn": |
2764 | + self.logger.setLevel(logging.WARN) |
2765 | + elif level == "error": |
2766 | + self.logger.setLevel(logging.ERROR) |
2767 | + else: |
2768 | + self.logger.setLevel(logging.INFO) |
2769 | + return True |
2770 | + |
2771 | + def debug(self, message): |
2772 | + """Log a message with debug loglevel.""" |
2773 | + self.logger.debug(message) |
2774 | + |
2775 | + def warn(self, message): |
2776 | + """Log a message with warn loglevel.""" |
2777 | + self.logger.warn(message) |
2778 | + |
2779 | + def info(self, message): |
2780 | + """Log a message with info loglevel.""" |
2781 | + self.logger.info(message) |
2782 | + |
2783 | + def error(self, message): |
2784 | + """Log a message with warn loglevel.""" |
2785 | + self.logger.error(message) |
2786 | diff --git a/jujulint/openstack.py b/jujulint/openstack.py |
2787 | new file mode 100644 |
2788 | index 0000000..398721e |
2789 | --- /dev/null |
2790 | +++ b/jujulint/openstack.py |
2791 | @@ -0,0 +1,71 @@ |
2792 | +#!/usr/bin/env python3 |
2793 | +# This file is part of juju-lint, a tool for validating that Juju |
2794 | +# deloyments meet configurable site policies. |
2795 | +# |
2796 | +# Copyright 2018-2020 Canonical Limited. |
2797 | +# License granted by Canonical Limited. |
2798 | +# |
2799 | +# This program is free software: you can redistribute it and/or modify |
2800 | +# it under the terms of the GNU General Public License version 3, as |
2801 | +# published by the Free Software Foundation. |
2802 | +# |
2803 | +# This program is distributed in the hope that it will be useful, but |
2804 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
2805 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
2806 | +# PURPOSE. See the GNU General Public License for more details. |
2807 | +# |
2808 | +# You should have received a copy of the GNU General Public License |
2809 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
2810 | +"""OpenStack checks module. |
2811 | + |
2812 | +This module provides checks for OpenStack clouds. |
2813 | + |
2814 | +Attributes: |
2815 | + access (string): Set the access method (local/ssh) |
2816 | + ssh_host (string, optional): Configuration to pass to Cloud module for accessing the cloud via SSH |
2817 | + ssh_jump (string, optional): Jump/Bastion configuration to pass to Cloud module for access the cloud via SSH |
2818 | + sudo_user (string, optional): User to switch to via sudo when accessing the cloud, passed to the Cloud module |
2819 | + |
2820 | +Todo: |
2821 | + * Check neutron configuration |
2822 | + * Check MTU configuration on neutron-api and OVS charms |
2823 | + * Check neutron units interface config for MTU settings |
2824 | + * Check namespaces and MTUs within namespaces |
2825 | + * Check OpenStack network definitions for MTU mismatches |
2826 | + * Check OVS configuration |
2827 | + * Check nova configuration for live migration settings |
2828 | + * Check Ceph for sensible priorities and placement |
2829 | + |
2830 | +""" |
2831 | + |
2832 | +from jujulint.cloud import Cloud |
2833 | + |
2834 | + |
2835 | +class OpenStack(Cloud): |
2836 | + """Helper class for interacting with Nagios via the livestatus socket.""" |
2837 | + |
2838 | + def __init__(self, *args, **kwargs): |
2839 | + """Initialise class-local variables and configuration and pass to super.""" |
2840 | + super(OpenStack, self).__init__(*args, **kwargs) |
2841 | + self.cloud_type = "openstack" |
2842 | + |
2843 | + def get_neutron_ports(self): |
2844 | + """Get a list of neutron ports.""" |
2845 | + |
2846 | + def get_neutron_routers(self): |
2847 | + """Get a list of neutron routers.""" |
2848 | + |
2849 | + def get_neutron_networks(self): |
2850 | + """Get a list of neutron networks.""" |
2851 | + |
2852 | + def refresh(self): |
2853 | + """Refresh cloud information.""" |
2854 | + return super(OpenStack, self).refresh() |
2855 | + |
2856 | + def audit(self): |
2857 | + """Audit OpenStack cloud and run base Cloud audits.""" |
2858 | + # add specific OpenStack checks here |
2859 | + self.logger.info( |
2860 | + "[{}] Running OpenStack-specific audit steps.".format(self.name) |
2861 | + ) |
2862 | + super(OpenStack, self).audit() |
2863 | diff --git a/jujulint/util.py b/jujulint/util.py |
2864 | new file mode 100644 |
2865 | index 0000000..c646b28 |
2866 | --- /dev/null |
2867 | +++ b/jujulint/util.py |
2868 | @@ -0,0 +1,38 @@ |
2869 | +#! /usr/bin/env python3 |
2870 | +# This file is part of juju-lint, a tool for validating that Juju |
2871 | +# deloyments meet configurable site policies. |
2872 | +# |
2873 | +# Copyright 2018-2020 Canonical Limited. |
2874 | +# License granted by Canonical Limited. |
2875 | +# |
2876 | +# This program is free software: you can redistribute it and/or modify |
2877 | +# it under the terms of the GNU General Public License version 3, as |
2878 | +# published by the Free Software Foundation. |
2879 | +# |
2880 | +# This program is distributed in the hope that it will be useful, but |
2881 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
2882 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
2883 | +# PURPOSE. See the GNU General Public License for more details. |
2884 | +# |
2885 | +# You should have received a copy of the GNU General Public License |
2886 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
2887 | +"""Utility library for all helpful functions this project uses.""" |
2888 | + |
2889 | + |
2890 | +def flatten_list(lumpy_list): |
2891 | + """Flatten a list potentially containing other lists.""" |
2892 | + flat_list = [] |
2893 | + for item in lumpy_list: |
2894 | + if not isinstance(item, list): |
2895 | + flat_list.append(item) |
2896 | + else: |
2897 | + flat_list.extend(flatten_list(item)) |
2898 | + return flat_list |
2899 | + |
2900 | + |
2901 | +def is_container(machine): |
2902 | + """Check if a provided machine is a container.""" |
2903 | + if "/" in machine: |
2904 | + return True |
2905 | + else: |
2906 | + return False |
2907 | diff --git a/requirements.txt b/requirements.txt |
2908 | new file mode 100644 |
2909 | index 0000000..9527545 |
2910 | --- /dev/null |
2911 | +++ b/requirements.txt |
2912 | @@ -0,0 +1,6 @@ |
2913 | +attrs>=18.1.0 |
2914 | +colorlog>=4.1.0 |
2915 | +confuse>=1.1.0 |
2916 | +fabric2>=2.5.0 |
2917 | +setuptools>=46.1.3 |
2918 | +PyYAML>=5.3.1 |
2919 | diff --git a/setup.py b/setup.py |
2920 | index c82caa1..4190e7a 100644 |
2921 | --- a/setup.py |
2922 | +++ b/setup.py |
2923 | @@ -15,6 +15,7 @@ |
2924 | # |
2925 | # You should have received a copy of the GNU General Public License |
2926 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
2927 | +"""Setuptools packaging metadata for juju-lint.""" |
2928 | |
2929 | import re |
2930 | import setuptools |
2931 | @@ -25,26 +26,26 @@ warnings.simplefilter("ignore", UserWarning) # Older pips complain about newer |
2932 | with open("README.md", "r") as fh: |
2933 | long_description = fh.read() |
2934 | |
2935 | -with open("debian/changelog", "r") as fh: |
2936 | - version = re.search(r'\((.*)\)', fh.readline()).group(1) |
2937 | - |
2938 | setuptools.setup( |
2939 | - name="juju-lint", |
2940 | - version=version, |
2941 | + name="jujulint", |
2942 | + use_scm_version={ |
2943 | + "local_scheme": "node-and-date", |
2944 | + }, |
2945 | author="Canonical", |
2946 | author_email="juju@lists.ubuntu.com", |
2947 | description="Linter for Juju models to compare deployments with configurable policy", |
2948 | long_description=long_description, |
2949 | long_description_content_type="text/markdown", |
2950 | url="https://launchpad.net/juju-lint", |
2951 | - classifiers=( |
2952 | + classifiers=[ |
2953 | "Programming Language :: Python :: 3", |
2954 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", |
2955 | "Development Status :: 2 - Beta", |
2956 | "Environment :: Plugins", |
2957 | - "Intended Audience :: System Administrators"), |
2958 | - python_requires='>=3.4', |
2959 | + "Intended Audience :: System Administrators", |
2960 | + ], |
2961 | + python_requires=">=3.4", |
2962 | py_modules=["jujulint"], |
2963 | - entry_points={ |
2964 | - 'console_scripts': [ |
2965 | - 'juju-lint=jujulint:main']}) |
2966 | + entry_points={"console_scripts": ["juju-lint=jujulint.cli:main"]}, |
2967 | + setup_requires=["setuptools_scm"], |
2968 | +) |
2969 | diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml |
2970 | index ddf7ea5..b8bd6da 100644 |
2971 | --- a/snap/snapcraft.yaml |
2972 | +++ b/snap/snapcraft.yaml |
2973 | @@ -1,30 +1,34 @@ |
2974 | +--- |
2975 | name: juju-lint |
2976 | -version: auto |
2977 | +base: core18 |
2978 | summary: Linter for Juju models to compare deployments with configurable policy |
2979 | -description: Linter for Juju models to compare deployments with configurable policy |
2980 | +adopt-info: juju-lint |
2981 | +description: | |
2982 | + Linter for remote or local Juju models. |
2983 | + Compares remote deployments with configurable policy rules. |
2984 | + Linting can also be performed on a YAML file representing cloud state. |
2985 | grade: stable |
2986 | confinement: classic |
2987 | -version-script: | |
2988 | - echo `head -1 debian/changelog | sed -rn 's/.*\((.*)\).*/\1/p'`-`git describe --dirty --always --tags | sed -r 's/v(.*)/\1/g'` |
2989 | apps: |
2990 | juju-lint: |
2991 | - command: usr/bin/python3 $SNAP/bin/juju-lint |
2992 | + command: juju-lint |
2993 | environment: |
2994 | PATH: "/snap/juju-lint/current/bin:/snap/juju-lint/current/usr/bin:/bin:/usr/bin:" |
2995 | + PYTHONPATH: $SNAP/usr/lib/python3.6/site-packages:$SNAP/usr/lib/python3.6/dist-packages:$PYTHONPATH |
2996 | parts: |
2997 | juju-lint: |
2998 | plugin: python |
2999 | python-version: python3 |
3000 | - python-packages: |
3001 | - - 3rdparty/attrs-18.1.0.tar.gz |
3002 | - build-packages: |
3003 | - - python3-setuptools |
3004 | - stage-packages: |
3005 | - - libc6 |
3006 | - - python3-yaml |
3007 | + requirements: |
3008 | + - requirements.txt |
3009 | source: . |
3010 | - source-type: git |
3011 | + override-build: | |
3012 | + snapcraftctl build |
3013 | + echo "Version: $(python3 setup.py --version)" |
3014 | + snapcraftctl set-version "$(python3 setup.py --version)" |
3015 | juju-lint-contrib: |
3016 | + after: |
3017 | + - juju-lint |
3018 | plugin: dump |
3019 | source: . |
3020 | prime: |
3021 | diff --git a/tests/conftest.py b/tests/conftest.py |
3022 | new file mode 100644 |
3023 | index 0000000..c95eb69 |
3024 | --- /dev/null |
3025 | +++ b/tests/conftest.py |
3026 | @@ -0,0 +1,60 @@ |
3027 | +#! /usr/bin/env python |
3028 | +# -*- coding: utf-8 -*- |
3029 | +# vim:fenc=utf-8 |
3030 | +# |
3031 | +# Copyright © 2020 James Hebden <james.hebden@canonical.com> |
3032 | +# |
3033 | +# Distributed under terms of the GPL license. |
3034 | + |
3035 | +"""Test fixtures for juju-lint tool.""" |
3036 | + |
3037 | +import mock |
3038 | +import os |
3039 | +import pytest |
3040 | +import sys |
3041 | + |
3042 | +# bring in top level library to path |
3043 | +test_path = os.path.dirname(os.path.abspath(__file__)) |
3044 | +sys.path.insert(0, test_path + "/../") |
3045 | + |
3046 | + |
3047 | +@pytest.fixture |
3048 | +def mocked_pkg_resources(monkeypatch): |
3049 | + """Mock the pkg_resources library.""" |
3050 | + import pkg_resources |
3051 | + |
3052 | + monkeypatch.setattr(pkg_resources, "require", mock.Mock()) |
3053 | + |
3054 | + |
3055 | +@pytest.fixture |
3056 | +def cli(): |
3057 | + """Provide a test instance of the CLI class.""" |
3058 | + from jujulint.cli import Cli |
3059 | + |
3060 | + cli = Cli() |
3061 | + |
3062 | + return cli |
3063 | + |
3064 | + |
3065 | +@pytest.fixture |
3066 | +def utils(): |
3067 | + """Provide a test instance of the CLI class.""" |
3068 | + from jujulint import util |
3069 | + |
3070 | + return util |
3071 | + |
3072 | + |
3073 | +@pytest.fixture |
3074 | +def parser(monkeypatch): |
3075 | + """Mock the configuration parser.""" |
3076 | + monkeypatch.setattr('jujulint.config.ArgumentParser', mock.Mock()) |
3077 | + |
3078 | + |
3079 | +@pytest.fixture |
3080 | +def lint(parser): |
3081 | + """Provide test fixture for the linter class.""" |
3082 | + from jujulint.lint import Linter |
3083 | + |
3084 | + linter = Linter('mockcloud', 'mockrules.yaml') |
3085 | + |
3086 | + return linter |
3087 | diff --git a/tests/requirements.txt b/tests/requirements.txt |
3088 | index 71f6c69..c20bbcd 100644 |
3089 | --- a/tests/requirements.txt |
3090 | +++ b/tests/requirements.txt |
3091 | @@ -1,3 +1,12 @@ |
3092 | # Module requirements |
3093 | -pyyaml |
3094 | -attrs |
3095 | +flake8 |
3096 | +flake8-colors |
3097 | +flake8-docstrings |
3098 | +flake8-html |
3099 | +mock |
3100 | +pep8-naming |
3101 | +pycodestyle |
3102 | +pyflakes |
3103 | +pytest |
3104 | +pytest-cov |
3105 | +pytest-html |
3106 | diff --git a/tests/test_cli.py b/tests/test_cli.py |
3107 | new file mode 100644 |
3108 | index 0000000..c5a9e95 |
3109 | --- /dev/null |
3110 | +++ b/tests/test_cli.py |
3111 | @@ -0,0 +1,14 @@ |
3112 | +#!/usr/bin/python3 |
3113 | +"""Test the CLI.""" |
3114 | + |
3115 | +from jujulint.cli import Cli |
3116 | + |
3117 | + |
3118 | +def test_pytest(): |
3119 | + """Test that pytest itself works.""" |
3120 | + assert True |
3121 | + |
3122 | + |
3123 | +def test_cli_fixture(cli): |
3124 | + """Test if the CLI fixture works.""" |
3125 | + assert isinstance(cli, Cli) |
3126 | diff --git a/tests/test_jujulint.py b/tests/test_jujulint.py |
3127 | index acf5cdc..ba2dbf7 100644 |
3128 | --- a/tests/test_jujulint.py |
3129 | +++ b/tests/test_jujulint.py |
3130 | @@ -1,33 +1,36 @@ |
3131 | #!/usr/bin/python3 |
3132 | +"""Tests for jujulint.""" |
3133 | |
3134 | -import unittest |
3135 | - |
3136 | +import pytest |
3137 | import jujulint |
3138 | |
3139 | |
3140 | -class TestJujuLint(unittest.TestCase): |
3141 | +def test_flatten_list(utils): |
3142 | + """Test the utils flatten_list function.""" |
3143 | + unflattened_list = [1, [2, 3]] |
3144 | + flattened_list = [1, 2, 3] |
3145 | + assert flattened_list == utils.flatten_list(unflattened_list) |
3146 | |
3147 | - def test_flatten_list(self): |
3148 | - unflattened_list = [1, [2, 3]] |
3149 | - flattened_list = [1, 2, 3] |
3150 | - self.assertEqual(flattened_list, jujulint.flatten_list(unflattened_list)) |
3151 | + unflattened_list = [1, [2, [3, 4]]] |
3152 | + flattened_list = [1, 2, 3, 4] |
3153 | + assert flattened_list == utils.flatten_list(unflattened_list) |
3154 | |
3155 | - unflattened_list = [1, [2, [3, 4]]] |
3156 | - flattened_list = [1, 2, 3, 4] |
3157 | - self.assertEqual(flattened_list, jujulint.flatten_list(unflattened_list)) |
3158 | |
3159 | - def test_map_charms(self): |
3160 | - model = jujulint.ModelInfo() |
3161 | - applications = {'test-app-1': {'charm': "cs:~USER/SERIES/TEST-CHARM12-123"}, |
3162 | - 'test-app-2': {'charm': "cs:~USER/TEST-CHARM12-123"}, |
3163 | - 'test-app-3': {'charm': "cs:TEST-CHARM12-123"}, |
3164 | - 'test-app-4': {'charm': "local:SERIES/TEST-CHARM12"}, |
3165 | - 'test-app-5': {'charm': "local:TEST-CHARM12"}, |
3166 | - 'test-app-6': {'charm': "cs:~TEST-CHARMERS/TEST-CHARM12-123"}, |
3167 | - } |
3168 | - jujulint.map_charms(applications, model) |
3169 | - for charm in model.charms: |
3170 | - self.assertEqual("TEST-CHARM12", charm) |
3171 | - applications = {'test-app1': {'charm': "cs:invalid-charm$"}, } |
3172 | - with self.assertRaises(jujulint.InvalidCharmNameError): |
3173 | - jujulint.map_charms(applications, model) |
3174 | +def test_map_charms(lint): |
3175 | + """Test the charm name validation code.""" |
3176 | + applications = { |
3177 | + "test-app-1": {"charm": "cs:~USER/SERIES/TEST-CHARM12-123"}, |
3178 | + "test-app-2": {"charm": "cs:~USER/TEST-CHARM12-123"}, |
3179 | + "test-app-3": {"charm": "cs:TEST-CHARM12-123"}, |
3180 | + "test-app-4": {"charm": "local:SERIES/TEST-CHARM12"}, |
3181 | + "test-app-5": {"charm": "local:TEST-CHARM12"}, |
3182 | + "test-app-6": {"charm": "cs:~TEST-CHARMERS/TEST-CHARM12-123"}, |
3183 | + } |
3184 | + lint.map_charms(applications) |
3185 | + for charm in lint.model.charms: |
3186 | + assert "TEST-CHARM12" == charm |
3187 | + applications = { |
3188 | + "test-app1": {"charm": "cs:invalid-charm$"}, |
3189 | + } |
3190 | + with pytest.raises(jujulint.lint.InvalidCharmNameError): |
3191 | + lint.map_charms(applications) |
3192 | diff --git a/tox.ini b/tox.ini |
3193 | index a3cb544..cf22f0f 100644 |
3194 | --- a/tox.ini |
3195 | +++ b/tox.ini |
3196 | @@ -1,22 +1,45 @@ |
3197 | [flake8] |
3198 | +exclude = |
3199 | + .git, |
3200 | + __pycache__, |
3201 | + .tox, |
3202 | +max-line-length = 120 |
3203 | +max-complexity = 10 |
3204 | ignore = C901 |
3205 | -max_line_length = 120 |
3206 | -max_complexity = 10 |
3207 | -hang_closing = yes |
3208 | |
3209 | [tox] |
3210 | -envlist = py3-{lint,test} |
3211 | -skipsdist = true |
3212 | +skipsdist=True |
3213 | +envlist = lintverbose, unit |
3214 | +skip_missing_interpreters = True |
3215 | |
3216 | [testenv] |
3217 | -install_command= |
3218 | - pip install --no-cache-dir --no-deps --find-links {toxinidir}/3rdparty --upgrade {opts} {packages} |
3219 | +basepython = python3 |
3220 | deps = |
3221 | - lint: flake8 == 3.5.0 |
3222 | - lint: pyflakes == 2.0.0 |
3223 | - lint: pycodestyle == 2.3.1 |
3224 | - test: -r{toxinidir}/tests/requirements.txt |
3225 | + -r{toxinidir}/tests/requirements.txt |
3226 | + -r{toxinidir}/requirements.txt |
3227 | commands = |
3228 | lint: flake8 {toxinidir} |
3229 | test: python3 -m unittest discover tests |
3230 | |
3231 | +[testenv:unit] |
3232 | +commands = pytest -v \ |
3233 | + --cov=jujulint \ |
3234 | + --cov-report=term \ |
3235 | + --cov-report=annotate:tests/report/coverage-annotated \ |
3236 | + --cov-report=html:tests/report/coverage-html \ |
3237 | + --html=tests/report/index.html \ |
3238 | + --junitxml=tests/report/junit.xml |
3239 | +setenv = PYTHONPATH={toxinidir}/lib |
3240 | + |
3241 | +[testenv:lint] |
3242 | +commands = flake8 jujulint --format=html --htmldir=tests/report/lint/ --tee |
3243 | + |
3244 | +[testenv:lintverbose] |
3245 | +commands = flake8 jujulint |
3246 | + |
3247 | +[testenv:lintjunit] |
3248 | +commands = flake8 jujulint --format junit-xml --output-file=report/lint/junit.xml |
3249 | + |
3250 | +[pytest] |
3251 | +filterwarnings = |
3252 | + ignore::DeprecationWarning |
This merge proposal is being monitored by mergebot. Change the status to Approved to merge.