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
diff --git a/.gitignore b/.gitignore
index 60a29ef..1f49ca6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,17 @@
4.tox/4.tox/
5juju_lint.egg-info/5juju_lint.egg-info/
6dist/6dist/
7tests/report/
8
9# python artefacts
10__pycache__
11*.pyc
12.eggs
13*.egg-info
14
15# test artefacts
16tests/report
17.coverage
718
8# debuild cruft19# debuild cruft
9debian/files20debian/files
@@ -14,3 +25,6 @@ parts/
14prime/25prime/
15snap/.snapcraft/26snap/.snapcraft/
16stage/27stage/
28
29# runtime
30output/
diff --git a/.python-version b/.python-version
17new file mode 10064431new file mode 100644
index 0000000..a08ffae
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
13.8.2
diff --git a/3rdparty/attrs-18.1.0.tar.gz b/3rdparty/attrs-18.1.0.tar.gz
0deleted file mode 1006442deleted file mode 100644
index 0d7c713..0000000
1Binary files a/3rdparty/attrs-18.1.0.tar.gz and /dev/null differ3Binary files a/3rdparty/attrs-18.1.0.tar.gz and /dev/null differ
diff --git a/3rdparty/flake8-3.5.0.tar.gz b/3rdparty/flake8-3.5.0.tar.gz
2deleted file mode 1006444deleted file mode 100644
index 78e01fd..0000000
3Binary files a/3rdparty/flake8-3.5.0.tar.gz and /dev/null differ5Binary files a/3rdparty/flake8-3.5.0.tar.gz and /dev/null differ
diff --git a/3rdparty/pycodestyle-2.3.1.tar.gz b/3rdparty/pycodestyle-2.3.1.tar.gz
4deleted file mode 1006446deleted file mode 100644
index 76de222..0000000
5Binary files a/3rdparty/pycodestyle-2.3.1.tar.gz and /dev/null differ7Binary files a/3rdparty/pycodestyle-2.3.1.tar.gz and /dev/null differ
diff --git a/3rdparty/pyflakes-2.0.0.tar.gz b/3rdparty/pyflakes-2.0.0.tar.gz
6deleted file mode 1006448deleted file mode 100644
index 95d6d3e..0000000
7Binary files a/3rdparty/pyflakes-2.0.0.tar.gz and /dev/null differ9Binary files a/3rdparty/pyflakes-2.0.0.tar.gz and /dev/null differ
diff --git a/MANIFEST.in b/MANIFEST.in
8deleted file mode 10064410deleted file mode 100644
index 3bb71b3..0000000
--- a/MANIFEST.in
+++ /dev/null
@@ -1,2 +0,0 @@
1# Additional files to include in any distutils source packages.
2include debian/changelog # required by setup.py
diff --git a/Pipfile b/Pipfile
3new file mode 1006440new file mode 100644
index 0000000..d2acf80
--- /dev/null
+++ b/Pipfile
@@ -0,0 +1,17 @@
1[[source]]
2url = "https://pypi.python.org/simple"
3verify_ssl = true
4name = "pypi"
5
6[packages]
7attrs = ">=18.1.0"
8colorlog = ">=4.1.0"
9confuse = ">=1.1.0"
10fabric2 = ">=2.5.0"
11setuptools = ">=46.1.3"
12PyYAML = ">=5.3.1"
13
14[dev-packages]
15
16[requires]
17python_version = "3.6"
diff --git a/Pipfile.lock b/Pipfile.lock
0new file mode 10064418new file mode 100644
index 0000000..c43f7b6
--- /dev/null
+++ b/Pipfile.lock
@@ -0,0 +1,199 @@
1{
2 "_meta": {
3 "hash": {
4 "sha256": "e1534fcda4a023c68f70df4e53b34a9bafcd9ec48c4f8d93e20dda25b3c43cd4"
5 },
6 "pipfile-spec": 6,
7 "requires": {
8 "python_version": "3.6"
9 },
10 "sources": [
11 {
12 "name": "pypi",
13 "url": "https://pypi.python.org/simple",
14 "verify_ssl": true
15 }
16 ]
17 },
18 "default": {
19 "attrs": {
20 "hashes": [
21 "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
22 "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
23 ],
24 "index": "pypi",
25 "version": "==19.3.0"
26 },
27 "bcrypt": {
28 "hashes": [
29 "sha256:0258f143f3de96b7c14f762c770f5fc56ccd72f8a1857a451c1cd9a655d9ac89",
30 "sha256:0b0069c752ec14172c5f78208f1863d7ad6755a6fae6fe76ec2c80d13be41e42",
31 "sha256:19a4b72a6ae5bb467fea018b825f0a7d917789bcfe893e53f15c92805d187294",
32 "sha256:5432dd7b34107ae8ed6c10a71b4397f1c853bd39a4d6ffa7e35f40584cffd161",
33 "sha256:6305557019906466fc42dbc53b46da004e72fd7a551c044a827e572c82191752",
34 "sha256:69361315039878c0680be456640f8705d76cb4a3a3fe1e057e0f261b74be4b31",
35 "sha256:6fe49a60b25b584e2f4ef175b29d3a83ba63b3a4df1b4c0605b826668d1b6be5",
36 "sha256:74a015102e877d0ccd02cdeaa18b32aa7273746914a6c5d0456dd442cb65b99c",
37 "sha256:763669a367869786bb4c8fcf731f4175775a5b43f070f50f46f0b59da45375d0",
38 "sha256:8b10acde4e1919d6015e1df86d4c217d3b5b01bb7744c36113ea43d529e1c3de",
39 "sha256:9fe92406c857409b70a38729dbdf6578caf9228de0aef5bc44f859ffe971a39e",
40 "sha256:a190f2a5dbbdbff4b74e3103cef44344bc30e61255beb27310e2aec407766052",
41 "sha256:a595c12c618119255c90deb4b046e1ca3bcfad64667c43d1166f2b04bc72db09",
42 "sha256:c9457fa5c121e94a58d6505cadca8bed1c64444b83b3204928a866ca2e599105",
43 "sha256:cb93f6b2ab0f6853550b74e051d297c27a638719753eb9ff66d1e4072be67133",
44 "sha256:ce4e4f0deb51d38b1611a27f330426154f2980e66582dc5f438aad38b5f24fc1",
45 "sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7",
46 "sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc"
47 ],
48 "version": "==3.1.7"
49 },
50 "cffi": {
51 "hashes": [
52 "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff",
53 "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b",
54 "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac",
55 "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0",
56 "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384",
57 "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26",
58 "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6",
59 "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b",
60 "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e",
61 "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd",
62 "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2",
63 "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66",
64 "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc",
65 "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8",
66 "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55",
67 "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4",
68 "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5",
69 "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d",
70 "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78",
71 "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa",
72 "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793",
73 "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f",
74 "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a",
75 "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f",
76 "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30",
77 "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f",
78 "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3",
79 "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c"
80 ],
81 "version": "==1.14.0"
82 },
83 "colorlog": {
84 "hashes": [
85 "sha256:30aaef5ab2a1873dec5da38fd6ba568fa761c9fa10b40241027fa3edea47f3d2",
86 "sha256:732c191ebbe9a353ec160d043d02c64ddef9028de8caae4cfa8bd49b6afed53e"
87 ],
88 "index": "pypi",
89 "version": "==4.1.0"
90 },
91 "confuse": {
92 "hashes": [
93 "sha256:adc1979ea6f4c0dd3d6fe06020c189843a649082ab8f6fb54db16f4ac5e5e1da"
94 ],
95 "index": "pypi",
96 "version": "==1.1.0"
97 },
98 "cryptography": {
99 "hashes": [
100 "sha256:091d31c42f444c6f519485ed528d8b451d1a0c7bf30e8ca583a0cac44b8a0df6",
101 "sha256:18452582a3c85b96014b45686af264563e3e5d99d226589f057ace56196ec78b",
102 "sha256:1dfa985f62b137909496e7fc182dac687206d8d089dd03eaeb28ae16eec8e7d5",
103 "sha256:1e4014639d3d73fbc5ceff206049c5a9a849cefd106a49fa7aaaa25cc0ce35cf",
104 "sha256:22e91636a51170df0ae4dcbd250d318fd28c9f491c4e50b625a49964b24fe46e",
105 "sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b",
106 "sha256:651448cd2e3a6bc2bb76c3663785133c40d5e1a8c1a9c5429e4354201c6024ae",
107 "sha256:726086c17f94747cedbee6efa77e99ae170caebeb1116353c6cf0ab67ea6829b",
108 "sha256:844a76bc04472e5135b909da6aed84360f522ff5dfa47f93e3dd2a0b84a89fa0",
109 "sha256:88c881dd5a147e08d1bdcf2315c04972381d026cdb803325c03fe2b4a8ed858b",
110 "sha256:96c080ae7118c10fcbe6229ab43eb8b090fccd31a09ef55f83f690d1ef619a1d",
111 "sha256:a0c30272fb4ddda5f5ffc1089d7405b7a71b0b0f51993cb4e5dbb4590b2fc229",
112 "sha256:bb1f0281887d89617b4c68e8db9a2c42b9efebf2702a3c5bf70599421a8623e3",
113 "sha256:c447cf087cf2dbddc1add6987bbe2f767ed5317adb2d08af940db517dd704365",
114 "sha256:c4fd17d92e9d55b84707f4fd09992081ba872d1a0c610c109c18e062e06a2e55",
115 "sha256:d0d5aeaedd29be304848f1c5059074a740fa9f6f26b84c5b63e8b29e73dfc270",
116 "sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e",
117 "sha256:e993468c859d084d5579e2ebee101de8f5a27ce8e2159959b6673b418fd8c785",
118 "sha256:f118a95c7480f5be0df8afeb9a11bd199aa20afab7a96bcf20409b411a3a85f0"
119 ],
120 "version": "==2.9.2"
121 },
122 "fabric2": {
123 "hashes": [
124 "sha256:29edd7848420df589a49743394a0ae6874ccb6a9fe6413c0076d42cc290dcad6",
125 "sha256:8838d9641fd4e95bfc2568aa16fc683a600de860ac52a1dc9675a4db3c6cef7c"
126 ],
127 "index": "pypi",
128 "version": "==2.5.0"
129 },
130 "invoke": {
131 "hashes": [
132 "sha256:87b3ef9d72a1667e104f89b159eaf8a514dbf2f3576885b2bbdefe74c3fb2132",
133 "sha256:93e12876d88130c8e0d7fd6618dd5387d6b36da55ad541481dfa5e001656f134",
134 "sha256:de3f23bfe669e3db1085789fd859eb8ca8e0c5d9c20811e2407fa042e8a5e15d"
135 ],
136 "version": "==1.4.1"
137 },
138 "paramiko": {
139 "hashes": [
140 "sha256:920492895db8013f6cc0179293147f830b8c7b21fdfc839b6bad760c27459d9f",
141 "sha256:9c980875fa4d2cb751604664e9a2d0f69096643f5be4db1b99599fe114a97b2f"
142 ],
143 "version": "==2.7.1"
144 },
145 "pycparser": {
146 "hashes": [
147 "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0",
148 "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"
149 ],
150 "version": "==2.20"
151 },
152 "pynacl": {
153 "hashes": [
154 "sha256:06cbb4d9b2c4bd3c8dc0d267416aaed79906e7b33f114ddbf0911969794b1cc4",
155 "sha256:11335f09060af52c97137d4ac54285bcb7df0cef29014a1a4efe64ac065434c4",
156 "sha256:2fe0fc5a2480361dcaf4e6e7cea00e078fcda07ba45f811b167e3f99e8cff574",
157 "sha256:30f9b96db44e09b3304f9ea95079b1b7316b2b4f3744fe3aaecccd95d547063d",
158 "sha256:511d269ee845037b95c9781aa702f90ccc36036f95d0f31373a6a79bd8242e25",
159 "sha256:537a7ccbea22905a0ab36ea58577b39d1fa9b1884869d173b5cf111f006f689f",
160 "sha256:54e9a2c849c742006516ad56a88f5c74bf2ce92c9f67435187c3c5953b346505",
161 "sha256:757250ddb3bff1eecd7e41e65f7f833a8405fede0194319f87899690624f2122",
162 "sha256:7757ae33dae81c300487591c68790dfb5145c7d03324000433d9a2c141f82af7",
163 "sha256:7c6092102219f59ff29788860ccb021e80fffd953920c4a8653889c029b2d420",
164 "sha256:8122ba5f2a2169ca5da936b2e5a511740ffb73979381b4229d9188f6dcb22f1f",
165 "sha256:9c4a7ea4fb81536c1b1f5cc44d54a296f96ae78c1ebd2311bd0b60be45a48d96",
166 "sha256:cd401ccbc2a249a47a3a1724c2918fcd04be1f7b54eb2a5a71ff915db0ac51c6",
167 "sha256:d452a6746f0a7e11121e64625109bc4468fc3100452817001dbe018bb8b08514",
168 "sha256:ea6841bc3a76fa4942ce00f3bda7d436fda21e2d91602b9e21b7ca9ecab8f3ff",
169 "sha256:f8851ab9041756003119368c1e6cd0b9c631f46d686b3904b18c0139f4419f80"
170 ],
171 "version": "==1.4.0"
172 },
173 "pyyaml": {
174 "hashes": [
175 "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
176 "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
177 "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
178 "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
179 "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
180 "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
181 "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
182 "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
183 "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
184 "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
185 "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
186 ],
187 "index": "pypi",
188 "version": "==5.3.1"
189 },
190 "six": {
191 "hashes": [
192 "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
193 "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
194 ],
195 "version": "==1.15.0"
196 }
197 },
198 "develop": {}
199}
diff --git a/README.md b/README.md
index 534395d..00d737d 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,42 @@
1= Juju Lint =1= Juju Lint =
22
3/!\ This is alpha software and backwards incompatible changes are expected.
4
5== Introduction ==3== Introduction ==
64
7This is intended to be run against a yaml dump of Juju status, which can be5This is intended to be run against a yaml dump of Juju status, a YAML dump of
8generated as follows:6a juju bundle or a remote cloud or clouds via SSH.
7
8To generate a status if you just want to audit placement:
99
10 juju status --format yaml > status.yaml10 juju status --format yaml > status.yaml
1111
12For auditing configuration, you would want:
13
14 juju export-bundle > bundle.yaml
15
12Then run `juju-lint` (using a rules file of `lint-rules.yaml`):16Then run `juju-lint` (using a rules file of `lint-rules.yaml`):
1317
14 ./juju-lint status.yaml18 juju-lint -f status.yaml (or bundle.yaml)
19
20You can also enable additional checks for specific cloud types by specifying
21the cloud type with `-t` as such:
22
23 juju-lint -f bundle.yaml -t openstack
24
25For remote or mass audits, you can remote audit clouds via SSH.
26To do this, you will need to add the clouds to your config file in:
27
28 ~/.config/juju-lint/config.yaml
29
30See the example config file in the `jujulint` directory of this repo.
31This tool will use your existing SSH keys, SSH agent, and SSH config.
32If you are running from the snap, you will need to connect the `ssh-keys`
33interface in order to grant access to your SSH configuation.
1534
16To use a different rules file:35To use a different rules file:
1736
18 ./juju-lint -c my-rules.yaml status.yaml37 juju-lint -c my-rules.yaml
38
39For all other options, consult `juju-lint --help`
1940
20== Rules File ==41== Rules File ==
2142
@@ -27,10 +48,12 @@ Supported top-level options for your rules file:
27 2. `known charms` - all primary charms should be in this list.48 2. `known charms` - all primary charms should be in this list.
28 3. `operations [mandatory|optional|subordinate]`49 3. `operations [mandatory|optional|subordinate]`
29 4. `openstack [mandatory|optional|subordinate]`50 4. `openstack [mandatory|optional|subordinate]`
51 5. `config` - application configuration auditing
52 5. `[openstack|kubernetes] config` - config auditing for specific cloud types.
3053
31== License ==54== License ==
3255
33Copyright 2018 Canonical Limited.56Copyright 2020 Canonical Limited.
34License granted by Canonical Limited.57License granted by Canonical Limited.
3558
36This program is free software: you can redistribute it and/or modify59This program is free software: you can redistribute it and/or modify
diff --git a/contrib/canonical-openstack-rules.yaml b/contrib/canonical-rules.yaml
37similarity index 75%60similarity index 75%
38rename from contrib/canonical-openstack-rules.yaml61rename from contrib/canonical-openstack-rules.yaml
39rename to contrib/canonical-rules.yaml62rename to contrib/canonical-rules.yaml
index 1dc8830..3a3f666 100644
--- a/contrib/canonical-openstack-rules.yaml
+++ b/contrib/canonical-rules.yaml
@@ -1,3 +1,49 @@
1kubernetes config:
2 kubernetes-master:
3 authorization-mode:
4 eq: "RBAC,Node"
5 canal:
6 cidr:
7 isset: false
8
9openstack config:
10 neutron-api:
11 path-mtu:
12 eq: 9000
13 global-physnet-mtu:
14 eq: 9000
15 nova-compute:
16 live-migration-permit-auto-converge:
17 eq: true
18 live-migration-permit-post-copy:
19 eq: true
20 cpu-model:
21 isset: true
22 percona-cluster:
23 innodb-buffer-pool-size:
24 gte: 6G
25 max-connections:
26 gte: 2000
27 mysql-innodb-cluster:
28 innodb-buffer-pool-size:
29 gte: 6G
30 max-connections:
31 gte: 2000
32 rabbitmq-server:
33 cluster-partition-handling:
34 eq: "pause_minority"
35 keystone:
36 token-expiration:
37 gte: 86400
38
39config:
40 hacluster:
41 cluster_count:
42 gte: 3
43 ntp:
44 auto_peers:
45 eq: true
46
1subordinates:47subordinates:
2 telegraf:48 telegraf:
3 where: all except prometheus # and prometheus-ceph-exporter and prometheus-openstack-exporter49 where: all except prometheus # and prometheus-ceph-exporter and prometheus-openstack-exporter
diff --git a/debian/changelog b/debian/changelog
4deleted file mode 10064450deleted file mode 100644
index c45b19e..0000000
--- a/debian/changelog
+++ /dev/null
@@ -1,11 +0,0 @@
1juju-lint (1.0.0.dev2) xenial; urgency=medium
2
3 * Add canonical-openstack-rules.yaml in contrib directory.
4
5 -- Tom Haddon <tom.haddon@canonical.com> Thu, 02 Aug 2018 18:08:27 +0100
6
7juju-lint (1.0.0.dev1) xenial; urgency=medium
8
9 * Initial release.
10
11 -- Stuart Bishop (Work) <stuart.bishop@canonical.com> Tue, 24 Jul 2018 17:36:40 +0700
diff --git a/debian/compat b/debian/compat
12deleted file mode 1006440deleted file mode 100644
index 7f8f011..0000000
--- a/debian/compat
+++ /dev/null
@@ -1 +0,0 @@
17
diff --git a/debian/control b/debian/control
2deleted file mode 1006440deleted file mode 100644
index a434417..0000000
--- a/debian/control
+++ /dev/null
@@ -1,19 +0,0 @@
1Source: juju-lint
2Section: admin
3Priority: extra
4Maintainer: Juju Linters <juju@lists.ubuntu.com>
5Build-Depends:
6 debhelper,
7 python3-all,
8 python3-setuptools
9Standards-Version: 3.9.3
10X-Python-Version: >= 3.4
11
12Package: juju-lint
13Architecture: all
14Depends:
15 ${python3:Depends},
16 ${misc:Depends},
17 python3-yaml,
18 python3-attr (>= 18.1)
19Description: Linter for Juju models to compare deployments with configurable policy
diff --git a/debian/copyright b/debian/copyright
20deleted file mode 1006440deleted file mode 100644
index 4270311..0000000
--- a/debian/copyright
+++ /dev/null
@@ -1,25 +0,0 @@
1Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
2Upstream-Name: juju-lint
3Source: https://code.launchpad.net/juju-lint
4
5Files: *
6Copyright: 2018 Canonical Limited <juju@lists.ubuntu.com>
7License: GPL-3
8 Copyright 2018 Canonical Limited.
9 License granted by Canonical Limited.
10 .
11 This program is free software: you can redistribute it and/or modify
12 it under the terms of the GNU General Public License version 3, as
13 published by the Free Software Foundation.
14 .
15 This program is distributed in the hope that it will be useful, but
16 WITHOUT ANY WARRANTY; without even the implied warranties of
17 MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
18 PURPOSE. See the GNU General Public License for more details.
19 .
20 You should have received a copy of the GNU General Public License
21 along with this program. If not, see <http://www.gnu.org/licenses/>.
22 .
23 On Debian systems, the full text of the GNU General Public
24 License version 3 can be found in the file
25 `/usr/share/common-licenses/GPL-3'.
diff --git a/debian/install b/debian/install
26deleted file mode 1006440deleted file mode 100644
index 2def499..0000000
--- a/debian/install
+++ /dev/null
@@ -1 +0,0 @@
1contrib/* usr/share/juju-lint/contrib
diff --git a/debian/rules b/debian/rules
2deleted file mode 1007550deleted file mode 100755
index 43797a6..0000000
--- a/debian/rules
+++ /dev/null
@@ -1,15 +0,0 @@
1#!/usr/bin/make -f
2# -*- makefile -*-
3# Sample debian/rules that uses debhelper.
4# This file was originally written by Joey Hess and Craig Small.
5# As a special exception, when this file is copied by dh-make into a
6# dh-make output file, you may use that output file without restriction.
7# This special exception was added by Craig Small in version 0.37 of dh-make.
8
9# Uncomment this to turn on verbose mode.
10#export DH_VERBOSE=1
11
12export PYBUILD_NAME=juju_lint
13
14%:
15 dh $@ --with=python3 --without-python2 --buildsystem=pybuild
diff --git a/example-lint-rules.yaml b/example-lint-rules.yaml
index 70776d4..e0c6830 100644
--- a/example-lint-rules.yaml
+++ b/example-lint-rules.yaml
@@ -1,5 +1,39 @@
1---
2config:
3 example-charm:
4 example-setting:
5 eq: true
6
1subordinates:7subordinates:
2 telegraf:8 telegraf:
3 where: all9 where: all
4 landscape-client:10 landscape-client:
5 where: all11 where: all
12 ntp:
13 where: all
14
15# the below "example" cloud depicts an application running on apache2
16# which relies on mysql and rabbitmq, and should have ntp deployed
17# across all notes. etcd may also be deployed on some machines.
18example mandatory: &example-mandatory-charms
19 - apache2
20
21example mandatory deps: &example-mandatory-deps
22 - rabbitmq-server
23 - mysql
24
25example mandatory subordinates: &example-mandatory-subs
26 - ntp
27
28example optional charms: &example-optional-charms
29 - etcd
30
31example charms: &openstack-charms
32 - *example-mandatory-charms
33 - *example-mandatory-deps
34 - *example-mandatory-subs
35 - *example-optional-charms
36
37known charms:
38 - ubuntu
39 - *example-charms
diff --git a/juju-lint b/juju-lint
index 6759e60..3df527a 120000
--- a/juju-lint
+++ b/juju-lint
@@ -1 +1 @@
1jujulint.py
2\ No newline at end of file1\ No newline at end of file
2jujulint/cli.py
3\ No newline at end of file3\ No newline at end of file
diff --git a/jujulint.py b/jujulint.py
4deleted file mode 1007554deleted file mode 100755
index 47806f3..0000000
--- a/jujulint.py
+++ /dev/null
@@ -1,457 +0,0 @@
1#!/usr/bin/env python3
2
3# This file is part of juju-lint, a tool for validating that Juju
4# deloyments meet configurable site policies.
5#
6# Copyright 2018-2019 Canonical Limited.
7# License granted by Canonical Limited.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License version 3, as
11# published by the Free Software Foundation.
12#
13# This program is distributed in the hope that it will be useful, but
14# WITHOUT ANY WARRANTY; without even the implied warranties of
15# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
16# PURPOSE. See the GNU General Public License for more details.
17#
18# You should have received a copy of the GNU General Public License
19# along with this program. If not, see <http://www.gnu.org/licenses/>.
20
21import collections
22import logging
23import optparse
24import pprint
25import re
26import sys
27
28import yaml
29
30from attr import attrs, attrib
31import attr
32
33# TODO:
34# - tests
35# - non-OK statuses?
36# - missing relations for mandatory subordinates
37# - info mode, e.g. num of machines, version (e.g. look at ceph), architecture
38
39
40class InvalidCharmNameError(Exception):
41 pass
42
43
44@attrs
45class ModelInfo(object):
46 # Info obtained from juju status data
47 charms = attrib(default=attr.Factory(set))
48 app_to_charm = attrib(default=attr.Factory(dict))
49 subs_on_machines = attrib(default=attr.Factory(dict))
50 apps_on_machines = attrib(default=attr.Factory(dict))
51 machines_to_az = attrib(default=attr.Factory(dict))
52
53 # Output of our linting
54 missing_subs = attrib(default=attr.Factory(dict))
55 extraneous_subs = attrib(default=attr.Factory(dict))
56 duelling_subs = attrib(default=attr.Factory(dict))
57 az_unbalanced_apps = attrib(default=attr.Factory(dict))
58
59
60def fubar(msg, exit_code=1):
61 sys.stderr.write("E: %s\n" % (msg))
62 sys.exit(exit_code)
63
64
65def setup_logging(loglevel, logfile):
66 logFormatter = logging.Formatter(
67 fmt="%(asctime)s [%(levelname)s] %(message)s",
68 datefmt="%Y-%m-%d %H:%M:%S")
69 rootLogger = logging.getLogger()
70 rootLogger.setLevel(loglevel)
71
72 consoleHandler = logging.StreamHandler()
73 consoleHandler.setFormatter(logFormatter)
74 rootLogger.addHandler(consoleHandler)
75
76 if logfile:
77 try:
78 fileLogger = logging.getLogger('file')
79 # If we send output to the file logger specifically, don't propagate it
80 # to the root logger as well to avoid duplicate output. So if we want
81 # to only send logging output to the file, you would do this:
82 # logging.getLogger('file').info("message for logfile only")
83 # rather than this:
84 # logging.info("message for console and logfile")
85 fileLogger.propagate = False
86
87 fileHandler = logging.FileHandler(logfile)
88 fileHandler.setFormatter(logFormatter)
89 rootLogger.addHandler(fileHandler)
90 fileLogger.addHandler(fileHandler)
91 except IOError:
92 logging.error("Unable to write to logfile: {}".format(logfile))
93
94
95def flatten_list(l):
96 t = []
97 for i in l:
98 if not isinstance(i, list):
99 t.append(i)
100 else:
101 t.extend(flatten_list(i))
102 return t
103
104
105def read_rules(options):
106 with open(options.config, 'r') as yaml_file:
107 lint_rules = yaml.safe_load(yaml_file)
108 if options.override:
109 for override in options.override.split("#"):
110 (name, where) = override.split(":")
111 logging.info("Overriding %s with %s" % (name, where))
112 lint_rules["subordinates"][name] = dict(where=where)
113 lint_rules["known charms"] = flatten_list(lint_rules["known charms"])
114 logging.debug("Lint Rules: {}".format(pprint.pformat(lint_rules)))
115 return lint_rules
116
117
118def process_subordinates(app_d, app_name, model):
119 # If this is a subordinate we have nothing else to do ATM
120 if "units" not in app_d:
121 return
122 for unit in app_d["units"]:
123 # juju_status = app_d["units"][unit]["juju-status"]
124 # workload_status = app_d["units"][unit]["workload-status"]
125 if "subordinates" in app_d["units"][unit]:
126 subordinates = app_d["units"][unit]["subordinates"].keys()
127 subordinates = [i.split("/")[0] for i in subordinates]
128 else:
129 subordinates = []
130 logging.debug("%s: %s" % (unit, subordinates))
131 machine = app_d["units"][unit]["machine"]
132 model.subs_on_machines.setdefault(machine, set())
133 for sub in subordinates:
134 if sub in model.subs_on_machines[machine]:
135 model.duelling_subs.setdefault(sub, set())
136 model.duelling_subs[sub].add(machine)
137 model.subs_on_machines[machine] = (set(subordinates) |
138 model.subs_on_machines[machine])
139 model.apps_on_machines.setdefault(machine, set())
140 model.apps_on_machines[machine].add(app_name)
141
142 return
143
144
145def is_container(machine):
146 if "/" in machine:
147 return True
148 else:
149 return False
150
151
152def check_subs(model, lint_rules):
153 all_or_nothing = set()
154 for machine in model.subs_on_machines:
155 for sub in model.subs_on_machines[machine]:
156 all_or_nothing.add(sub)
157
158 for required_sub in lint_rules["subordinates"]:
159 model.missing_subs.setdefault(required_sub, set())
160 model.extraneous_subs.setdefault(required_sub, set())
161 logging.debug("Checking for sub %s" % (required_sub))
162 where = lint_rules["subordinates"][required_sub]["where"]
163 for machine in model.subs_on_machines:
164 logging.debug("Checking on %s" % (machine))
165 present_subs = model.subs_on_machines[machine]
166 apps = model.apps_on_machines[machine]
167 if where.startswith("on "): # only on specific apps
168 logging.debug("requirement is = form...")
169 required_on = where[3:]
170 if required_on not in apps:
171 logging.debug("... NOT matched")
172 continue
173 logging.debug("... matched")
174 # TODO this needs to be not just one app, but a list
175 elif where.startswith("all except "): # not next to this app
176 logging.debug("requirement is != form...")
177 not_on = where[11:]
178 if not_on in apps:
179 logging.debug("... matched, not wanted on this host")
180 continue
181 elif where == "host only":
182 logging.debug("requirement is 'host only' form....")
183 if is_container(machine):
184 logging.debug("... and we are a container, checking")
185 # XXX check alternate names?
186 if required_sub in present_subs:
187 logging.debug("... found extraneous sub")
188 for app in model.apps_on_machines[machine]:
189 model.extraneous_subs[required_sub].add(app)
190 continue
191 logging.debug("... and we are a host, will fallthrough")
192 elif (where == "all or nothing" and
193 required_sub not in all_or_nothing):
194 logging.debug("requirement is 'all or nothing' and was 'nothing'.")
195 continue
196 # At this point we know we require the subordinate - we might just
197 # need to change the name we expect to see it as
198 elif where == "container aware":
199 logging.debug("requirement is 'container aware'.")
200 if is_container(machine):
201 suffixes = lint_rules["subordinates"][required_sub]["container-suffixes"]
202 else:
203 suffixes = lint_rules["subordinates"][required_sub]["host-suffixes"]
204 logging.debug("-> suffixes == %s" % (suffixes))
205 found = False
206 for suffix in suffixes:
207 looking_for = "%s-%s" % (required_sub, suffix)
208 logging.debug("-> Looking for %s" % (looking_for))
209 if looking_for in present_subs:
210 logging.debug("-> FOUND!!!")
211 found = True
212 if not found:
213 for sub in present_subs:
214 if model.app_to_charm[sub] == required_sub:
215 logging.debug("!!: winner winner chicken dinner %s" % (sub))
216 found = True
217 if not found:
218 logging.debug("-> NOT FOUND")
219 for app in model.apps_on_machines[machine]:
220 model.missing_subs[required_sub].add(app)
221 logging.debug("-> continue-ing back out...")
222 continue
223 elif where not in ["all", "all or nothing"]:
224 fubar("invalid requirement '%s' on %s" % (where, required_sub))
225 logging.debug("requirement is 'all' OR we fell through.")
226 if required_sub not in present_subs:
227 for sub in present_subs:
228 if model.app_to_charm[sub] == required_sub:
229 logging.debug("!!!: winner winner chicken dinner %s" % (sub))
230 continue
231 logging.debug("not found.")
232 for app in model.apps_on_machines[machine]:
233 model.missing_subs[required_sub].add(app)
234
235 for sub in list(model.missing_subs.keys()):
236 if not model.missing_subs[sub]:
237 del model.missing_subs[sub]
238 for sub in list(model.extraneous_subs.keys()):
239 if not model.extraneous_subs[sub]:
240 del model.extraneous_subs[sub]
241
242
243def check_charms(model, lint_rules):
244 # Check we recognise the charms which are there
245 for charm in model.charms:
246 if charm not in lint_rules["known charms"]:
247 logging.error("charm '%s' not recognised" % (charm))
248 # Then look for charms we require
249 for charm in lint_rules["operations mandatory"]:
250 if charm not in model.charms:
251 logging.error("ops charm '%s' not found" % (charm))
252 for charm in lint_rules["openstack mandatory"]:
253 if charm not in model.charms:
254 logging.error("OpenStack charm '%s' not found" % (charm))
255
256
257def results(model):
258 if model.missing_subs:
259 logging.info("The following subordinates couldn't be found:")
260 for sub in model.missing_subs:
261 logging.error(" -> %s [%s]" % (sub, ", ".join(sorted(model.missing_subs[sub]))))
262 if model.extraneous_subs:
263 logging.info("following subordinates where found unexpectedly:")
264 for sub in model.extraneous_subs:
265 logging.error(" -> %s [%s]" % (sub, ", ".join(sorted(model.extraneous_subs[sub]))))
266 if model.duelling_subs:
267 logging.info("following subordinates where found on machines more than once:")
268 for sub in model.duelling_subs:
269 logging.error(" -> %s [%s]" % (sub, ", ".join(sorted(model.duelling_subs[sub]))))
270 if model.az_unbalanced_apps:
271 logging.error("The following apps are unbalanced across AZs: ")
272 for app in model.az_unbalanced_apps:
273 (num_units, az_counter) = model.az_unbalanced_apps[app]
274 az_map = ", ".join(["%s: %s" % (az, az_counter[az]) for az in az_counter])
275 logging.error(" -> %s: %s units, deployed as: %s" % (app, num_units, az_map))
276
277
278def map_charms(applications, model):
279 for app in applications:
280 charm = applications[app]["charm"]
281 match = re.match(r'^(?:\w+:)?(?:~[\w-]+/)?(?:\w+/)?([a-zA-Z0-9-]+?)(?:-\d+)?$', charm)
282 if not match:
283 raise InvalidCharmNameError("charm name '{}' is invalid".format(charm))
284 charm = match.group(1)
285 model.charms.add(charm)
286 model.app_to_charm[app] = charm
287
288
289def map_machines_to_az(machines, model):
290 for machine in machines:
291 if "hardware" not in machines[machine]:
292 logging.error("I: Machine %s has no hardware info; skipping." % (machine))
293 continue
294
295 hardware = machines[machine]["hardware"]
296 found_az = False
297 for entry in hardware.split():
298 if entry.startswith("availability-zone="):
299 found_az = True
300 az = entry.split("=")[1]
301 model.machines_to_az[machine] = az
302 break
303 if not found_az:
304 logging.error("I: Machine %s has no availability-zone info in hardware field; skipping." % (machine))
305
306
307def check_status(what, status, expected):
308 if isinstance(expected, str):
309 expected = [expected]
310 if status.get("current") not in expected:
311 logging.error("%s has status '%s' (since: %s, message: %s); {We expected: %s}"
312 % (what, status.get("current"), status.get("since"),
313 status.get("message"), expected))
314
315
316def check_status_pair(name, status_type, data_d):
317 if status_type in ["machine", "container"]:
318 primary = "machine-status"
319 primary_expected = "running"
320 juju_expected = "started"
321 elif status_type in ["unit", "subordinate"]:
322 primary = "workload-status"
323 primary_expected = ["active", "unknown"]
324 juju_expected = "idle"
325 elif status_type in ["application"]:
326 primary = "application-status"
327 primary_expected = ["active", "unknown"]
328 juju_expected = None
329
330 check_status("%s %s" % (status_type.title(), name), data_d[primary],
331 expected=primary_expected)
332 if juju_expected:
333 check_status("Juju on %s %s" % (status_type, name),
334 data_d["juju-status"],
335 expected=juju_expected)
336
337
338def check_statuses(juju_status, applications):
339 for machine_name in juju_status["machines"]:
340 check_status_pair(machine_name, "machine", juju_status["machines"][machine_name])
341 for container_name in juju_status["machines"][machine_name].get("container", []):
342 check_status_pair(container_name, "container",
343 juju_status["machines"][machine_name][container_name])
344
345 for app_name in juju_status[applications]:
346 check_status_pair(app_name, "application",
347 juju_status[applications][app_name])
348 for unit_name in juju_status[applications][app_name].get("units", []):
349 check_status_pair(unit_name, "unit",
350 juju_status[applications][app_name]["units"][unit_name])
351 # This is noisy and only covers a very theoretical corner case
352 # where a misbehaving or malicious leader unit sets the
353 # application-status to OK despite one or more units being in error
354 # state.
355 #
356 # We could revisit this later by splitting it into two passes and
357 # only warning about individual subordinate units if the
358 # application-status for the subordinate claims to be OK.
359 #
360 # for subordinate_name in juju_status[applications][app_name]["units"][unit_name].get("subordinates", []):
361 # check_status_pair(subordinate_name, "subordinate",
362 # juju_status[applications][app_name]["units"][unit_name]["subordinates"][subordinate_name])
363
364
365def check_azs(applications, model):
366 # Figure out how many AZs we have
367 azs = set()
368 for machine in model.machines_to_az:
369 azs.add(model.machines_to_az[machine])
370 num_azs = len(azs)
371 if num_azs != 3:
372 logging.error("E: Found %s AZs (not 3); and I don't currently know how to lint that." % (num_azs))
373 return
374
375 for app_name in applications:
376 az_counter = collections.Counter()
377 for az in azs:
378 az_counter[az] = 0
379 num_units = len(applications[app_name].get("units", []))
380 if num_units <= 1:
381 continue
382 min_per_az = num_units // num_azs
383 for unit in applications[app_name]["units"]:
384 machine = applications[app_name]["units"][unit]["machine"]
385 machine = machine.split("/")[0]
386 if machine not in model.machines_to_az:
387 logging.error("E: [%s] Can't find machine %s in machine to AZ mapping data" % (app_name, machine))
388 continue
389 az_counter[model.machines_to_az[machine]] += 1
390 for az in az_counter:
391 num_this_az = az_counter[az]
392 if num_this_az < min_per_az:
393 model.az_unbalanced_apps[app_name] = [num_units, az_counter]
394
395
396def lint(filename, lint_rules):
397 model = ModelInfo()
398
399 with open(filename, 'r') as infile:
400 j = yaml.safe_load(infile.read())
401
402 # Handle Juju 2 vs Juju 1
403 applications = "applications"
404 if applications not in j:
405 applications = "services"
406
407 # Build a list of deployed charms and mapping of charms <-> applications
408 map_charms(j[applications], model)
409
410 # Then map out subordinates to applications
411 for app in j[applications]:
412 process_subordinates(j[applications][app], app, model)
413
414 map_machines_to_az(j["machines"], model)
415 check_azs(j[applications], model)
416
417 check_subs(model, lint_rules)
418 check_charms(model, lint_rules)
419
420 if j.get('machines'):
421 check_statuses(j, applications)
422 else:
423 logging.info("Not checking status, this is a bundle")
424
425 results(model)
426
427
428def init():
429 """Initalization, including parsing of options."""
430
431 usage = """usage: %prog [OPTIONS]
432 Sanity check a Juju model"""
433 parser = optparse.OptionParser(usage)
434 parser.add_option("-c", "--config", default="lint-rules.yaml",
435 help="File to read lint rules from. Defaults to `lint-rules.yaml`")
436 parser.add_option("-o", "--override-subordinate",
437 dest="override",
438 help="override lint-rules.yaml, e.g. -o canonical-livepatch:all")
439 parser.add_option("--loglevel", "-l", default='INFO',
440 help="Log level. Defaults to INFO")
441 parser.add_option("--logfile", "-L", default=None,
442 help="File to log to in addition to stdout")
443 (options, args) = parser.parse_args()
444
445 return (options, args)
446
447
448def main():
449 (options, args) = init()
450 setup_logging(options.loglevel, options.logfile)
451 lint_rules = read_rules(options)
452 for filename in args:
453 lint(filename, lint_rules)
454
455
456if __name__ == "__main__":
457 main()
diff --git a/jujulint/__init__.py b/jujulint/__init__.py
458new file mode 1006440new file mode 100644
index 0000000..3bb8cbc
--- /dev/null
+++ b/jujulint/__init__.py
@@ -0,0 +1 @@
1"""Import this library to fetch and lint the configuration and status of Juju environments."""
diff --git a/jujulint/cli.py b/jujulint/cli.py
0new file mode 1007552new file mode 100755
index 0000000..a75a7c3
--- /dev/null
+++ b/jujulint/cli.py
@@ -0,0 +1,136 @@
1#!/usr/bin/env python3
2# This file is part of juju-lint, a tool for validating that Juju
3# deloyments meet configurable site policies.
4#
5# Copyright 2018-2020 Canonical Limited.
6# License granted by Canonical Limited.
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3, as
10# published by the Free Software Foundation.
11#
12# This program is distributed in the hope that it will be useful, but
13# WITHOUT ANY WARRANTY; without even the implied warranties of
14# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
15# PURPOSE. See the GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19"""Main entrypoint for the juju-lint CLI."""
20from jujulint.config import Config
21from jujulint.lint import Linter
22from jujulint.logging import Logger
23from jujulint.openstack import OpenStack
24import pkg_resources
25import yaml
26
27
28class Cli:
29 """Core class of the CLI for juju-lint."""
30
31 clouds = {}
32
33 def __init__(self):
34 """Create new CLI and configure runtime environment."""
35 self.config = Config()
36 self.logger = Logger(self.config["logging"]["loglevel"].get())
37 self.version = pkg_resources.require("jujulint")[0].version
38 self.lint_rules = "{}/{}".format(
39 self.config.config_dir(), self.config["rules"]["file"].get()
40 )
41
42 def startup_message(self):
43 """Print startup message to log."""
44 self.logger.info(
45 (
46 "juju-lint version {} starting...\n"
47 "\t* Config directory: {}\n"
48 "\t* Log level: {}\n"
49 ).format(
50 self.version,
51 self.config.config_dir(),
52 self.config["logging"]["loglevel"].get(),
53 )
54 )
55
56 def audit_file(self, filename, cloud_type=None):
57 """Directly audit a YAML file."""
58 self.logger.debug("Starting audit of file {}".format(filename))
59 linter = Linter(filename, self.lint_rules, cloud_type=cloud_type,)
60 linter.read_rules()
61 self.logger.info("[{}] Linting manual file...".format(filename))
62 linter.lint_yaml_file(filename)
63
64 def audit_all(self):
65 """Iterate over clouds and run audit."""
66 self.logger.debug("Starting audit")
67 for cloud_name in self.config["clouds"].get():
68 self.audit(cloud_name)
69 # serialise state
70 if self.clouds:
71 self.write_yaml(self.clouds, "all-data.yaml")
72
73 def audit(self, cloud_name):
74 """Run the main audit process process each cloud."""
75 # load clouds and loop through each defined cloud
76 if cloud_name not in self.clouds.keys():
77 self.clouds[cloud_name] = {}
78 cloud = self.config["clouds"][cloud_name].get()
79 access_method = "local"
80 ssh_host = None
81 sudo_user = None
82 if "access" in cloud:
83 access_method = cloud["access"]
84 if "sudo" in cloud:
85 sudo_user = cloud["sudo"]
86 if "host" in cloud:
87 ssh_host = cloud["host"]
88 self.logger.debug(cloud)
89 # load correct handler (OpenStack)
90 if cloud["type"] == "openstack":
91 cloud_instance = OpenStack(
92 cloud_name,
93 access_method=access_method,
94 ssh_host=ssh_host,
95 sudo_user=sudo_user,
96 lint_rules=self.lint_rules,
97 )
98 # refresh information
99 result = cloud_instance.refresh()
100 if result:
101 self.clouds[cloud_name] = cloud_instance.cloud_state
102 self.logger.debug(
103 "Cloud state for {} after refresh: {}".format(
104 cloud_name, cloud_instance.cloud_state
105 )
106 )
107 self.write_yaml(
108 cloud_instance.cloud_state, "{}-state.yaml".format(cloud_name)
109 )
110 # run audit checks
111 cloud_instance.audit()
112 else:
113 self.logger.error("[{}] Failed getting cloud state".format(cloud_name))
114
115 def write_yaml(self, data, file_name):
116 """Write collected information to YAML."""
117 if "dump" in self.config["output"]:
118 if self.config["output"]["dump"]:
119 folder_name = self.config["output"]["folder"].get()
120 file_handle = open("{}/{}".format(folder_name, file_name), "w")
121 yaml.dump(data, file_handle)
122
123
124def main():
125 """Program entry point."""
126 cli = Cli()
127 cli.startup_message()
128 if "manual-file" in cli.config:
129 manual_file = cli.config["manual-file"].get()
130 if "manual-type" in cli.config:
131 manual_type = cli.config["manual-type"].get()
132 cli.audit_file(manual_file, cloud_type=manual_type)
133 else:
134 cli.audit_file(manual_file)
135 else:
136 cli.audit_all()
diff --git a/jujulint/cloud.py b/jujulint/cloud.py
0new file mode 100644137new file mode 100644
index 0000000..b3cde24
--- /dev/null
+++ b/jujulint/cloud.py
@@ -0,0 +1,376 @@
1#!/usr/bin/env python3
2# This file is part of juju-lint, a tool for validating that Juju
3# deloyments meet configurable site policies.
4#
5# Copyright 2018-2020 Canonical Limited.
6# License granted by Canonical Limited.
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3, as
10# published by the Free Software Foundation.
11#
12# This program is distributed in the hope that it will be useful, but
13# WITHOUT ANY WARRANTY; without even the implied warranties of
14# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
15# PURPOSE. See the GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19"""Cloud access module.
20
21Runs locally or uses Fabric to parse a cloud's Juju bundle and provide SSH access to the units
22
23Attributes:
24 access_method (string, optional): Set the access method (local/ssh). Defaults to local.
25 ssh_host (string, optional): Configuration to pass to Cloud module for accessing the cloud via SSH
26 sudo_user (string, optional): User to switch to via sudo when accessing the cloud, passed to the Cloud module
27
28Todo:
29 * SSH to remote host honouring SSH config
30 * Sudo to desired user
31 * Get bundle from remote host
32 * Parse bundle into dict
33 * Add function to run command on a unit, via fabric and jump host if configured
34
35"""
36from fabric2 import Connection, Config
37from paramiko.ssh_exception import SSHException
38from jujulint.logging import Logger
39from jujulint.lint import Linter
40from subprocess import check_output
41import socket
42import yaml
43
44
45class Cloud:
46 """Cloud helper class."""
47
48 def __init__(
49 self,
50 name,
51 lint_rules=None,
52 access_method="local",
53 ssh_host=None,
54 sudo_user=None,
55 lint_overrides=None,
56 cloud_type=None,
57 ):
58 """Instantiate Cloud configuration and state."""
59 # instance variables
60 self.cloud_state = {}
61 self.access_method = "local"
62 self.ssh_host = ""
63 self.sudo_user = ""
64 self.hostname = ""
65 self.name = ""
66 self.fabric_config = {}
67 self.lint_rules = lint_rules
68 self.lint_overrides = lint_overrides
69 self.cloud_type = cloud_type
70
71 # process variables
72 self.logger = Logger()
73 self.logger.debug("Configuring {} cloud.".format(access_method))
74 if sudo_user:
75 self.sudo_user = sudo_user
76 self.fabric_config = {"sudo": {"user": sudo_user}}
77 if access_method == "ssh":
78 if ssh_host:
79 self.logger.debug("SSH host: {}".format(ssh_host))
80 self.hostname = ssh_host
81 self.connection = Connection(
82 ssh_host, config=Config(overrides=self.fabric_config)
83 )
84 self.access_method = "ssh"
85 elif access_method == "local":
86 self.hostname = socket.getfqdn()
87 self.name = name
88
89 def run_command(self, command):
90 """Run a command via fabric on the local or remote host."""
91 if self.access_method == "local":
92 self.logger.debug("Running local command: {}".format(command))
93 args = command.split(" ")
94 return check_output(args)
95 elif self.access_method == "ssh":
96 if self.sudo_user:
97 self.logger.debug(
98 "Running SSH command {} on {} as {}...".format(
99 command, self.hostname, self.sudo_user
100 )
101 )
102 try:
103 result = self.connection.sudo(command, hide=True, warn=True)
104 except SSHException as e:
105 self.logger.error(
106 "[{}] SSH command {} failed: {}".format(self.name, command, e)
107 )
108 return None
109 return result.stdout
110 else:
111 self.logger.debug(
112 "Running SSH command {} on {}...".format(command, self.hostname)
113 )
114 try:
115 result = self.connection.run(command, hide=True, warn=True)
116 except SSHException as e:
117 self.logger.error(
118 "[{}] SSH command {} failed: {}".format(self.name, command, e)
119 )
120 return None
121 return result.stdout
122
123 def run_unit_command(self, target, command):
124 """Run a command on a Juju unit and return the output."""
125
126 def parse_yaml(self, yaml_string):
127 """Parse YAML using PyYAML."""
128 data = yaml.safe_load_all(yaml_string)
129 return list(data)
130
131 def get_juju_controllers(self):
132 """Get a list of Juju controllers."""
133 controller_output = self.run_command("juju controllers --format yaml")
134 if controller_output:
135 controllers = self.parse_yaml(controller_output)
136
137 if len(controllers) > 0:
138 self.logger.debug("Juju controller list: {}".format(controllers[0]))
139 if "controllers" in controllers[0]:
140 for controller in controllers[0]["controllers"].keys():
141 self.logger.info(
142 "[{}] Found Juju controller: {}".format(
143 self.name, controller
144 )
145 )
146 if controller not in self.cloud_state.keys():
147 self.cloud_state[controller] = {}
148 self.cloud_state[controller]["config"] = controllers[0][
149 "controllers"
150 ][controller]
151 return True
152 self.logger.error("[{}] Could not get controller list".format(self.name))
153 return False
154
155 def get_juju_models(self):
156 """Get a list of Juju models."""
157 result = self.get_juju_controllers()
158 if result:
159 for controller in self.cloud_state.keys():
160 self.logger.info(
161 "[{}] Getting models for controller: {}".format(
162 self.name, controller
163 )
164 )
165 models_data = self.run_command(
166 "juju models -c {} --format yaml".format(controller)
167 )
168 self.logger.debug("Getting models from: {}".format(models_data))
169 models = self.parse_yaml(models_data)
170 if len(models) > 0:
171 if "models" in models[0]:
172 for model in models[0]["models"]:
173 model_name = model["short-name"]
174 self.logger.info(
175 "[{}] Processing model {} for controller: {}".format(
176 self.name, model_name, controller
177 )
178 )
179 self.logger.debug(
180 "Processing model {} for controller {}: {}".format(
181 model_name, controller, model
182 )
183 )
184 if "models" not in self.cloud_state[controller].keys():
185 self.cloud_state[controller]["models"] = {}
186 if (
187 model_name
188 not in self.cloud_state[controller]["models"].keys()
189 ):
190 self.cloud_state[controller]["models"][model_name] = {}
191 self.cloud_state[controller]["models"][model_name][
192 "config"
193 ] = model
194 return True
195 self.logger.error("[{}] Could not get model list".format(self.name))
196 return False
197
198 def get_juju_status(self, controller, model):
199 """Get a view of juju status for a given model."""
200 status_data = self.run_command(
201 "juju status -m {}:{} --format yaml".format(controller, model)
202 )
203 status = self.parse_yaml(status_data)
204 self.logger.info(
205 "[{}] Processing Juju status for model {} on controller {}".format(
206 self.name, model, controller
207 )
208 )
209 if len(status) > 0:
210 if "model" in status[0].keys():
211 self.cloud_state[controller]["models"][model]["version"] = status[0][
212 "model"
213 ]["version"]
214 if "machines" in status[0].keys():
215 for machine in status[0]["machines"].keys():
216 machine_data = status[0]["machines"][machine]
217 self.logger.debug(
218 "Parsing status for machine {} in model {}: {}".format(
219 machine, model, machine_data
220 )
221 )
222 if "display-name" in machine_data:
223 machine_name = machine_data["display-name"]
224 else:
225 machine_name = machine
226 if "machines" not in self.cloud_state[controller]["models"][model]:
227 self.cloud_state[controller]["models"][model]["machines"] = {}
228 if (
229 "machine_name"
230 not in self.cloud_state[controller]["models"][model][
231 "machines"
232 ].keys()
233 ):
234 self.cloud_state[controller]["models"][model]["machines"][
235 machine_name
236 ] = {}
237 self.cloud_state[controller]["models"][model]["machines"][
238 machine_name
239 ].update(machine_data)
240 self.cloud_state[controller]["models"][model]["machines"][
241 machine_name
242 ]["machine_id"] = machine
243 if "applications" in status[0].keys():
244 for application in status[0]["applications"].keys():
245 application_data = status[0]["applications"][application]
246 self.logger.debug(
247 "Parsing status for application {} in model {}: {}".format(
248 application, model, application_data
249 )
250 )
251 if (
252 "applications"
253 not in self.cloud_state[controller]["models"][model]
254 ):
255 self.cloud_state[controller]["models"][model][
256 "applications"
257 ] = {}
258 if (
259 application
260 not in self.cloud_state[controller]["models"][model][
261 "applications"
262 ].keys()
263 ):
264 self.cloud_state[controller]["models"][model]["applications"][
265 application
266 ] = {}
267 self.cloud_state[controller]["models"][model]["applications"][
268 application
269 ].update(application_data)
270
271 def get_juju_bundle(self, controller, model):
272 """Get an export of the juju bundle for the provided model."""
273 bundle_data = self.run_command(
274 "juju export-bundle -m {}:{}".format(controller, model)
275 )
276 bundles = self.parse_yaml(bundle_data)
277 self.logger.info(
278 "[{}] Processing Juju bundle export for model {} on controller {}".format(
279 self.name, model, controller
280 )
281 )
282 self.logger.debug(
283 "Juju bundle for model {} on controller {}: {}".format(
284 model, controller, bundles
285 )
286 )
287 if len(bundles) > 0:
288 combined = {}
289 for bundle in bundles:
290 combined.update(bundle)
291 if "applications" in combined:
292 for application in combined["applications"].keys():
293 self.logger.debug(
294 "Parsing configuration for application {} in model {}: {}".format(
295 application, model, combined
296 )
297 )
298 application_config = combined["applications"][application]
299 if (
300 "applications"
301 not in self.cloud_state[controller]["models"][model]
302 ):
303 self.cloud_state[controller]["models"][model][
304 "applications"
305 ] = {}
306 if (
307 application
308 not in self.cloud_state[controller]["models"][model][
309 "applications"
310 ].keys()
311 ):
312 self.cloud_state[controller]["models"][model]["applications"][
313 application
314 ] = {}
315 self.cloud_state[controller]["models"][model]["applications"][
316 application
317 ].update(application_config)
318
319 def get_juju_state(self):
320 """Update our view of Juju-managed application state."""
321 self.logger.info(
322 "[{}] Getting Juju state for {}".format(self.name, self.hostname)
323 )
324 result = self.get_juju_models()
325 if result:
326 self.logger.debug(
327 "Cloud state for {} after gathering models:\n{}".format(
328 self.name, yaml.dump(self.cloud_state)
329 )
330 )
331 for controller in self.cloud_state.keys():
332 for model in self.cloud_state[controller]["models"].keys():
333 self.get_juju_status(controller, model)
334 self.get_juju_bundle(controller, model)
335 self.logger.debug(
336 "Cloud state for {} after gathering apps:\n{}".format(
337 self.name, yaml.dump(self.cloud_state)
338 )
339 )
340 return True
341 return False
342
343 def refresh(self):
344 """Refresh all information about the Juju cloud."""
345 self.logger.info(
346 "[{}] Refreshing cloud information for {}".format(self.name, self.hostname)
347 )
348 self.logger.debug("Running cloud-agnostic cloud refresh steps." "")
349 state = self.get_juju_state()
350 return state
351
352 def audit(self):
353 """Run cloud-type agnostic audit steps."""
354 self.logger.info(
355 "[{}] Auditing information for {}".format(self.name, self.hostname)
356 )
357 # run lint rules
358 self.logger.debug("Running cloud-agnostic Juju audits.")
359 if self.lint_rules:
360 for controller in self.cloud_state.keys():
361 for model in self.cloud_state[controller]["models"].keys():
362 linter = Linter(
363 self.name,
364 self.lint_rules,
365 overrides=self.lint_overrides,
366 cloud_type=self.cloud_type,
367 controller_name=controller,
368 model_name=model,
369 )
370 linter.read_rules()
371 self.logger.info(
372 "[{}] Linting model information for {}, controller {}, model {}...".format(
373 self.name, self.hostname, controller, model
374 )
375 )
376 linter.do_lint(self.cloud_state[controller]["models"][model])
diff --git a/jujulint/config.py b/jujulint/config.py
0new file mode 100644377new file mode 100644
index 0000000..b4d1e1c
--- /dev/null
+++ b/jujulint/config.py
@@ -0,0 +1,101 @@
1#!/usr/bin/env python3
2# This file is part of juju-lint, a tool for validating that Juju
3# deloyments meet configurable site policies.
4#
5# Copyright 2018-2020 Canonical Limited.
6# License granted by Canonical Limited.
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3, as
10# published by the Free Software Foundation.
11#
12# This program is distributed in the hope that it will be useful, but
13# WITHOUT ANY WARRANTY; without even the implied warranties of
14# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
15# PURPOSE. See the GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19"""Config handling routines."""
20
21from confuse import Configuration
22from argparse import ArgumentParser
23
24
25class Config(Configuration):
26 """Helper class for holding parsed config, extending confuse's BaseConfiguraion class."""
27
28 def __init__(self):
29 """Wrap the initialisation of confuse's Configuration object providing defaults for our application."""
30 super().__init__("juju-lint", __name__)
31
32 parser = ArgumentParser(description="Sanity check one or more Juju models")
33 parser.add_argument(
34 "-l",
35 "--log-level",
36 type=str,
37 default="info",
38 nargs="?",
39 help="The default log level, valid options are info, warn, error or debug",
40 dest="logging.loglevel",
41 )
42 parser.add_argument(
43 "-d",
44 "--output-dir",
45 type=str,
46 default="output",
47 nargs="?",
48 help="The folder to use when saving gathered cloud data and lint reports.",
49 dest="output.folder",
50 )
51 parser.add_argument(
52 "--dump-state",
53 type=str,
54 help=(
55 "Optionally, dump cloud state as YAML into --output-dir."
56 "Use with caution, as dumps will contain sensitve data."
57 ),
58 dest="output.dump",
59 )
60 parser.add_argument(
61 "-c",
62 "--config",
63 default="lint-rules.yaml",
64 help="File to read lint rules from. Defaults to `lint-rules.yaml`",
65 dest="rules.file",
66 )
67 parser.add_argument(
68 "manual-file",
69 metavar="manual-file",
70 nargs='?',
71 type=str,
72 default=None,
73 help=(
74 "File to read state from. Supports bundles and status output in YAML format."
75 "Setting this disables collection of data from remote or local clouds configured via config.yaml."
76 ),
77 )
78 parser.add_argument(
79 "-t",
80 "--cloud-type",
81 help=(
82 "Sets the cloud type when specifying a YAML file to audit with -f or --cloud-file."
83 ),
84 dest="manual-type",
85 )
86 parser.add_argument(
87 "-o",
88 "--override-subordinate",
89 dest="override.subordinate",
90 help="override lint-rules.yaml, e.g. -o canonical-livepatch:all",
91 )
92 parser.add_argument(
93 "--logfile",
94 "-L",
95 default=None,
96 help="File to log to in addition to stdout",
97 dest="logging.file",
98 )
99
100 args = parser.parse_args()
101 self.set_args(args, dots=True)
diff --git a/jujulint/config_default.yaml b/jujulint/config_default.yaml
0new file mode 100644102new file mode 100644
index 0000000..19cbe91
--- /dev/null
+++ b/jujulint/config_default.yaml
@@ -0,0 +1,34 @@
1---
2clouds:
3 # an example local Juju-deployed OpenStack
4 cloud1:
5 type: openstack
6 access: local
7
8 # an example remote Juju-deployed OpenStack
9 cloud2:
10 type: openstack
11 access: ssh
12 host: 'ubuntu@openstack.example.fake'
13
14 # an exported bundle
15 yamlfile:
16 type: openstack
17 access: dump
18 file: 'export.yaml'
19
20 # an example remote Juju-deployed OpenStack where
21 # Juju controllers are registered under user 'juju-user
22 # in this example cloud, so we sudo to that user
23 cloud3:
24 type: openstack
25 access: ssh
26 host: 'ubuntu@openstack2.example.fake'
27 sudo: 'juju-user'
28
29logging:
30 level: INFO
31 file: jujulint.log
32
33rules:
34 file: lint-rules.yaml
diff --git a/jujulint/k8s.py b/jujulint/k8s.py
0new file mode 10064435new file mode 100644
index 0000000..53b8c30
--- /dev/null
+++ b/jujulint/k8s.py
@@ -0,0 +1,65 @@
1#!/usr/bin/env python3
2# This file is part of juju-lint, a tool for validating that Juju
3# deloyments meet configurable site policies.
4#
5# Copyright 2018-2020 Canonical Limited.
6# License granted by Canonical Limited.
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3, as
10# published by the Free Software Foundation.
11#
12# This program is distributed in the hope that it will be useful, but
13# WITHOUT ANY WARRANTY; without even the implied warranties of
14# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
15# PURPOSE. See the GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19"""Kubernetes checks module.
20
21This module provides checks for Kubernetes clouds.
22
23Attributes:
24 access (string): Set the access method (local/ssh)
25 ssh_host (string, optional): Configuration to pass to Cloud module for accessing the cloud via SSH
26 ssh_jump (string, optional): Jump/Bastion configuration to pass to Cloud module for access the cloud via SSH
27 sudo_user (string, optional): User to switch to via sudo when accessing the cloud, passed to the Cloud module
28
29Todo:
30 * Add processing of kubectl information
31 * Pass cloud type back to lint module
32 * Add rules for k8s
33 * Check OpenStack integrator charm configuration
34 * Check distribution of k8s workloads to workers
35
36"""
37
38from jujulint.cloud import Cloud
39
40
41class OpenStack(Cloud):
42 """Helper class for interacting with Nagios via the livestatus socket."""
43
44 def __init__(self, *args, **kwargs):
45 """Initialise class-local variables and configuration and pass to super."""
46 super(OpenStack, self).__init__(*args, **kwargs)
47
48 def get_neutron_ports(self):
49 """Get a list of neutron ports."""
50
51 def get_neutron_routers(self):
52 """Get a list of neutron routers."""
53
54 def get_neutron_networks(self):
55 """Get a list of neutron networks."""
56
57 def refresh(self):
58 """Refresh cloud information."""
59 return super(OpenStack, self).refresh()
60
61 def audit(self):
62 """Audit OpenStack cloud and run base Cloud audits."""
63 # add specific OpenStack checks here
64 self.logger.debug("Running OpenStack-specific audit steps.")
65 super(OpenStack, self).audit()
diff --git a/jujulint/lint.py b/jujulint/lint.py
0new file mode 10075566new file mode 100755
index 0000000..92e56d6
--- /dev/null
+++ b/jujulint/lint.py
@@ -0,0 +1,882 @@
1#!/usr/bin/env python3
2
3# This file is part of juju-lint, a tool for validating that Juju
4# deloyments meet configurable site policies.
5#
6# Copyright 2018-2020 Canonical Limited.
7# License granted by Canonical Limited.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License version 3, as
11# published by the Free Software Foundation.
12#
13# This program is distributed in the hope that it will be useful, but
14# WITHOUT ANY WARRANTY; without even the implied warranties of
15# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
16# PURPOSE. See the GNU General Public License for more details.
17#
18# You should have received a copy of the GNU General Public License
19# along with this program. If not, see <http://www.gnu.org/licenses/>.
20"""Lint operations and rule processing engine."""
21import collections
22import pprint
23import re
24
25import yaml
26
27from attr import attrs, attrib
28import attr
29
30from jujulint.util import flatten_list, is_container
31from jujulint.logging import Logger
32
33# TODO:
34# - tests
35# - missing relations for mandatory subordinates
36# - info mode, e.g. num of machines, version (e.g. look at ceph), architecture
37
38
39class InvalidCharmNameError(Exception):
40 """Represents an invalid charm name being processed."""
41
42 pass
43
44
45@attrs
46class ModelInfo(object):
47 """Represent information obtained from juju status data."""
48
49 charms = attrib(default=attr.Factory(set))
50 app_to_charm = attrib(default=attr.Factory(dict))
51 subs_on_machines = attrib(default=attr.Factory(dict))
52 apps_on_machines = attrib(default=attr.Factory(dict))
53 machines_to_az = attrib(default=attr.Factory(dict))
54
55 # Output of our linting
56 missing_subs = attrib(default=attr.Factory(dict))
57 extraneous_subs = attrib(default=attr.Factory(dict))
58 duelling_subs = attrib(default=attr.Factory(dict))
59 az_unbalanced_apps = attrib(default=attr.Factory(dict))
60
61
62class Linter:
63 """Linter for a Juju model, instantiate a new class for each model."""
64
65 def __init__(
66 self,
67 name,
68 filename,
69 controller_name="manual",
70 model_name="manual",
71 overrides=None,
72 cloud_type=None,
73 ):
74 """Instantiate linter."""
75 self.logger = Logger()
76 self.lint_rules = {}
77 self.model = ModelInfo()
78 self.filename = filename
79 self.overrides = overrides
80 self.cloud_name = name
81 self.cloud_type = cloud_type
82 self.controller_name = controller_name
83 self.model_name = model_name
84
85 def read_rules(self):
86 """Read and parse rules from YAML, optionally processing provided overrides."""
87 with open(self.filename, "r") as yaml_file:
88 self.lint_rules = yaml.safe_load(yaml_file)
89 if self.overrides:
90 for override in self.overrides.split("#"):
91 (name, where) = override.split(":")
92 self.logger.info(
93 "[{}] [{}/{}] Overriding {} with {}".format(
94 self.cloud_name,
95 self.controller_name,
96 self.model_name,
97 name,
98 where,
99 )
100 )
101 self.lint_rules["subordinates"][name] = dict(where=where)
102 self.lint_rules["known charms"] = flatten_list(self.lint_rules["known charms"])
103 self.logger.debug(
104 "[{}] [{}/{}] Lint Rules: {}".format(
105 self.cloud_name,
106 self.controller_name,
107 self.model_name,
108 pprint.pformat(self.lint_rules),
109 )
110 )
111
112 def process_subordinates(self, app_d, app_name):
113 """Iterate over subordinates and run subordinate checks."""
114 # If this is a subordinate we have nothing else to do ATM
115 if "units" not in app_d:
116 return
117 for unit in app_d["units"]:
118 # juju_status = app_d["units"][unit]["juju-status"]
119 # workload_status = app_d["units"][unit]["workload-status"]
120 if "subordinates" in app_d["units"][unit]:
121 subordinates = app_d["units"][unit]["subordinates"].keys()
122 subordinates = [i.split("/")[0] for i in subordinates]
123 else:
124 subordinates = []
125 self.logger.debug(
126 "[{}] [{}/{}] {}: {}".format(
127 self.cloud_name,
128 self.controller_name,
129 self.model_name,
130 unit,
131 subordinates,
132 )
133 )
134 machine = app_d["units"][unit]["machine"]
135 self.model.subs_on_machines.setdefault(machine, set())
136 for sub in subordinates:
137 if sub in self.model.subs_on_machines[machine]:
138 self.model.duelling_subs.setdefault(sub, set())
139 self.model.duelling_subs[sub].add(machine)
140 self.model.subs_on_machines[machine] = (
141 set(subordinates) | self.model.subs_on_machines[machine]
142 )
143 self.model.apps_on_machines.setdefault(machine, set())
144 self.model.apps_on_machines[machine].add(app_name)
145
146 return
147
148 def atoi(val):
149 """Deal with complex number representations as strings, returning a number."""
150 if type(val) != str:
151 return val
152
153 if type(val[-1]) != str:
154 return val
155
156 try:
157 _int = int(val[0:-1])
158 except Exception:
159 return val
160
161 quotient = 1024
162 if val[-1].lower() == val[-1]:
163 quotient = 1000
164
165 conv = {"g": quotient ** 3, "m": quotient ** 2, "k": quotient}
166
167 return _int * conv[val[-1].lower()]
168
169 def gte(self, name, check_value, rule, config):
170 """Check if value is greater than or equal to the check value."""
171 if rule in config:
172 current = self.atoi(config[rule])
173 expected = self.atoi(check_value)
174 if current >= expected:
175 self.logger.info(
176 "[{}] [{}/{}] (PASS) Application {} has config for {} which is >= {}: {}.".format(
177 self.cloud_name,
178 self.controller_name,
179 self.model_name,
180 name,
181 rule,
182 check_value,
183 config[rule],
184 )
185 )
186 return True
187 self.logger.error(
188 "[{}] [{}/{}] (FAIL) Application {} has config for {} which is less than {}: {}.".format(
189 self.cloud_name,
190 self.controller_name,
191 self.model_name,
192 name,
193 rule,
194 check_value,
195 config[rule],
196 )
197 )
198 return False
199 self.logger.warn(
200 "[{}] [{}/{}] When checking if application {} has no config for {}, can't determine if >= than {}.".format(
201 self.cloud_name,
202 self.controller_name,
203 self.model_name,
204 name,
205 rule,
206 check_value,
207 )
208 )
209 return False
210
211 def isset(self, name, check_value, rule, config):
212 """Check if value is set per rule constraints."""
213 if rule in config:
214 if check_value is True:
215 self.logger.info(
216 "[{}] [{}/{}] (PASS) Application {} correctly has manual config for {}: {}.".format(
217 self.cloud_name,
218 self.controller_name,
219 self.model_name,
220 name,
221 rule,
222 config[rule],
223 )
224 )
225 return True
226 self.logger.error(
227 "[{}] [{}/{}] (FAIL) Application {} has manual config for {}: {}.".format(
228 self.cloud_name,
229 self.controller_name,
230 self.model_name,
231 name,
232 rule,
233 config[rule],
234 )
235 )
236 return False
237 if check_value is False:
238 self.logger.info(
239 "[{}] [{}/{}] (PASS) Application {} is correctly using default config for {}.".format(
240 self.cloud_name, self.controller_name, self.model_name, name, rule,
241 )
242 )
243 return True
244 self.logger.error(
245 "[{}] [{}/{}] (FAIL) Application {} has no manual config for {}.".format(
246 self.cloud_name, self.controller_name, self.model_name, name, rule,
247 )
248 )
249 return False
250
251 def eq(self, name, check_value, rule, config):
252 """Check if value is matches the provided value or regex, autodetecting regex."""
253 if rule in config:
254 match = False
255 try:
256 match = re.match(re.compile(str(check_value)), str(config[rule]))
257 except re.error:
258 match = check_value == config[rule]
259 if match:
260 self.logger.info(
261 "[{}] [{}/{}] Application {} has correct setting for {}: Expected {}, got {}.".format(
262 self.cloud_name,
263 self.controller_name,
264 self.model_name,
265 name,
266 rule,
267 check_value,
268 config[rule],
269 )
270 )
271 return True
272 self.logger.error(
273 "[{}] [{}/{}] Application {} has incorrect setting for {}: Expected {}, got {}.".format(
274 self.cloud_name,
275 self.controller_name,
276 self.model_name,
277 name,
278 rule,
279 check_value,
280 config[rule],
281 )
282 )
283
284 def check_config(self, name, config, rules):
285 """Check application against provided rules."""
286 rules = dict(rules)
287 for rule in rules:
288 self.logger.debug(
289 "[{}] [{}/{}] Checking {} for configuration {}".format(
290 self.cloud_name, self.controller_name, self.model_name, name, rule
291 )
292 )
293 for check_op, check_value in rules[rule].items():
294 if check_op == "isset":
295 self.isset(name, check_value, rule, config)
296 elif check_op == "eq":
297 self.eq(name, check_value, rule, config)
298 elif check_op == "gte":
299 self.eq(name, check_value, rule, config)
300 else:
301 self.logger.warn(
302 "[{}] [{}/{}] Application {} has unknown check operation for {}: {}.".format(
303 self.cloud_name,
304 self.controller_name,
305 self.model_name,
306 name,
307 rule,
308 check_op,
309 )
310 )
311
312 def check_configuration(self, applications):
313 """Check applicaton configs in the model."""
314 for application in applications.keys():
315 # look for config rules for this application
316 lint_rules = []
317 if "charm-name" in applications[application]:
318 charm_name = applications[application]["charm-name"]
319 if "config" in self.lint_rules:
320 if charm_name in self.lint_rules["config"]:
321 lint_rules = self.lint_rules["config"][charm_name].items()
322
323 if self.cloud_type == "openstack":
324 # process openstack config rules
325 if "openstack config" in self.lint_rules:
326 if charm_name in self.lint_rules["openstack config"]:
327 lint_rules.extend(
328 self.lint_rules["openstack config"][charm_name].items()
329 )
330
331 if lint_rules:
332 if "options" in applications[application]:
333 self.check_config(
334 application,
335 applications[application]["options"],
336 lint_rules,
337 )
338
339 def check_subs(self):
340 """Check the subordinates in the model."""
341 all_or_nothing = set()
342 for machine in self.model.subs_on_machines:
343 for sub in self.model.subs_on_machines[machine]:
344 all_or_nothing.add(sub)
345
346 for required_sub in self.lint_rules["subordinates"]:
347 self.model.missing_subs.setdefault(required_sub, set())
348 self.model.extraneous_subs.setdefault(required_sub, set())
349 self.logger.debug(
350 "[{}] [{}/{}] Checking for sub {}".format(
351 self.cloud_name, self.controller_name, self.model_name, required_sub
352 )
353 )
354 where = self.lint_rules["subordinates"][required_sub]["where"]
355 for machine in self.model.subs_on_machines:
356 self.logger.debug(
357 "[{}] [{}/{}] Checking on {}".format(
358 self.cloud_name, self.controller_name, self.model_name, machine
359 )
360 )
361 present_subs = self.model.subs_on_machines[machine]
362 apps = self.model.apps_on_machines[machine]
363 if where.startswith("on "): # only on specific apps
364 required_on = where[3:]
365 self.logger.debug(
366 "[{}] [{}/{}] Requirement {} is = from...".format(
367 self.cloud_name,
368 self.controller_name,
369 self.model_name,
370 required_on,
371 )
372 )
373 if required_on not in apps:
374 self.logger.debug(
375 "[{}] [{}/{}] ... NOT matched".format(
376 self.cloud_name, self.controller_name, self.model_name
377 )
378 )
379 continue
380 self.logger.debug("[{}] [{}/{}] ... matched")
381 # TODO this needs to be not just one app, but a list
382 elif where.startswith("all except "): # not next to this app
383 self.logger.debug(
384 "[{}] [{}/{}] requirement is != form...".format(
385 self.cloud_name, self.controller_name, self.model_name
386 )
387 )
388 not_on = where[11:]
389 if not_on in apps:
390 self.logger.debug(
391 "[{}] [{}/{}] ... matched, not wanted on this host".format(
392 self.cloud_name, self.controller_name, self.model_name
393 )
394 )
395 continue
396 elif where == "host only":
397 self.logger.debug(
398 "[{}] [{}/{}] requirement is 'host only' form....".format(
399 self.cloud_name, self.controller_name, self.model_name
400 )
401 )
402 if is_container(machine):
403 self.logger.debug(
404 "[{}] [{}/{}] ... and we are a container, checking".format(
405 self.cloud_name, self.controller_name, self.model_name
406 )
407 )
408 # XXX check alternate names?
409 if required_sub in present_subs:
410 self.logger.debug(
411 "[{}] [{}/{}] ... found extraneous sub".format(
412 self.cloud_name,
413 self.controller_name,
414 self.model_name,
415 )
416 )
417 for app in self.model.apps_on_machines[machine]:
418 self.model.extraneous_subs[required_sub].add(app)
419 continue
420 self.logger.debug(
421 "[{}] [{}/{}] ... and we are a host, will fallthrough".format(
422 self.cloud_name, self.controller_name, self.model_name,
423 )
424 )
425 elif where == "all or nothing" and required_sub not in all_or_nothing:
426 self.logger.debug(
427 "[{}] [{}/{}] requirement is 'all or nothing' and was 'nothing'.".format(
428 self.cloud_name, self.controller_name, self.model_name,
429 )
430 )
431 continue
432 # At this point we know we require the subordinate - we might just
433 # need to change the name we expect to see it as
434 elif where == "container aware":
435 self.logger.debug(
436 "[{}] [{}/{}] requirement is 'container aware'.".format(
437 self.cloud_name, self.controller_name, self.model_name,
438 )
439 )
440 if is_container(machine):
441 suffixes = self.lint_rules["subordinates"][required_sub][
442 "container-suffixes"
443 ]
444 else:
445 suffixes = self.lint_rules["subordinates"][required_sub][
446 "host-suffixes"
447 ]
448 self.logger.debug(
449 "[{}] [{}/{}] -> suffixes == {}".format(
450 self.cloud_name,
451 self.controller_name,
452 self.model_name,
453 suffixes,
454 )
455 )
456 found = False
457 for suffix in suffixes:
458 looking_for = "{}-{}".format(required_sub, suffix)
459 self.logger.debug(
460 "[{}] [{}/{}] -> Looking for {}".format(
461 self.cloud_name,
462 self.controller_name,
463 self.model_name,
464 looking_for,
465 )
466 )
467 if looking_for in present_subs:
468 self.logger.debug("-> FOUND!!!")
469 self.cloud_name,
470 self.controller_name,
471 self.model_name,
472 found = True
473 if not found:
474 for sub in present_subs:
475 if self.model.app_to_charm[sub] == required_sub:
476 self.logger.debug(
477 "[{}] [{}/{}] Winner winner, chicken dinner! 🍗 {}".format(
478 self.cloud_name,
479 self.controller_name,
480 self.model_name,
481 sub,
482 )
483 )
484 found = True
485 if not found:
486 self.logger.debug(
487 "[{}] [{}/{}] -> NOT FOUND".format(
488 self.cloud_name, self.controller_name, self.model_name,
489 )
490 )
491 for app in self.model.apps_on_machines[machine]:
492 self.model.missing_subs[required_sub].add(app)
493 self.logger.debug(
494 "[{}] [{}/{}] -> continue-ing back out...".format(
495 self.cloud_name, self.controller_name, self.model_name,
496 )
497 )
498 continue
499 elif where not in ["all", "all or nothing"]:
500 self.logger.fubar(
501 "[{}] [{}/{}] Invalid requirement '{}' on {}".format(
502 self.cloud_name,
503 self.controller_name,
504 self.model_name,
505 where,
506 required_sub,
507 )
508 )
509 self.logger.debug(
510 "[{}] [{}/{}] requirement is 'all' OR we fell through.".format(
511 self.cloud_name, self.controller_name, self.model_name,
512 )
513 )
514 if required_sub not in present_subs:
515 for sub in present_subs:
516 if self.model.app_to_charm[sub] == required_sub:
517 self.logger.debug(
518 "Winner winner, chicken dinner! 🍗 {}".format(sub)
519 )
520 self.cloud_name,
521 self.controller_name,
522 self.model_name,
523 continue
524 self.logger.debug(
525 "[{}] [{}/{}] not found.".format(
526 self.cloud_name, self.controller_name, self.model_name,
527 )
528 )
529 for app in self.model.apps_on_machines[machine]:
530 self.model.missing_subs[required_sub].add(app)
531
532 for sub in list(self.model.missing_subs.keys()):
533 if not self.model.missing_subs[sub]:
534 del self.model.missing_subs[sub]
535 for sub in list(self.model.extraneous_subs.keys()):
536 if not self.model.extraneous_subs[sub]:
537 del self.model.extraneous_subs[sub]
538
539 def check_charms(self):
540 """Check we recognise the charms which are in the model."""
541 for charm in self.model.charms:
542 if charm not in self.lint_rules["known charms"]:
543 self.logger.error(
544 "[{}] Charm '{}' in model {} on controller {} not recognised".format(
545 self.cloud_name, charm, self.model_name, self.controller_name
546 )
547 )
548 # Then look for charms we require
549 for charm in self.lint_rules["operations mandatory"]:
550 if charm not in self.model.charms:
551 self.logger.error(
552 "[{}] Ops charm '{}' in model {} on controller {} not found".format(
553 self.cloud_name, charm, self.model_name, self.controller_name
554 )
555 )
556 if self.cloud_type == "openstack":
557 for charm in self.lint_rules["openstack mandatory"]:
558 if charm not in self.model.charms:
559 self.logger.error(
560 "[{}] OpenStack charm '{}' in model {} on controller {} not found".format(
561 self.cloud_name,
562 charm,
563 self.model_name,
564 self.controller_name,
565 )
566 )
567 elif self.cloud_type == "kubernetes":
568 for charm in self.lint_rules["kubernetes mandatory"]:
569 if charm not in self.model.charms:
570 self.logger.error(
571 "[{}] [{}/{}] Kubernetes charm '{}' not found".format(
572 self.cloud_name,
573 self.controller_name,
574 self.model_name,
575 charm,
576 )
577 )
578
579 def results(self):
580 """Provide results of the linting process."""
581 if self.model.missing_subs:
582 self.logger.error("The following subordinates couldn't be found:")
583 for sub in self.model.missing_subs:
584 self.logger.error(
585 "[{}] [{}/{}] -> {} [{}]".format(
586 self.cloud_name,
587 self.controller_name,
588 self.model_name,
589 sub,
590 ", ".join(sorted(self.model.missing_subs[sub])),
591 )
592 )
593 if self.model.extraneous_subs:
594 self.logger.error("following subordinates where found unexpectedly:")
595 for sub in self.model.extraneous_subs:
596 self.logger.error(
597 "[{}] [{}/{}] -> {} [{}]".format(
598 self.cloud_name,
599 self.controller_name,
600 self.model_name,
601 sub,
602 ", ".join(sorted(self.model.extraneous_subs[sub])),
603 )
604 )
605 if self.model.duelling_subs:
606 self.logger.error(
607 "[{}] [{}/{}] following subordinates where found on machines more than once:".format(
608 self.cloud_name, self.controller_name, self.model_name,
609 )
610 )
611 for sub in self.model.duelling_subs:
612 self.logger.error(
613 "[{}] [{}/{}] -> {} [{}]".format(
614 self.cloud_name,
615 self.controller_name,
616 self.model_name,
617 sub,
618 ", ".join(sorted(self.model.duelling_subs[sub])),
619 )
620 )
621 if self.model.az_unbalanced_apps:
622 self.logger.error("The following apps are unbalanced across AZs: ")
623 for app in self.model.az_unbalanced_apps:
624 (num_units, az_counter) = self.model.az_unbalanced_apps[app]
625 az_map = ", ".join(
626 ["{}: {}".format(az, az_counter[az]) for az in az_counter]
627 )
628 self.logger.error(
629 "[{}] [{}/{}] -> {}: {} units, deployed as: {}".format(
630 self.cloud_name,
631 self.controller_name,
632 self.model_name,
633 app,
634 num_units,
635 az_map,
636 )
637 )
638
639 def map_charms(self, applications):
640 """Process applications in the model, validating and normalising the names."""
641 for app in applications:
642 if "charm" in applications[app]:
643 charm = applications[app]["charm"]
644 match = re.match(
645 r"^(?:\w+:)?(?:~[\w-]+/)?(?:\w+/)?([a-zA-Z0-9-]+?)(?:-\d+)?$", charm
646 )
647 if not match:
648 raise InvalidCharmNameError(
649 "charm name '{}' is invalid".format(charm)
650 )
651 charm = match.group(1)
652 self.model.charms.add(charm)
653 self.model.app_to_charm[app] = charm
654 else:
655 self.logger.error(
656 "[{}] [{}/{}] Could not detect which charm is used for application {}".format(
657 self.cloud_name, self.controller_name, self.model_name, app
658 )
659 )
660
661 def map_machines_to_az(self, machines):
662 """Map machines in the model to their availability zone."""
663 for machine in machines:
664 if "hardware" not in machines[machine]:
665 self.logger.warn(
666 "[{}] [{}/{}] Machine {} has no hardware info; skipping.".format(
667 self.cloud_name, self.controller_name, self.model_name, machine
668 )
669 )
670 continue
671
672 hardware = machines[machine]["hardware"]
673 found_az = False
674 for entry in hardware.split():
675 if entry.startswith("availability-zone="):
676 found_az = True
677 az = entry.split("=")[1]
678 self.model.machines_to_az[machine] = az
679 break
680 if not found_az:
681 self.logger.warn(
682 "[{}] [{}/{}] Machine {} has no availability-zone info in hardware field; skipping.".format(
683 self.cloud_name, self.controller_name, self.model_name, machine
684 )
685 )
686
687 def check_status(self, what, status, expected):
688 """Lint the status of a unit."""
689 if isinstance(expected, str):
690 expected = [expected]
691 if status.get("current") not in expected:
692 self.logger.error(
693 "[{}] [{}/{}] {} has status '{}' (since: {}, message: {}); (We expected: {})".format(
694 self.cloud_name,
695 self.controller_name,
696 self.model_name,
697 what,
698 status.get("current"),
699 status.get("since"),
700 status.get("message"),
701 expected,
702 )
703 )
704
705 def check_status_pair(self, name, status_type, data_d):
706 """Cross reference satus of paired constructs, like machines and units."""
707 if status_type in ["machine", "container"]:
708 primary = "machine-status"
709 primary_expected = "running"
710 juju_expected = "started"
711 elif status_type in ["unit", "subordinate"]:
712 primary = "workload-status"
713 primary_expected = ["active", "unknown"]
714 juju_expected = "idle"
715 elif status_type in ["application"]:
716 primary = "application-status"
717 primary_expected = ["active", "unknown"]
718 juju_expected = None
719
720 if primary in data_d:
721 self.check_status(
722 "{} {}".format(status_type.title(), name),
723 data_d[primary],
724 expected=primary_expected,
725 )
726 if juju_expected:
727 if "juju-status" in data_d:
728 self.check_status(
729 "Juju on {} {}".format(status_type, name),
730 data_d["juju-status"],
731 expected=juju_expected,
732 )
733 else:
734 self.logger.warn(
735 "[{}] [{}/{}] Could not determine Juju status for {}.".format(
736 self.cloud_name, self.controller_name, self.model_name, name
737 )
738 )
739 else:
740 self.logger.warn(
741 "[{}] [{}/{}] Could not determine appropriate status key for {}.".format(
742 self.cloud_name, self.controller_name, self.model_name, name,
743 )
744 )
745
746 def check_statuses(self, juju_status, applications):
747 """Check all statuses in juju status output."""
748 for machine_name in juju_status["machines"]:
749 self.check_status_pair(
750 machine_name, "machine", juju_status["machines"][machine_name]
751 )
752 for container_name in juju_status["machines"][machine_name].get(
753 "container", []
754 ):
755 self.check_status_pair(
756 container_name,
757 "container",
758 juju_status["machines"][machine_name][container_name],
759 )
760
761 for app_name in juju_status[applications]:
762 self.check_status_pair(
763 app_name, "application", juju_status[applications][app_name]
764 )
765 for unit_name in juju_status[applications][app_name].get("units", []):
766 self.check_status_pair(
767 unit_name,
768 "unit",
769 juju_status[applications][app_name]["units"][unit_name],
770 )
771
772 # This is noisy and only covers a very theoretical corner case
773 # where a misbehaving or malicious leader unit sets the
774 # application-status to OK despite one or more units being in error
775 # state.
776 #
777 # We could revisit this later by splitting it into two passes and
778 # only warning about individual subordinate units if the
779 # application-status for the subordinate claims to be OK.
780 #
781 # for subordinate_name in juju_status[applications][app_name]["units"][unit_name].get("subordinates", []):
782 # check_status_pair(subordinate_name, "subordinate",
783 # juju_status[applications][app_name]["units"][unit_name]["subordinates"][subordinate_name])
784
785 def check_azs(self, applications):
786 """Lint AZ distribution."""
787 azs = set()
788 for machine in self.model.machines_to_az:
789 azs.add(self.model.machines_to_az[machine])
790 num_azs = len(azs)
791 if num_azs != 3:
792 self.logger.error(
793 "[{}] [{}/{}] Found {} AZs (not 3); and I don't currently know how to lint that.".format(
794 self.cloud_name, self.controller_name, self.model_name, num_azs
795 )
796 )
797 return
798
799 for app_name in applications:
800 az_counter = collections.Counter()
801 for az in azs:
802 az_counter[az] = 0
803 num_units = len(applications[app_name].get("units", []))
804 if num_units <= 1:
805 continue
806 min_per_az = num_units // num_azs
807 for unit in applications[app_name]["units"]:
808 machine = applications[app_name]["units"][unit]["machine"]
809 machine = machine.split("/")[0]
810 if machine not in self.model.machines_to_az:
811 self.logger.error(
812 "[{}] [{}/{}] {}: Can't find machine {} in machine to AZ mapping data".format(
813 self.cloud_name,
814 self.controller_name,
815 self.model_name,
816 app_name,
817 machine,
818 )
819 )
820 continue
821 az_counter[self.model.machines_to_az[machine]] += 1
822 for az in az_counter:
823 num_this_az = az_counter[az]
824 if num_this_az < min_per_az:
825 self.model.az_unbalanced_apps[app_name] = [num_units, az_counter]
826
827 def lint_yaml_string(self, yaml):
828 """Lint provided YAML string."""
829 parsed_yaml = yaml.safe_load(yaml)
830 return self.do_lint(parsed_yaml)
831
832 def lint_yaml_file(self, filename):
833 """Load and lint provided YAML file."""
834 if filename:
835 with open(filename, "r") as infile:
836 parsed_yaml = yaml.safe_load(infile.read())
837 if parsed_yaml:
838 return self.do_lint(parsed_yaml)
839 self.logger.fubar("Failed to parse YAML from file {}".format(filename))
840
841 def do_lint(self, parsed_yaml):
842 """Lint parsed YAML."""
843 # Handle Juju 2 vs Juju 1
844 applications = "applications"
845 if applications not in parsed_yaml:
846 applications = "services"
847
848 if applications in parsed_yaml:
849
850 # Build a list of deployed charms and mapping of charms <-> applications
851 self.map_charms(parsed_yaml[applications])
852
853 # Check configuration
854 self.check_configuration(parsed_yaml[applications])
855
856 # Then map out subordinates to applications
857 for app in parsed_yaml[applications]:
858 self.process_subordinates(parsed_yaml[applications][app], app)
859
860 self.check_subs()
861 self.check_charms()
862
863 if parsed_yaml.get("machines"):
864 self.map_machines_to_az(parsed_yaml["machines"])
865 self.check_azs(parsed_yaml[applications])
866 self.check_statuses(parsed_yaml, applications)
867 else:
868 self.logger.warn(
869 (
870 "[{}] [{}/{}] No machine status present in model."
871 "possibly a bundle without status, skipping AZ checks"
872 ).format(
873 self.cloud_name, self.model_name, self.controller_name,
874 )
875 )
876
877 self.results()
878 self.logger.warn(
879 "[{}] [{}/{}] Model contains no applications, skipping.".format(
880 self.cloud_name, self.controller_name, self.model_name,
881 )
882 )
diff --git a/jujulint/logging.py b/jujulint/logging.py
0new file mode 100644883new file mode 100644
index 0000000..00dd200
--- /dev/null
+++ b/jujulint/logging.py
@@ -0,0 +1,106 @@
1#!/usr/bin/env python3
2# This file is part of juju-lint, a tool for validating that Juju
3# deloyments meet configurable site policies.
4#
5# Copyright 2018-2020 Canonical Limited.
6# License granted by Canonical Limited.
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3, as
10# published by the Free Software Foundation.
11#
12# This program is distributed in the hope that it will be useful, but
13# WITHOUT ANY WARRANTY; without even the implied warranties of
14# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
15# PURPOSE. See the GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19"""Logging helper functions."""
20import colorlog
21import logging
22import sys
23
24
25class Logger:
26 """Helper class for logging."""
27
28 def __init__(self, level=None, logfile=None):
29 """Set up logging instance and set log level."""
30 self.logger = colorlog.getLogger()
31 self.set_level(level)
32 if not len(self.logger.handlers):
33 format_string = "%(log_color)s%(asctime)s [%(levelname)s] %(message)s"
34 date_format = "%Y-%m-%d %H:%M:%S"
35 colour_formatter = colorlog.ColoredFormatter(
36 format_string,
37 datefmt=date_format,
38 log_colors={
39 "DEBUG": "cyan",
40 "INFO": "green",
41 "WARNING": "yellow",
42 "ERROR": "red",
43 "CRITICAL": "red,bg_white",
44 },
45 )
46 console = colorlog.StreamHandler()
47 console.setFormatter(colour_formatter)
48 self.logger.addHandler(console)
49 if logfile:
50 try:
51 file_logger = colorlog.getLogger("file")
52 plain_formatter = logging.Formatter(
53 format_string, datefmt=date_format
54 )
55 # If we send output to the file logger specifically, don't propagate it
56 # to the root logger as well to avoid duplicate output. So if we want
57 # to only send logging output to the file, you would do this:
58 # logging.getLogger('file').info("message for logfile only")
59 # rather than this:
60 # logging.info("message for console and logfile")
61 file_logger.propagate = False
62
63 file_handler = logging.FileHandler(logfile)
64 file_handler.setFormatter(plain_formatter)
65 self.logger.addHandler(file_handler)
66 file_logger.addHandler(file_handler)
67 except IOError:
68 logging.error("Unable to write to logfile: {}".format(logfile))
69
70 def fubar(self, msg, exit_code=1):
71 """Exit and print to stderr because everything is FUBAR."""
72 sys.stderr.write("E: %s\n" % (msg))
73 sys.exit(exit_code)
74
75 def set_level(self, level="info"):
76 """Set the level to the provided level."""
77 if level:
78 level = level.lower()
79 else:
80 return False
81
82 if level == "debug":
83 logging.basicConfig(level=logging.DEBUG)
84 elif level == "warn":
85 self.logger.setLevel(logging.WARN)
86 elif level == "error":
87 self.logger.setLevel(logging.ERROR)
88 else:
89 self.logger.setLevel(logging.INFO)
90 return True
91
92 def debug(self, message):
93 """Log a message with debug loglevel."""
94 self.logger.debug(message)
95
96 def warn(self, message):
97 """Log a message with warn loglevel."""
98 self.logger.warn(message)
99
100 def info(self, message):
101 """Log a message with info loglevel."""
102 self.logger.info(message)
103
104 def error(self, message):
105 """Log a message with warn loglevel."""
106 self.logger.error(message)
diff --git a/jujulint/openstack.py b/jujulint/openstack.py
0new file mode 100644107new file mode 100644
index 0000000..398721e
--- /dev/null
+++ b/jujulint/openstack.py
@@ -0,0 +1,71 @@
1#!/usr/bin/env python3
2# This file is part of juju-lint, a tool for validating that Juju
3# deloyments meet configurable site policies.
4#
5# Copyright 2018-2020 Canonical Limited.
6# License granted by Canonical Limited.
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3, as
10# published by the Free Software Foundation.
11#
12# This program is distributed in the hope that it will be useful, but
13# WITHOUT ANY WARRANTY; without even the implied warranties of
14# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
15# PURPOSE. See the GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19"""OpenStack checks module.
20
21This module provides checks for OpenStack clouds.
22
23Attributes:
24 access (string): Set the access method (local/ssh)
25 ssh_host (string, optional): Configuration to pass to Cloud module for accessing the cloud via SSH
26 ssh_jump (string, optional): Jump/Bastion configuration to pass to Cloud module for access the cloud via SSH
27 sudo_user (string, optional): User to switch to via sudo when accessing the cloud, passed to the Cloud module
28
29Todo:
30 * Check neutron configuration
31 * Check MTU configuration on neutron-api and OVS charms
32 * Check neutron units interface config for MTU settings
33 * Check namespaces and MTUs within namespaces
34 * Check OpenStack network definitions for MTU mismatches
35 * Check OVS configuration
36 * Check nova configuration for live migration settings
37 * Check Ceph for sensible priorities and placement
38
39"""
40
41from jujulint.cloud import Cloud
42
43
44class OpenStack(Cloud):
45 """Helper class for interacting with Nagios via the livestatus socket."""
46
47 def __init__(self, *args, **kwargs):
48 """Initialise class-local variables and configuration and pass to super."""
49 super(OpenStack, self).__init__(*args, **kwargs)
50 self.cloud_type = "openstack"
51
52 def get_neutron_ports(self):
53 """Get a list of neutron ports."""
54
55 def get_neutron_routers(self):
56 """Get a list of neutron routers."""
57
58 def get_neutron_networks(self):
59 """Get a list of neutron networks."""
60
61 def refresh(self):
62 """Refresh cloud information."""
63 return super(OpenStack, self).refresh()
64
65 def audit(self):
66 """Audit OpenStack cloud and run base Cloud audits."""
67 # add specific OpenStack checks here
68 self.logger.info(
69 "[{}] Running OpenStack-specific audit steps.".format(self.name)
70 )
71 super(OpenStack, self).audit()
diff --git a/jujulint/util.py b/jujulint/util.py
0new file mode 10064472new file mode 100644
index 0000000..c646b28
--- /dev/null
+++ b/jujulint/util.py
@@ -0,0 +1,38 @@
1#! /usr/bin/env python3
2# This file is part of juju-lint, a tool for validating that Juju
3# deloyments meet configurable site policies.
4#
5# Copyright 2018-2020 Canonical Limited.
6# License granted by Canonical Limited.
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3, as
10# published by the Free Software Foundation.
11#
12# This program is distributed in the hope that it will be useful, but
13# WITHOUT ANY WARRANTY; without even the implied warranties of
14# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
15# PURPOSE. See the GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19"""Utility library for all helpful functions this project uses."""
20
21
22def flatten_list(lumpy_list):
23 """Flatten a list potentially containing other lists."""
24 flat_list = []
25 for item in lumpy_list:
26 if not isinstance(item, list):
27 flat_list.append(item)
28 else:
29 flat_list.extend(flatten_list(item))
30 return flat_list
31
32
33def is_container(machine):
34 """Check if a provided machine is a container."""
35 if "/" in machine:
36 return True
37 else:
38 return False
diff --git a/requirements.txt b/requirements.txt
0new file mode 10064439new file mode 100644
index 0000000..9527545
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,6 @@
1attrs>=18.1.0
2colorlog>=4.1.0
3confuse>=1.1.0
4fabric2>=2.5.0
5setuptools>=46.1.3
6PyYAML>=5.3.1
diff --git a/setup.py b/setup.py
index c82caa1..4190e7a 100644
--- a/setup.py
+++ b/setup.py
@@ -15,6 +15,7 @@
15#15#
16# You should have received a copy of the GNU General Public License16# You should have received a copy of the GNU General Public License
17# along with this program. If not, see <http://www.gnu.org/licenses/>.17# along with this program. If not, see <http://www.gnu.org/licenses/>.
18"""Setuptools packaging metadata for juju-lint."""
1819
19import re20import re
20import setuptools21import setuptools
@@ -25,26 +26,26 @@ warnings.simplefilter("ignore", UserWarning) # Older pips complain about newer
25with open("README.md", "r") as fh:26with open("README.md", "r") as fh:
26 long_description = fh.read()27 long_description = fh.read()
2728
28with open("debian/changelog", "r") as fh:
29 version = re.search(r'\((.*)\)', fh.readline()).group(1)
30
31setuptools.setup(29setuptools.setup(
32 name="juju-lint",30 name="jujulint",
33 version=version,31 use_scm_version={
32 "local_scheme": "node-and-date",
33 },
34 author="Canonical",34 author="Canonical",
35 author_email="juju@lists.ubuntu.com",35 author_email="juju@lists.ubuntu.com",
36 description="Linter for Juju models to compare deployments with configurable policy",36 description="Linter for Juju models to compare deployments with configurable policy",
37 long_description=long_description,37 long_description=long_description,
38 long_description_content_type="text/markdown",38 long_description_content_type="text/markdown",
39 url="https://launchpad.net/juju-lint",39 url="https://launchpad.net/juju-lint",
40 classifiers=(40 classifiers=[
41 "Programming Language :: Python :: 3",41 "Programming Language :: Python :: 3",
42 "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",42 "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
43 "Development Status :: 2 - Beta",43 "Development Status :: 2 - Beta",
44 "Environment :: Plugins",44 "Environment :: Plugins",
45 "Intended Audience :: System Administrators"),45 "Intended Audience :: System Administrators",
46 python_requires='>=3.4',46 ],
47 python_requires=">=3.4",
47 py_modules=["jujulint"],48 py_modules=["jujulint"],
48 entry_points={49 entry_points={"console_scripts": ["juju-lint=jujulint.cli:main"]},
49 'console_scripts': [50 setup_requires=["setuptools_scm"],
50 'juju-lint=jujulint:main']})51)
diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml
index ddf7ea5..b8bd6da 100644
--- a/snap/snapcraft.yaml
+++ b/snap/snapcraft.yaml
@@ -1,30 +1,34 @@
1---
1name: juju-lint2name: juju-lint
2version: auto3base: core18
3summary: Linter for Juju models to compare deployments with configurable policy4summary: Linter for Juju models to compare deployments with configurable policy
4description: Linter for Juju models to compare deployments with configurable policy5adopt-info: juju-lint
6description: |
7 Linter for remote or local Juju models.
8 Compares remote deployments with configurable policy rules.
9 Linting can also be performed on a YAML file representing cloud state.
5grade: stable10grade: stable
6confinement: classic11confinement: classic
7version-script: |
8 echo `head -1 debian/changelog | sed -rn 's/.*\((.*)\).*/\1/p'`-`git describe --dirty --always --tags | sed -r 's/v(.*)/\1/g'`
9apps:12apps:
10 juju-lint:13 juju-lint:
11 command: usr/bin/python3 $SNAP/bin/juju-lint14 command: juju-lint
12 environment:15 environment:
13 PATH: "/snap/juju-lint/current/bin:/snap/juju-lint/current/usr/bin:/bin:/usr/bin:"16 PATH: "/snap/juju-lint/current/bin:/snap/juju-lint/current/usr/bin:/bin:/usr/bin:"
17 PYTHONPATH: $SNAP/usr/lib/python3.6/site-packages:$SNAP/usr/lib/python3.6/dist-packages:$PYTHONPATH
14parts:18parts:
15 juju-lint:19 juju-lint:
16 plugin: python20 plugin: python
17 python-version: python321 python-version: python3
18 python-packages:22 requirements:
19 - 3rdparty/attrs-18.1.0.tar.gz23 - requirements.txt
20 build-packages:
21 - python3-setuptools
22 stage-packages:
23 - libc6
24 - python3-yaml
25 source: .24 source: .
26 source-type: git25 override-build: |
26 snapcraftctl build
27 echo "Version: $(python3 setup.py --version)"
28 snapcraftctl set-version "$(python3 setup.py --version)"
27 juju-lint-contrib:29 juju-lint-contrib:
30 after:
31 - juju-lint
28 plugin: dump32 plugin: dump
29 source: .33 source: .
30 prime:34 prime:
diff --git a/tests/conftest.py b/tests/conftest.py
31new file mode 10064435new file mode 100644
index 0000000..c95eb69
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,60 @@
1#! /usr/bin/env python
2# -*- coding: utf-8 -*-
3# vim:fenc=utf-8
4#
5# Copyright © 2020 James Hebden <james.hebden@canonical.com>
6#
7# Distributed under terms of the GPL license.
8
9"""Test fixtures for juju-lint tool."""
10
11import mock
12import os
13import pytest
14import sys
15
16# bring in top level library to path
17test_path = os.path.dirname(os.path.abspath(__file__))
18sys.path.insert(0, test_path + "/../")
19
20
21@pytest.fixture
22def mocked_pkg_resources(monkeypatch):
23 """Mock the pkg_resources library."""
24 import pkg_resources
25
26 monkeypatch.setattr(pkg_resources, "require", mock.Mock())
27
28
29@pytest.fixture
30def cli():
31 """Provide a test instance of the CLI class."""
32 from jujulint.cli import Cli
33
34 cli = Cli()
35
36 return cli
37
38
39@pytest.fixture
40def utils():
41 """Provide a test instance of the CLI class."""
42 from jujulint import util
43
44 return util
45
46
47@pytest.fixture
48def parser(monkeypatch):
49 """Mock the configuration parser."""
50 monkeypatch.setattr('jujulint.config.ArgumentParser', mock.Mock())
51
52
53@pytest.fixture
54def lint(parser):
55 """Provide test fixture for the linter class."""
56 from jujulint.lint import Linter
57
58 linter = Linter('mockcloud', 'mockrules.yaml')
59
60 return linter
diff --git a/tests/requirements.txt b/tests/requirements.txt
index 71f6c69..c20bbcd 100644
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -1,3 +1,12 @@
1# Module requirements1# Module requirements
2pyyaml2flake8
3attrs3flake8-colors
4flake8-docstrings
5flake8-html
6mock
7pep8-naming
8pycodestyle
9pyflakes
10pytest
11pytest-cov
12pytest-html
diff --git a/tests/test_cli.py b/tests/test_cli.py
4new file mode 10064413new file mode 100644
index 0000000..c5a9e95
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,14 @@
1#!/usr/bin/python3
2"""Test the CLI."""
3
4from jujulint.cli import Cli
5
6
7def test_pytest():
8 """Test that pytest itself works."""
9 assert True
10
11
12def test_cli_fixture(cli):
13 """Test if the CLI fixture works."""
14 assert isinstance(cli, Cli)
diff --git a/tests/test_jujulint.py b/tests/test_jujulint.py
index acf5cdc..ba2dbf7 100644
--- a/tests/test_jujulint.py
+++ b/tests/test_jujulint.py
@@ -1,33 +1,36 @@
1#!/usr/bin/python31#!/usr/bin/python3
2"""Tests for jujulint."""
23
3import unittest4import pytest
4
5import jujulint5import jujulint
66
77
8class TestJujuLint(unittest.TestCase):8def test_flatten_list(utils):
9 """Test the utils flatten_list function."""
10 unflattened_list = [1, [2, 3]]
11 flattened_list = [1, 2, 3]
12 assert flattened_list == utils.flatten_list(unflattened_list)
913
10 def test_flatten_list(self):14 unflattened_list = [1, [2, [3, 4]]]
11 unflattened_list = [1, [2, 3]]15 flattened_list = [1, 2, 3, 4]
12 flattened_list = [1, 2, 3]16 assert flattened_list == utils.flatten_list(unflattened_list)
13 self.assertEqual(flattened_list, jujulint.flatten_list(unflattened_list))
1417
15 unflattened_list = [1, [2, [3, 4]]]
16 flattened_list = [1, 2, 3, 4]
17 self.assertEqual(flattened_list, jujulint.flatten_list(unflattened_list))
1818
19 def test_map_charms(self):19def test_map_charms(lint):
20 model = jujulint.ModelInfo()20 """Test the charm name validation code."""
21 applications = {'test-app-1': {'charm': "cs:~USER/SERIES/TEST-CHARM12-123"},21 applications = {
22 'test-app-2': {'charm': "cs:~USER/TEST-CHARM12-123"},22 "test-app-1": {"charm": "cs:~USER/SERIES/TEST-CHARM12-123"},
23 'test-app-3': {'charm': "cs:TEST-CHARM12-123"},23 "test-app-2": {"charm": "cs:~USER/TEST-CHARM12-123"},
24 'test-app-4': {'charm': "local:SERIES/TEST-CHARM12"},24 "test-app-3": {"charm": "cs:TEST-CHARM12-123"},
25 'test-app-5': {'charm': "local:TEST-CHARM12"},25 "test-app-4": {"charm": "local:SERIES/TEST-CHARM12"},
26 'test-app-6': {'charm': "cs:~TEST-CHARMERS/TEST-CHARM12-123"},26 "test-app-5": {"charm": "local:TEST-CHARM12"},
27 }27 "test-app-6": {"charm": "cs:~TEST-CHARMERS/TEST-CHARM12-123"},
28 jujulint.map_charms(applications, model)28 }
29 for charm in model.charms:29 lint.map_charms(applications)
30 self.assertEqual("TEST-CHARM12", charm)30 for charm in lint.model.charms:
31 applications = {'test-app1': {'charm': "cs:invalid-charm$"}, }31 assert "TEST-CHARM12" == charm
32 with self.assertRaises(jujulint.InvalidCharmNameError):32 applications = {
33 jujulint.map_charms(applications, model)33 "test-app1": {"charm": "cs:invalid-charm$"},
34 }
35 with pytest.raises(jujulint.lint.InvalidCharmNameError):
36 lint.map_charms(applications)
diff --git a/tox.ini b/tox.ini
index a3cb544..cf22f0f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,22 +1,45 @@
1[flake8]1[flake8]
2exclude =
3 .git,
4 __pycache__,
5 .tox,
6max-line-length = 120
7max-complexity = 10
2ignore = C9018ignore = C901
3max_line_length = 120
4max_complexity = 10
5hang_closing = yes
69
7[tox]10[tox]
8envlist = py3-{lint,test}11skipsdist=True
9skipsdist = true12envlist = lintverbose, unit
13skip_missing_interpreters = True
1014
11[testenv]15[testenv]
12install_command=16basepython = python3
13 pip install --no-cache-dir --no-deps --find-links {toxinidir}/3rdparty --upgrade {opts} {packages}
14deps =17deps =
15 lint: flake8 == 3.5.018 -r{toxinidir}/tests/requirements.txt
16 lint: pyflakes == 2.0.019 -r{toxinidir}/requirements.txt
17 lint: pycodestyle == 2.3.1
18 test: -r{toxinidir}/tests/requirements.txt
19commands =20commands =
20 lint: flake8 {toxinidir}21 lint: flake8 {toxinidir}
21 test: python3 -m unittest discover tests22 test: python3 -m unittest discover tests
2223
24[testenv:unit]
25commands = pytest -v \
26 --cov=jujulint \
27 --cov-report=term \
28 --cov-report=annotate:tests/report/coverage-annotated \
29 --cov-report=html:tests/report/coverage-html \
30 --html=tests/report/index.html \
31 --junitxml=tests/report/junit.xml
32setenv = PYTHONPATH={toxinidir}/lib
33
34[testenv:lint]
35commands = flake8 jujulint --format=html --htmldir=tests/report/lint/ --tee
36
37[testenv:lintverbose]
38commands = flake8 jujulint
39
40[testenv:lintjunit]
41commands = flake8 jujulint --format junit-xml --output-file=report/lint/junit.xml
42
43[pytest]
44filterwarnings =
45 ignore::DeprecationWarning

Subscribers

People subscribed via source and target branches