Merge ~ec0/juju-lint:modular into juju-lint:master

Proposed by James Hebden
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)
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-than-or-equal to, isset and equality for testing values against a baseline lint rules file
 * 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-than-or-equal to, isset and equality for testing values against a baseline lint rules file
 * 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.

To post a comment you must log in.
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

This merge proposal is being monitored by mergebot. Change the status to Approved to merge.

Revision history for this message
🤖 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.

Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

Change successfully merged at revision 020be3e78f7964d1ce6934e82c690c0e1d4462dd

Preview Diff

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

Subscribers

People subscribed via source and target branches