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