Merge ~peter-sabaini/charm-sudo-pair:feature/add-pd-logging into ~sudo-pair-charmers/charm-sudo-pair:master
- Git
- lp:~peter-sabaini/charm-sudo-pair
- feature/add-pd-logging
- Merge into master
Status: | Superseded |
---|---|
Proposed branch: | ~peter-sabaini/charm-sudo-pair:feature/add-pd-logging |
Merge into: | ~sudo-pair-charmers/charm-sudo-pair:master |
Diff against target: |
1981 lines (+857/-382) 22 files modified
.gitignore (+24/-16) Makefile (+60/-33) dev/null (+0/-185) interfaces/.empty (+0/-0) layers/.empty (+0/-0) src/README.md (+1/-1) src/actions/actions.py (+19/-2) src/actions/remove-sudopair (+1/-0) src/config.yaml (+8/-0) src/copyright (+16/-0) src/files/pagerdutyevent.py (+68/-0) src/lib/libsudopair.py (+237/-0) src/metadata.yaml (+1/-1) src/reactive/sudo_pair.py (+25/-10) src/templates/sudo_approve.tmpl (+5/-2) src/templates/sudo_pair.pagerduty.tmpl (+6/-0) src/tests/functional/conftest.py (+51/-38) src/tests/functional/test_deploy.py (+53/-66) src/tests/unit/conftest.py (+11/-5) src/tests/unit/test_actions.py (+23/-0) src/tests/unit/test_libsudopair.py (+206/-0) src/tox.ini (+42/-23) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Giuseppe Petralia | Pending | ||
Paul Goins | Pending | ||
Review via email: mp+403955@code.launchpad.net |
This proposal supersedes a proposal from 2020-11-23.
This proposal has been superseded by a proposal from 2021-06-09.
Commit message
Pagerduty alerting, action logging
Ability to have pagerduty alerts triggered on auto-approve. Revamp logging, add logging for remove-sudopair action. Use https proxy to contact the PD events endpoint if set in model config.
Description of the change
Giuseppe Petralia (peppepetra) wrote : Posted in a previous version of this proposal | # |
Paul Goins (vultaire) wrote : Posted in a previous version of this proposal | # |
I agree with Giuseppe. I think we should pull the proxy from the env's JUJU_CHARM_
Peter Sabaini (peter-sabaini) wrote : Posted in a previous version of this proposal | # |
Thanks -- proxy config updated as requested. I've resubmitted the branch as there was some code reorg interim.
Celia Wang (ziyiwang) wrote : Posted in a previous version of this proposal | # |
LGTM.
Paul Goins (vultaire) wrote : Posted in a previous version of this proposal | # |
Sorry for the delay here - It looks like this is close to if not identical to https:/
Unmerged commits
- 3681b10... by Peter Sabaini
-
Pagerduty alerting, action logging
Ability to have pagerduty alerts triggered on auto-approve. Revamp
logging, add logging for remove-sudopair action. Use https proxy to
contact the PD events endpoint if set in model config. - 0e31f83... by Alvaro Uria
-
Merge remote-tracking branch 'drew/copyright'
Reviewed-on: https:/
/code.launchpad .net/~afreiberg er/charm- sudo-pair/ +git/charm- sudo-pair/ +merge/ 392422
Reviewed-by: Alvaro Uria <email address hidden> - 7628722... by Drew Freiberger
-
Update copyright to 2018-2020
- 24bbbb2... by Giuseppe Petralia
-
Add copyright Apache-2
- 7e3bae4... by Giuseppe Petralia
-
Update noqa comments to do a single check
- 625fd16... by Giuseppe Petralia
-
Fix flake8/black conflicts.
- 98e3587... by Giuseppe Petralia
-
Extra Linting completed for 20.08 charm release
- 224d950... by Giuseppe Petralia
-
Blackened repository to 88 lines
- 421e685... by Giuseppe Petralia
-
Imported standard Makefile and tox.ini and fixed up tests
- de75f72... by Adam Dyess
-
Resolve issue where workload status isn't updated after remove-sudopair action
Preview Diff
1 | diff --git a/.gitignore b/.gitignore |
2 | index eda8bea..6f1f367 100644 |
3 | --- a/.gitignore |
4 | +++ b/.gitignore |
5 | @@ -1,33 +1,41 @@ |
6 | +# Juju files |
7 | +.unit-state.db |
8 | +.go-cookies |
9 | + |
10 | +layers/* |
11 | +interfaces/* |
12 | + |
13 | # Byte-compiled / optimized / DLL files |
14 | __pycache__/ |
15 | *.py[cod] |
16 | *$py.class |
17 | |
18 | +# Tests files and dir |
19 | +.pytest_cache/ |
20 | +.coverage |
21 | +.tox |
22 | +report/ |
23 | +htmlcov/ |
24 | + |
25 | # Log files |
26 | *.log |
27 | |
28 | -.tox/ |
29 | -src/.tox/ |
30 | -.coverage |
31 | +# pycharm |
32 | +.idea/ |
33 | |
34 | # vi |
35 | .*.swp |
36 | |
37 | -# pycharm |
38 | -.idea/ |
39 | -.unit-state.db |
40 | -src/.unit-state.db |
41 | - |
42 | # version data |
43 | repo-info |
44 | +version |
45 | |
46 | +# Python builds |
47 | +deb_dist/ |
48 | +dist/ |
49 | |
50 | -# reports |
51 | -report/* |
52 | -src/report/* |
53 | - |
54 | -# virtual env |
55 | -venv/* |
56 | +# Snaps |
57 | +*.snap |
58 | |
59 | -# builds |
60 | -builds/* |
61 | \ No newline at end of file |
62 | +# Builds |
63 | +.build/ |
64 | \ No newline at end of file |
65 | diff --git a/Makefile b/Makefile |
66 | index c7506b2..0a84b5f 100644 |
67 | --- a/Makefile |
68 | +++ b/Makefile |
69 | @@ -1,53 +1,80 @@ |
70 | -PROJECTPATH = $(dir $(realpath $(MAKEFILE_LIST))) |
71 | -DIRNAME = $(notdir $(PROJECTPATH:%/=%)) |
72 | +PYTHON := /usr/bin/python3 |
73 | |
74 | +PROJECTPATH=$(dir $(realpath $(MAKEFILE_LIST))) |
75 | ifndef CHARM_BUILD_DIR |
76 | - CHARM_BUILD_DIR := /tmp/$(DIRNAME)-builds |
77 | - $(warning Warning CHARM_BUILD_DIR was not set, defaulting to $(CHARM_BUILD_DIR)) |
78 | + CHARM_BUILD_DIR=${PROJECTPATH}.build |
79 | endif |
80 | +ifndef CHARM_LAYERS_DIR |
81 | + CHARM_LAYERS_DIR=${PROJECTPATH}/layers |
82 | +endif |
83 | +ifndef CHARM_INTERFACES_DIR |
84 | + CHARM_INTERFACES_DIR=${PROJECTPATH}/interfaces |
85 | +endif |
86 | +METADATA_FILE="src/metadata.yaml" |
87 | +CHARM_NAME=$(shell cat ${PROJECTPATH}/${METADATA_FILE} | grep -E "^name:" | awk '{print $$2}') |
88 | |
89 | help: |
90 | @echo "This project supports the following targets" |
91 | @echo "" |
92 | @echo " make help - show this text" |
93 | - @echo " make lint - run flake8" |
94 | - @echo " make test - run the functional tests, unittests and lint" |
95 | - @echo " make unittest - run the tests defined in the unittest subdirectory" |
96 | - @echo " make functional - run the tests defined in the functional subdirectory" |
97 | - @echo " make release - build the charm" |
98 | @echo " make clean - remove unneeded files" |
99 | + @echo " make submodules - make sure that the submodules are up-to-date" |
100 | + @echo " make submodules-update - update submodules to latest changes on remote branch" |
101 | + @echo " make build - build the charm" |
102 | + @echo " make release - run clean, submodules, and build targets" |
103 | + @echo " make lint - run flake8 and black --check" |
104 | + @echo " make black - run black and reformat files" |
105 | + @echo " make proof - run charm proof" |
106 | + @echo " make unittests - run the tests defined in the unittest subdirectory" |
107 | + @echo " make functional - run the tests defined in the functional subdirectory" |
108 | + @echo " make test - run lint, proof, unittests and functional targets" |
109 | @echo "" |
110 | |
111 | -lint: |
112 | - @echo "Running flake8" |
113 | - @tox -e lint |
114 | - |
115 | -test: lint unittest functional |
116 | +clean: |
117 | + @echo "Cleaning files" |
118 | + @git clean -ffXd -e '!.idea' |
119 | + @echo "Cleaning existing build" |
120 | + @rm -rf ${CHARM_BUILD_DIR}/${CHARM_NAME} |
121 | |
122 | -functional: build |
123 | - @PYTEST_KEEP_MODEL=$(PYTEST_KEEP_MODEL) \ |
124 | - PYTEST_CLOUD_NAME=$(PYTEST_CLOUD_NAME) \ |
125 | - PYTEST_CLOUD_REGION=$(PYTEST_CLOUD_REGION) \ |
126 | - tox -e functional |
127 | +submodules: |
128 | + # @git submodule update --init --recursive |
129 | + @echo "No submodules. Skipping." |
130 | |
131 | -unittest: |
132 | - @tox -e unit |
133 | +submodules-update: |
134 | + # @git submodule update --init --recursive --remote --merge |
135 | + @echo "No submodules. Skipping." |
136 | |
137 | build: |
138 | - @echo "Building charm to base directory $(CHARM_BUILD_DIR)" |
139 | - @-git describe --tags > ./repo-info |
140 | - @CHARM_LAYERS_DIR=./layers CHARM_INTERFACES_DIR=./interfaces TERM=linux\ |
141 | - charm build --output-dir $(CHARM_BUILD_DIR) $(PROJECTPATH) --force |
142 | + @echo "Building charm to directory ${CHARM_BUILD_DIR}/${CHARM_NAME}" |
143 | + @-git rev-parse --abbrev-ref HEAD > ./src/repo-info |
144 | + @CHARM_LAYERS_DIR=${CHARM_LAYERS_DIR} CHARM_INTERFACES_DIR=${CHARM_INTERFACES_DIR} \ |
145 | + TERM=linux CHARM_BUILD_DIR=${CHARM_BUILD_DIR} charm build src/ |
146 | |
147 | release: clean build |
148 | - @echo "Charm is built at $(CHARM_BUILD_DIR)/builds" |
149 | + @echo "Charm is built at ${CHARM_BUILD_DIR}/${CHARM_NAME}" |
150 | |
151 | -clean: |
152 | - @echo "Cleaning files" |
153 | - @find $(PROJECTPATH) -iname __pycache__ -exec rm -r {} + |
154 | - @if [ -d $(CHARM_BUILD_DIR)/builds ] ; then rm -r $(CHARM_BUILD_DIR)/builds ; fi |
155 | - @if [ -d $(PROJECTPATH)/.tox ] ; then rm -r $(PROJECTPATH)/.tox ; fi |
156 | - @if [ -d $(PROJECTPATH)/.pytest_cache ] ; then rm -r $(PROJECTPATH)/.pytest_cache ; fi |
157 | +lint: |
158 | + @echo "Running lint checks" |
159 | + @cd src && tox -e lint |
160 | + |
161 | +black: |
162 | + @echo "Reformat files with black" |
163 | + @cd src && tox -e black |
164 | + |
165 | +proof: build |
166 | + @echo "Running charm proof" |
167 | + @charm proof ${CHARM_BUILD_DIR}/${CHARM_NAME} |
168 | + |
169 | +unittests: |
170 | + @echo "Running unit tests" |
171 | + @cd src && tox -e unit |
172 | + |
173 | +functional: build |
174 | + @echo "Executing functional tests in ${CHARM_BUILD_DIR}" |
175 | + @cd src && CHARM_BUILD_DIR=${CHARM_BUILD_DIR} tox -e func |
176 | + |
177 | +test: lint proof unittests functional |
178 | + @echo "Tests completed for charm ${CHARM_NAME}." |
179 | |
180 | # The targets below don't depend on a file |
181 | -.PHONY: lint test unittest functional build release clean help |
182 | +.PHONY: help submodules submodules-update clean build release lint black proof unittests functional test |
183 | diff --git a/actions/remove-sudopair b/actions/remove-sudopair |
184 | deleted file mode 120000 |
185 | index ff9536b..0000000 |
186 | --- a/actions/remove-sudopair |
187 | +++ /dev/null |
188 | @@ -1 +0,0 @@ |
189 | -./actions.py |
190 | \ No newline at end of file |
191 | diff --git a/interfaces/.empty b/interfaces/.empty |
192 | new file mode 100644 |
193 | index 0000000..e69de29 |
194 | --- /dev/null |
195 | +++ b/interfaces/.empty |
196 | diff --git a/layers/.empty b/layers/.empty |
197 | new file mode 100644 |
198 | index 0000000..e69de29 |
199 | --- /dev/null |
200 | +++ b/layers/.empty |
201 | diff --git a/lib/libsudopair.py b/lib/libsudopair.py |
202 | deleted file mode 100644 |
203 | index 05d1f9d..0000000 |
204 | --- a/lib/libsudopair.py |
205 | +++ /dev/null |
206 | @@ -1,151 +0,0 @@ |
207 | -import grp |
208 | -import os |
209 | - |
210 | -from charmhelpers.core import hookenv, host, templating |
211 | - |
212 | - |
213 | -def check_valid_group(group_name): |
214 | - """Check that a group exists.""" |
215 | - try: |
216 | - grp.getgrnam(group_name) |
217 | - return True |
218 | - except KeyError: |
219 | - return False |
220 | - |
221 | - |
222 | -def group_id(group_name): |
223 | - """Check that a group exists.""" |
224 | - return grp.getgrnam(group_name).gr_gid |
225 | - |
226 | - |
227 | -def group_names_to_group_ids(group_names): |
228 | - """ |
229 | - Return comma-separated list of Group Ids. |
230 | - |
231 | - :param group_names: i.e. "root,user1,user2" |
232 | - :return gids: i.e. "0,1001,1002" |
233 | - """ |
234 | - group_names = list(filter(check_valid_group, group_names.split(','))) |
235 | - return ','.join(map(str, (map(group_id, group_names)))) |
236 | - |
237 | - |
238 | -def copy_file(source, destination, owner, group, perms): |
239 | - """Copy a file on the unit.""" |
240 | - if destination is not None: |
241 | - target_dir = os.path.dirname(destination) |
242 | - if not os.path.exists(target_dir): |
243 | - # This is a terrible default directory permission, as the file |
244 | - # or its siblings will often contain secrets. |
245 | - host.mkdir(os.path.dirname(destination), owner, group, perms=0o755) |
246 | - with open(source, 'rb') as source_f: |
247 | - host.write_file(destination, source_f.read(), perms=perms, owner=owner, group=group) |
248 | - |
249 | - |
250 | -class SudoPairHelper(object): |
251 | - """Configure sudo-pair.""" |
252 | - |
253 | - def __init__(self): |
254 | - """Retrieve charm config and set defaults.""" |
255 | - self.charm_config = hookenv.config() |
256 | - self.binary_path = '/usr/bin/sudo_approve' |
257 | - self.sudo_conf_path = '/etc/sudo.conf' |
258 | - self.sudoers_path = '/etc/sudoers' |
259 | - self.sudo_lib_path = '/usr/lib/sudo/sudo_pair.so' |
260 | - self.sudoers_bypass_path = "/etc/sudoers.d/91-bypass-sudopair-cmds" |
261 | - self.user_prompt_path = '/etc/sudo_pair.prompt.user' |
262 | - self.pair_prompt_path = '/etc/sudo_pair.prompt.pair' |
263 | - self.socket_dir = '/var/run/sudo_pair' |
264 | - self.tmpfiles_conf = '/usr/lib/tmpfiles.d/sudo_pair.conf' |
265 | - self.owner = 'root' |
266 | - self.group = 'root' |
267 | - self.socket_dir_perms = 0o644 |
268 | - self.sudo_pair_so_perms = 0o644 |
269 | - self.prompt_perms = 0o644 |
270 | - self.sudoers_perms = 0o440 |
271 | - self.sudo_conf_perms = 0o644 |
272 | - self.sudo_approve_perms = 0o755 |
273 | - |
274 | - def get_config(self): |
275 | - """Return config as a dict.""" |
276 | - config = { |
277 | - 'binary_path': self.binary_path, |
278 | - 'user_prompt_path': self.user_prompt_path, |
279 | - 'pair_prompt_path': self.pair_prompt_path, |
280 | - 'socket_dir': self.socket_dir, |
281 | - 'gids_enforced': group_names_to_group_ids(self.charm_config['groups_enforced']), |
282 | - 'gids_exempted': group_names_to_group_ids(self.charm_config['groups_exempted']), |
283 | - } |
284 | - |
285 | - config.update(self.charm_config) |
286 | - return config |
287 | - |
288 | - def set_charm_config(self, charm_config): |
289 | - """Update configuration.""" |
290 | - self.charm_config = charm_config |
291 | - |
292 | - def render_sudo_conf(self): |
293 | - """Render sudo.conf file.""" |
294 | - return templating.render('sudo.conf.tmpl', self.sudo_conf_path, self.get_config(), |
295 | - perms=self.sudo_conf_perms, owner=self.owner, group=self.group) |
296 | - |
297 | - def create_socket_dir(self): |
298 | - """Create socket dir.""" |
299 | - host.mkdir(self.socket_dir, perms=self.socket_dir_perms, owner=self.owner, group=self.group) |
300 | - |
301 | - def create_tmpfiles_conf(self): |
302 | - """Create temporary conf file.""" |
303 | - with open(self.tmpfiles_conf, "w") as f: |
304 | - f.write("d {} 0755 - - -\n".format(self.socket_dir)) |
305 | - |
306 | - def install_sudo_pair_so(self): |
307 | - """Install sudo-pair lib.""" |
308 | - sudo_pair_lib = os.path.join(hookenv.charm_dir(), 'files', 'sudo_pair.so') |
309 | - copy_file(sudo_pair_lib, self.sudo_lib_path, self.owner, self.group, self.sudo_pair_so_perms) |
310 | - |
311 | - def copy_user_prompt(self): |
312 | - """Copy user prompt on the unit.""" |
313 | - prompt_file = os.path.join(hookenv.charm_dir(), 'files', 'sudo.prompt.user') |
314 | - copy_file(prompt_file, self.user_prompt_path, self.owner, self.group, self.prompt_perms) |
315 | - |
316 | - def copy_pair_prompt(self): |
317 | - """Copy pair prompt on the unit.""" |
318 | - prompt_file = os.path.join(hookenv.charm_dir(), 'files', 'sudo.prompt.pair') |
319 | - copy_file(prompt_file, self.pair_prompt_path, self.owner, self.group, self.prompt_perms) |
320 | - |
321 | - def copy_sudoers(self): |
322 | - """Copy sudoers file on the unit.""" |
323 | - sudoers_file = os.path.join(hookenv.charm_dir(), 'files', 'sudoers') |
324 | - copy_file(sudoers_file, self.sudoers_path, self.owner, self.group, self.sudoers_perms) |
325 | - |
326 | - def render_sudo_approve(self): |
327 | - """Render sudo-approve file.""" |
328 | - hookenv.log("Rendering sudo_approve.tmpl to {}".format(self.binary_path)) |
329 | - return templating.render('sudo_approve.tmpl', self.binary_path, self.get_config(), |
330 | - perms=self.sudo_approve_perms, owner=self.owner, group=self.group) |
331 | - |
332 | - def render_bypass_cmds(self): |
333 | - """Render bypass command file.""" |
334 | - if self.get_config()['bypass_cmds'] != "" and self.get_config()['bypass_group'] != "": |
335 | - hookenv.log("Render bypass cmds to {}".format(self.sudoers_bypass_path)) |
336 | - return templating.render('91-bypass-sudopair-cmds.tmpl', self.sudoers_bypass_path, |
337 | - self.get_config(), perms=0o440, owner=self.owner, group=self.group) |
338 | - return None |
339 | - |
340 | - def deconfigure(self): |
341 | - """Remove sudo-pair configuration.""" |
342 | - paths = [ |
343 | - self.sudo_conf_path, |
344 | - self.sudo_lib_path, |
345 | - self.binary_path, |
346 | - self.user_prompt_path, |
347 | - self.pair_prompt_path, |
348 | - self.sudoers_bypass_path, |
349 | - self.tmpfiles_conf |
350 | - ] |
351 | - hookenv.log("Deleting: {}".format(paths)) |
352 | - for path in paths: |
353 | - try: |
354 | - os.unlink(path) |
355 | - except Exception as e: |
356 | - # We're trying hard to delete all files, even if some might fail |
357 | - hookenv.log("Got exception unlinking {}: {}, continuing".format(path, e)) |
358 | diff --git a/README.md b/src/README.md |
359 | similarity index 98% |
360 | rename from README.md |
361 | rename to src/README.md |
362 | index 0804cca..3c80295 100644 |
363 | --- a/README.md |
364 | +++ b/src/README.md |
365 | @@ -45,7 +45,7 @@ tox -e functional |
366 | |
367 | |
368 | # Contact Information |
369 | -Giuseppe Petralia <giuseppe.petralia@canonical.com> |
370 | +BootStack Charmers <bootstack-charmers@lists.canonical.com> |
371 | |
372 | [service]: https://github.com/square/sudo_pair |
373 | [icon guidelines]: https://jujucharms.com/docs/stable/authors-charm-icon |
374 | diff --git a/actions.yaml b/src/actions.yaml |
375 | similarity index 100% |
376 | rename from actions.yaml |
377 | rename to src/actions.yaml |
378 | diff --git a/actions/actions.py b/src/actions/actions.py |
379 | similarity index 78% |
380 | rename from actions/actions.py |
381 | rename to src/actions/actions.py |
382 | index ccf3ffc..6ab26a2 100755 |
383 | --- a/actions/actions.py |
384 | +++ b/src/actions/actions.py |
385 | @@ -1,4 +1,5 @@ |
386 | #!/usr/local/sbin/charm-env python3 |
387 | +"""Sudo Pair actions.""" |
388 | # |
389 | # Copyright 2016,2019,2020 Canonical Ltd |
390 | # |
391 | @@ -13,10 +14,13 @@ |
392 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
393 | # See the License for the specific language governing permissions and |
394 | # limitations under the License. |
395 | +import logging |
396 | +import logging.handlers |
397 | import os |
398 | +import subprocess |
399 | import sys |
400 | |
401 | -from charmhelpers.core.hookenv import action_fail, action_set |
402 | +from charmhelpers.core.hookenv import action_fail, action_set, status_set |
403 | |
404 | |
405 | sys.path.append("lib") |
406 | @@ -24,11 +28,23 @@ sys.path.append("lib") |
407 | import libsudopair # NOQA |
408 | |
409 | |
410 | +logger = logging.getLogger("sudopair") |
411 | +logger.setLevel(logging.INFO) |
412 | +handler = logging.handlers.SysLogHandler( |
413 | + address="/dev/log", facility=logging.handlers.SysLogHandler.LOG_AUTH |
414 | +) |
415 | +logger.addHandler(handler) |
416 | + |
417 | + |
418 | def remove(): |
419 | """Remove sudo-pair config and binaries.""" |
420 | sph = libsudopair.SudoPairHelper() |
421 | sph.deconfigure() |
422 | - action_set({"message": "Successfully removed sudo-pair config and binaries"}) |
423 | + msg = "Successfully removed sudo-pair config and binaries" |
424 | + logger.warning(msg) |
425 | + subprocess.run(["/usr/bin/pagerdutyevent.py", msg]) |
426 | + action_set({"message": msg}) |
427 | + status_set("active", msg) |
428 | |
429 | |
430 | # A dictionary of all the defined actions to callables (which take |
431 | @@ -37,6 +53,7 @@ ACTIONS = {"remove-sudopair": remove} |
432 | |
433 | |
434 | def main(args): |
435 | + """Dispatch actions based on command arguments.""" |
436 | action_name = os.path.basename(args[0]) |
437 | try: |
438 | ACTIONS[action_name]() |
439 | diff --git a/src/actions/remove-sudopair b/src/actions/remove-sudopair |
440 | new file mode 120000 |
441 | index 0000000..ff9536b |
442 | --- /dev/null |
443 | +++ b/src/actions/remove-sudopair |
444 | @@ -0,0 +1 @@ |
445 | +./actions.py |
446 | \ No newline at end of file |
447 | diff --git a/config.yaml b/src/config.yaml |
448 | similarity index 86% |
449 | rename from config.yaml |
450 | rename to src/config.yaml |
451 | index 795269b..6089ac9 100644 |
452 | --- a/config.yaml |
453 | +++ b/src/config.yaml |
454 | @@ -19,3 +19,11 @@ options: |
455 | type: boolean |
456 | default: true |
457 | description: "If true, auto approval is permitted." |
458 | + pagerduty_key: |
459 | + type: string |
460 | + default: '' |
461 | + description: "If set, a pagerduty event will be triggered upon auto-approving" |
462 | + pagerduty_context: |
463 | + type: string |
464 | + default: '' |
465 | + description: "Prefix to add to pagerduty events" |
466 | diff --git a/src/copyright b/src/copyright |
467 | new file mode 100644 |
468 | index 0000000..8fc50d9 |
469 | --- /dev/null |
470 | +++ b/src/copyright |
471 | @@ -0,0 +1,16 @@ |
472 | +Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0 |
473 | + |
474 | +Files: * |
475 | +Copyright: 2018-2020, Canonical Ltd. |
476 | +License: Apache-2.0 |
477 | + Licensed under the Apache License, Version 2.0 (the "License"); you may |
478 | + not use this file except in compliance with the License. You may obtain |
479 | + a copy of the License at |
480 | + |
481 | + http://www.apache.org/licenses/LICENSE-2.0 |
482 | + |
483 | + Unless required by applicable law or agreed to in writing, software |
484 | + distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
485 | + WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
486 | + License for the specific language governing permissions and limitations |
487 | + under the License. |
488 | diff --git a/src/files/pagerdutyevent.py b/src/files/pagerdutyevent.py |
489 | new file mode 100644 |
490 | index 0000000..f33be42 |
491 | --- /dev/null |
492 | +++ b/src/files/pagerdutyevent.py |
493 | @@ -0,0 +1,68 @@ |
494 | +#!/usr/bin/env python3 |
495 | +"""Send events to pagerduty.""" |
496 | + |
497 | +import argparse |
498 | +import configparser |
499 | +import json |
500 | +import logging |
501 | +import logging.handlers |
502 | + |
503 | +import requests |
504 | + |
505 | +logger = logging.getLogger("sudopair") |
506 | +logger.setLevel(logging.INFO) |
507 | +handler = logging.handlers.SysLogHandler( |
508 | + address="/dev/log", facility=logging.handlers.SysLogHandler.LOG_AUTH |
509 | +) |
510 | +logger.addHandler(handler) |
511 | + |
512 | + |
513 | +def args(): |
514 | + """Get cli args.""" |
515 | + parser = argparse.ArgumentParser() |
516 | + parser.add_argument("summary") |
517 | + return parser.parse_args() |
518 | + |
519 | + |
520 | +def trigger_incident(pd_key, summary, source, https_proxy=None): |
521 | + """Send an event to the PD events endpoint.""" |
522 | + header = {"Content-Type": "application/json"} |
523 | + |
524 | + payload = { |
525 | + "routing_key": pd_key, |
526 | + "event_action": "trigger", |
527 | + "payload": {"summary": summary, "source": source, "severity": "info"}, |
528 | + } |
529 | + if https_proxy: |
530 | + proxies = {"https": https_proxy} |
531 | + else: |
532 | + proxies = None |
533 | + response = requests.post( |
534 | + "https://events.pagerduty.com/v2/enqueue", |
535 | + data=json.dumps(payload), |
536 | + proxies=proxies, |
537 | + headers=header, |
538 | + ) |
539 | + |
540 | + if response.json()["status"] == "success": |
541 | + logger.info("Triggered alert with key {}".format(response.json()["dedup_key"])) |
542 | + else: |
543 | + logger.warning("Failed to send pagerduty alert: {}".format(response.text)) |
544 | + |
545 | + |
546 | +def get_conf(): |
547 | + """Return config options from file.""" |
548 | + config = configparser.ConfigParser() |
549 | + config.read("/etc/sudo_pair.pagerduty.ini") |
550 | + sec = config["DEFAULT"] |
551 | + return sec["pagerduty_key"], sec["pagerduty_source"], sec.get("https_proxy") |
552 | + |
553 | + |
554 | +def main(): |
555 | + """Run the script.""" |
556 | + pd_key, source, https_proxy = get_conf() |
557 | + trigger_incident(pd_key, args().summary, source, https_proxy) |
558 | + |
559 | + |
560 | +if __name__ == "__main__": |
561 | + main() |
562 | diff --git a/files/sudo.prompt.pair b/src/files/sudo.prompt.pair |
563 | similarity index 100% |
564 | rename from files/sudo.prompt.pair |
565 | rename to src/files/sudo.prompt.pair |
566 | diff --git a/files/sudo.prompt.user b/src/files/sudo.prompt.user |
567 | similarity index 100% |
568 | rename from files/sudo.prompt.user |
569 | rename to src/files/sudo.prompt.user |
570 | diff --git a/files/sudo_pair.so b/src/files/sudo_pair.so |
571 | similarity index 100% |
572 | rename from files/sudo_pair.so |
573 | rename to src/files/sudo_pair.so |
574 | Binary files a/files/sudo_pair.so and b/src/files/sudo_pair.so differ |
575 | diff --git a/files/sudoers b/src/files/sudoers |
576 | similarity index 100% |
577 | rename from files/sudoers |
578 | rename to src/files/sudoers |
579 | diff --git a/layer.yaml b/src/layer.yaml |
580 | similarity index 100% |
581 | rename from layer.yaml |
582 | rename to src/layer.yaml |
583 | diff --git a/src/lib/libsudopair.py b/src/lib/libsudopair.py |
584 | new file mode 100644 |
585 | index 0000000..c66873a |
586 | --- /dev/null |
587 | +++ b/src/lib/libsudopair.py |
588 | @@ -0,0 +1,237 @@ |
589 | +"""Sudo pair utilities.""" |
590 | +import grp |
591 | +import os |
592 | + |
593 | +from charmhelpers.core import hookenv, host, templating |
594 | + |
595 | + |
596 | +def check_valid_group(group_name): |
597 | + """Check that a group exists.""" |
598 | + try: |
599 | + grp.getgrnam(group_name) |
600 | + return True |
601 | + except KeyError: |
602 | + return False |
603 | + |
604 | + |
605 | +def group_id(group_name): |
606 | + """Check that a group exists.""" |
607 | + return grp.getgrnam(group_name).gr_gid |
608 | + |
609 | + |
610 | +def group_names_to_group_ids(group_names): |
611 | + """ |
612 | + Return comma-separated list of Group Ids. |
613 | + |
614 | + :param group_names: i.e. "root,user1,user2" |
615 | + :return gids: i.e. "0,1001,1002" |
616 | + """ |
617 | + group_names = list(filter(check_valid_group, group_names.split(","))) |
618 | + return ",".join(map(str, (map(group_id, group_names)))) |
619 | + |
620 | + |
621 | +def copy_file(source, destination, owner, group, perms): |
622 | + """Copy a file on the unit.""" |
623 | + if destination is not None: |
624 | + target_dir = os.path.dirname(destination) |
625 | + if not os.path.exists(target_dir): |
626 | + # This is a terrible default directory permission, as the file |
627 | + # or its siblings will often contain secrets. |
628 | + host.mkdir(os.path.dirname(destination), owner, group, perms=0o755) |
629 | + with open(source, "rb") as source_f: |
630 | + host.write_file( |
631 | + destination, source_f.read(), perms=perms, owner=owner, group=group |
632 | + ) |
633 | + |
634 | + |
635 | +class SudoPairHelper(object): |
636 | + """Configure sudo-pair.""" |
637 | + |
638 | + def __init__(self): |
639 | + """Retrieve charm config and set defaults.""" |
640 | + self.charm_config = hookenv.config() |
641 | + self.binary_path = "/usr/bin/sudo_approve" |
642 | + self.sudo_conf_path = "/etc/sudo.conf" |
643 | + self.sudoers_path = "/etc/sudoers" |
644 | + self.sudo_lib_path = "/usr/lib/sudo/sudo_pair.so" |
645 | + self.sudoers_bypass_path = "/etc/sudoers.d/91-bypass-sudopair-cmds" |
646 | + self.user_prompt_path = "/etc/sudo_pair.prompt.user" |
647 | + self.pair_prompt_path = "/etc/sudo_pair.prompt.pair" |
648 | + self.socket_dir = "/var/run/sudo_pair" |
649 | + self.tmpfiles_conf = "/usr/lib/tmpfiles.d/sudo_pair.conf" |
650 | + self.owner = "root" |
651 | + self.group = "root" |
652 | + self.socket_dir_perms = 0o644 |
653 | + self.sudo_pair_so_perms = 0o644 |
654 | + self.prompt_perms = 0o644 |
655 | + self.sudoers_perms = 0o440 |
656 | + self.sudo_conf_perms = 0o644 |
657 | + self.sudo_approve_perms = 0o755 |
658 | + self.pagerduty_conf_path = "/etc/sudo_pair.pagerduty.ini" |
659 | + self.pagerduty_path = "/usr/bin/pagerdutyevent.py" |
660 | + self.pagerduty_perms = 0o755 |
661 | + |
662 | + def get_config(self): |
663 | + """Return config as a dict.""" |
664 | + config = { |
665 | + "binary_path": self.binary_path, |
666 | + "user_prompt_path": self.user_prompt_path, |
667 | + "pair_prompt_path": self.pair_prompt_path, |
668 | + "socket_dir": self.socket_dir, |
669 | + "gids_enforced": group_names_to_group_ids( |
670 | + self.charm_config["groups_enforced"] |
671 | + ), |
672 | + "gids_exempted": group_names_to_group_ids( |
673 | + self.charm_config["groups_exempted"] |
674 | + ), |
675 | + "pagerduty_key": self.charm_config.get("pagerduty_key"), |
676 | + } |
677 | + proxy_settings = hookenv.env_proxy_settings() |
678 | + if proxy_settings: |
679 | + config["https_proxy"] = proxy_settings.get("https_proxy") |
680 | + config.update(self.charm_config) |
681 | + return config |
682 | + |
683 | + def set_charm_config(self, charm_config): |
684 | + """Update configuration.""" |
685 | + self.charm_config = charm_config |
686 | + |
687 | + def copy_pagerduty(self): |
688 | + """Copy PD script in place.""" |
689 | + pd_file = os.path.join(hookenv.charm_dir(), "files", "pagerdutyevent.py") |
690 | + copy_file( |
691 | + pd_file, self.pagerduty_path, self.owner, self.group, self.pagerduty_perms |
692 | + ) |
693 | + |
694 | + def render_sudo_conf(self): |
695 | + """Render sudo.conf file.""" |
696 | + return templating.render( |
697 | + "sudo.conf.tmpl", |
698 | + self.sudo_conf_path, |
699 | + self.get_config(), |
700 | + perms=self.sudo_conf_perms, |
701 | + owner=self.owner, |
702 | + group=self.group, |
703 | + ) |
704 | + |
705 | + def create_socket_dir(self): |
706 | + """Create socket dir.""" |
707 | + host.mkdir( |
708 | + self.socket_dir, |
709 | + perms=self.socket_dir_perms, |
710 | + owner=self.owner, |
711 | + group=self.group, |
712 | + ) |
713 | + |
714 | + def create_tmpfiles_conf(self): |
715 | + """Create temporary conf file.""" |
716 | + with open(self.tmpfiles_conf, "w") as f: |
717 | + f.write("d {} 0755 - - -\n".format(self.socket_dir)) |
718 | + |
719 | + def install_sudo_pair_so(self): |
720 | + """Install sudo-pair lib.""" |
721 | + sudo_pair_lib = os.path.join(hookenv.charm_dir(), "files", "sudo_pair.so") |
722 | + copy_file( |
723 | + sudo_pair_lib, |
724 | + self.sudo_lib_path, |
725 | + self.owner, |
726 | + self.group, |
727 | + self.sudo_pair_so_perms, |
728 | + ) |
729 | + |
730 | + def copy_user_prompt(self): |
731 | + """Copy user prompt on the unit.""" |
732 | + prompt_file = os.path.join(hookenv.charm_dir(), "files", "sudo.prompt.user") |
733 | + copy_file( |
734 | + prompt_file, |
735 | + self.user_prompt_path, |
736 | + self.owner, |
737 | + self.group, |
738 | + self.prompt_perms, |
739 | + ) |
740 | + |
741 | + def copy_pair_prompt(self): |
742 | + """Copy pair prompt on the unit.""" |
743 | + prompt_file = os.path.join(hookenv.charm_dir(), "files", "sudo.prompt.pair") |
744 | + copy_file( |
745 | + prompt_file, |
746 | + self.pair_prompt_path, |
747 | + self.owner, |
748 | + self.group, |
749 | + self.prompt_perms, |
750 | + ) |
751 | + |
752 | + def copy_sudoers(self): |
753 | + """Copy sudoers file on the unit.""" |
754 | + sudoers_file = os.path.join(hookenv.charm_dir(), "files", "sudoers") |
755 | + copy_file( |
756 | + sudoers_file, self.sudoers_path, self.owner, self.group, self.sudoers_perms |
757 | + ) |
758 | + |
759 | + def render_sudo_approve(self): |
760 | + """Render sudo-approve file.""" |
761 | + hookenv.log("Rendering sudo_approve.tmpl to {}".format(self.binary_path)) |
762 | + return templating.render( |
763 | + "sudo_approve.tmpl", |
764 | + self.binary_path, |
765 | + self.get_config(), |
766 | + perms=self.sudo_approve_perms, |
767 | + owner=self.owner, |
768 | + group=self.group, |
769 | + ) |
770 | + |
771 | + def render_bypass_cmds(self): |
772 | + """Render bypass command file.""" |
773 | + if ( |
774 | + self.get_config()["bypass_cmds"] != "" |
775 | + and self.get_config()["bypass_group"] != "" |
776 | + ): |
777 | + hookenv.log("Render bypass cmds to {}".format(self.sudoers_bypass_path)) |
778 | + return templating.render( |
779 | + "91-bypass-sudopair-cmds.tmpl", |
780 | + self.sudoers_bypass_path, |
781 | + self.get_config(), |
782 | + perms=0o440, |
783 | + owner=self.owner, |
784 | + group=self.group, |
785 | + ) |
786 | + return None |
787 | + |
788 | + def render_pagerduty_conf(self): |
789 | + """Create the PD script configuration.""" |
790 | + pd_source = "{}-{}".format( |
791 | + self.charm_config.get("pagerduty_context", "juju"), hookenv.principal_unit() |
792 | + ) |
793 | + cfg = self.get_config() |
794 | + cfg["pagerduty_source"] = pd_source |
795 | + hookenv.log( |
796 | + "Rendering pagerduty configuration to {}".format(self.pagerduty_conf_path) |
797 | + ) |
798 | + return templating.render( |
799 | + "sudo_pair.pagerduty.tmpl", |
800 | + self.pagerduty_conf_path, |
801 | + cfg, |
802 | + owner=self.owner, |
803 | + group=self.group, |
804 | + ) |
805 | + |
806 | + def deconfigure(self): |
807 | + """Remove sudo-pair configuration.""" |
808 | + paths = [ |
809 | + self.sudo_conf_path, |
810 | + self.sudo_lib_path, |
811 | + self.binary_path, |
812 | + self.user_prompt_path, |
813 | + self.pair_prompt_path, |
814 | + self.sudoers_bypass_path, |
815 | + self.tmpfiles_conf, |
816 | + ] |
817 | + hookenv.log("Deleting: {}".format(paths)) |
818 | + for path in paths: |
819 | + try: |
820 | + os.unlink(path) |
821 | + except Exception as e: |
822 | + # We're trying hard to delete all files, even if some might fail |
823 | + hookenv.log( |
824 | + "Got exception unlinking {}: {}, continuing".format(path, e) |
825 | + ) |
826 | diff --git a/metadata.yaml b/src/metadata.yaml |
827 | similarity index 94% |
828 | rename from metadata.yaml |
829 | rename to src/metadata.yaml |
830 | index fe2a13c..8b6a9d4 100644 |
831 | --- a/metadata.yaml |
832 | +++ b/src/metadata.yaml |
833 | @@ -1,7 +1,7 @@ |
834 | name: sudo-pair |
835 | display-name: sudo-pair |
836 | summary: sudo_pair is a sudo plugin to manage root privileges |
837 | -maintainer: LMA Charmers <llama-charmers@lists.ubuntu.com> |
838 | +maintainer: BootStack Charmers <bootstack-charmers@lists.canonical.com> |
839 | description: | |
840 | sudo_pair is a sudo plugin that ensure that if a user tries to get root privileges, |
841 | he will need an authorization from a pair |
842 | diff --git a/reactive/sudo_pair.py b/src/reactive/sudo_pair.py |
843 | similarity index 55% |
844 | rename from reactive/sudo_pair.py |
845 | rename to src/reactive/sudo_pair.py |
846 | index 974dd66..392383b 100644 |
847 | --- a/reactive/sudo_pair.py |
848 | +++ b/src/reactive/sudo_pair.py |
849 | @@ -1,3 +1,5 @@ |
850 | +"""Charm reactive hooks.""" |
851 | + |
852 | from charmhelpers.core import hookenv |
853 | |
854 | from charms.reactive import hook, remove_state, set_state, when, when_not |
855 | @@ -7,10 +9,14 @@ from libsudopair import SudoPairHelper |
856 | sph = SudoPairHelper() |
857 | |
858 | |
859 | -@when('apt.installed.socat') |
860 | -@when_not('sudo-pair.configured') |
861 | +@when("apt.installed.socat") |
862 | +@when_not("sudo-pair.configured") |
863 | def install_sudo_pair(): |
864 | - # Install sudo_pair.so, create socket dir, copy sudo_approve to /usr/bin, copy prompts to /etc |
865 | + """Install sudo pair. |
866 | + |
867 | + Install sudo_pair.so, create socket dir, copy sudo_approve to /usr/bin |
868 | + and copy prompts to /etc. |
869 | + """ |
870 | sph.install_sudo_pair_so() |
871 | |
872 | sph.create_socket_dir() |
873 | @@ -26,24 +32,33 @@ def install_sudo_pair(): |
874 | # Add "Defaults log_output to /etc/sudoers |
875 | sph.copy_sudoers() |
876 | |
877 | + # Add pagerduty script and config |
878 | + sph.copy_pagerduty() |
879 | + sph.render_pagerduty_conf() |
880 | + |
881 | # If there are cmds to bypass sudo pairing create file unders sudoers.d |
882 | sph.render_bypass_cmds() |
883 | |
884 | # Add Plugin sudo_pair sudo_pair.so to sudo.conf |
885 | sph.render_sudo_conf() |
886 | |
887 | - set_state('sudo-pair.installed') |
888 | - set_state('sudo-pair.configured') |
889 | - hookenv.status_set('active', 'sudo pairing for users groups: [{}]'.format(sph.get_config()['gids_enforced'])) |
890 | + set_state("sudo-pair.installed") |
891 | + set_state("sudo-pair.configured") |
892 | + hookenv.status_set( |
893 | + "active", |
894 | + "sudo pairing for users groups: [{}]".format(sph.get_config()["gids_enforced"]), |
895 | + ) |
896 | |
897 | |
898 | -@hook('config-changed') |
899 | +@hook("config-changed") |
900 | def reconfigure_sudo_pair_charm(): |
901 | + """Run config-changed hook.""" |
902 | sph.set_charm_config(hookenv.config()) |
903 | - remove_state('sudo-pair.configured') |
904 | + remove_state("sudo-pair.configured") |
905 | |
906 | |
907 | -@hook('stop') |
908 | +@hook("stop") |
909 | def stop(): |
910 | + """Run stop hook and remove charm configuration.""" |
911 | sph.deconfigure() |
912 | - remove_state('sudo-pair.installed') |
913 | + remove_state("sudo-pair.installed") |
914 | diff --git a/templates/91-bypass-sudopair-cmds.tmpl b/src/templates/91-bypass-sudopair-cmds.tmpl |
915 | similarity index 100% |
916 | rename from templates/91-bypass-sudopair-cmds.tmpl |
917 | rename to src/templates/91-bypass-sudopair-cmds.tmpl |
918 | diff --git a/templates/sudo.conf.tmpl b/src/templates/sudo.conf.tmpl |
919 | similarity index 100% |
920 | rename from templates/sudo.conf.tmpl |
921 | rename to src/templates/sudo.conf.tmpl |
922 | diff --git a/templates/sudo_approve.tmpl b/src/templates/sudo_approve.tmpl |
923 | similarity index 96% |
924 | rename from templates/sudo_approve.tmpl |
925 | rename to src/templates/sudo_approve.tmpl |
926 | index 7164b5a..49665d3 100755 |
927 | --- a/templates/sudo_approve.tmpl |
928 | +++ b/src/templates/sudo_approve.tmpl |
929 | @@ -88,7 +88,7 @@ main() { |
930 | declare -r username |
931 | |
932 | declare log_line |
933 | - log_line="$(date "+[%b %d %H:%M:%S] WARNING: ${username} approved is own sudo session.")" |
934 | + log_line="WARNING: ${username} approved own sudo session." |
935 | declare -r log_line |
936 | |
937 | if [[ "${uid}" -eq "${ruid}" ]]; then |
938 | @@ -97,7 +97,10 @@ main() { |
939 | exit 1 |
940 | {% else %} |
941 | echo "You are approving your own session. The incident will be logged." |
942 | - echo ${log_line} >> /var/log/sudo_pair.log |
943 | + logger -p auth.warn $log_line |
944 | + {% if pagerduty_key %} |
945 | + /usr/bin/pagerdutyevent.py "$log_line" |
946 | + {% endif %} |
947 | {% endif %} |
948 | fi |
949 | |
950 | diff --git a/src/templates/sudo_pair.pagerduty.tmpl b/src/templates/sudo_pair.pagerduty.tmpl |
951 | new file mode 100644 |
952 | index 0000000..2185cf9 |
953 | --- /dev/null |
954 | +++ b/src/templates/sudo_pair.pagerduty.tmpl |
955 | @@ -0,0 +1,6 @@ |
956 | +[DEFAULT] |
957 | +pagerduty_key: {{ pagerduty_key }} |
958 | +pagerduty_source: {{ pagerduty_source }} |
959 | +{% if https_proxy -%} |
960 | +https_proxy: {{ https_proxy }} |
961 | +{% endif %} |
962 | \ No newline at end of file |
963 | diff --git a/tests/00-unit b/src/tests/00-unit |
964 | similarity index 100% |
965 | rename from tests/00-unit |
966 | rename to src/tests/00-unit |
967 | diff --git a/tests/01-functional b/src/tests/01-functional |
968 | similarity index 100% |
969 | rename from tests/01-functional |
970 | rename to src/tests/01-functional |
971 | diff --git a/tests/functional/conftest.py b/src/tests/functional/conftest.py |
972 | similarity index 75% |
973 | rename from tests/functional/conftest.py |
974 | rename to src/tests/functional/conftest.py |
975 | index aa42a09..fdd4356 100644 |
976 | --- a/tests/functional/conftest.py |
977 | +++ b/src/tests/functional/conftest.py |
978 | @@ -1,4 +1,5 @@ |
979 | #!/usr/bin/python3 |
980 | +"""Functional tests utilities.""" |
981 | |
982 | import asyncio |
983 | import json |
984 | @@ -16,7 +17,7 @@ import pytest |
985 | STAT_FILE = "python3 -c \"import json; import os; s=os.stat('%s'); print(json.dumps({'uid': s.st_uid, 'gid': s.st_gid, 'mode': oct(s.st_mode), 'size': s.st_size}))\"" # noqa: E501 |
986 | |
987 | |
988 | -@pytest.yield_fixture(scope='module') |
989 | +@pytest.yield_fixture(scope="module") |
990 | def event_loop(request): |
991 | """Override the default pytest event loop to allow for broaded scopedv fixtures.""" |
992 | loop = asyncio.get_event_loop_policy().new_event_loop() |
993 | @@ -27,7 +28,7 @@ def event_loop(request): |
994 | asyncio.set_event_loop(None) |
995 | |
996 | |
997 | -@pytest.fixture(scope='module') |
998 | +@pytest.fixture(scope="module") |
999 | async def controller(): |
1000 | """Connect to the current controller.""" |
1001 | controller = Controller() |
1002 | @@ -36,21 +37,21 @@ async def controller(): |
1003 | await controller.disconnect() |
1004 | |
1005 | |
1006 | -@pytest.fixture(scope='module') |
1007 | +@pytest.fixture(scope="module") |
1008 | async def model(controller): |
1009 | """Create a model that lives only for the duration of the test.""" |
1010 | model_name = "functest-{}".format(uuid.uuid4()) |
1011 | model = await controller.add_model(model_name) |
1012 | yield model |
1013 | await model.disconnect() |
1014 | - if os.getenv('test_preserve_model'): |
1015 | + if os.getenv("PYTEST_KEEP_MODEL"): |
1016 | return |
1017 | await controller.destroy_model(model_name) |
1018 | while model_name in await controller.list_models(): |
1019 | await asyncio.sleep(1) |
1020 | |
1021 | |
1022 | -@pytest.fixture(scope='module') |
1023 | +@pytest.fixture(scope="module") |
1024 | async def current_model(): |
1025 | """Return the current model, does not create or destroy it.""" |
1026 | model = Model() |
1027 | @@ -60,31 +61,36 @@ async def current_model(): |
1028 | |
1029 | |
1030 | @pytest.fixture |
1031 | -async def get_app(model): |
1032 | +async def get_app(model): # noqa: D202 |
1033 | """Return the application requested.""" |
1034 | + |
1035 | async def _get_app(name): |
1036 | try: |
1037 | return model.applications[name] |
1038 | except KeyError: |
1039 | raise JujuError("Cannot find application {}".format(name)) |
1040 | + |
1041 | return _get_app |
1042 | |
1043 | |
1044 | @pytest.fixture |
1045 | -async def get_unit(model): |
1046 | +async def get_unit(model): # noqa: D202 |
1047 | """Return the requested <app_name>/<unit_number> unit.""" |
1048 | + |
1049 | async def _get_unit(name): |
1050 | try: |
1051 | - (app_name, unit_number) = name.split('/') |
1052 | + (app_name, unit_number) = name.split("/") |
1053 | return model.applications[app_name].units[unit_number] |
1054 | except (KeyError, ValueError): |
1055 | raise JujuError("Cannot find unit {}".format(name)) |
1056 | + |
1057 | return _get_unit |
1058 | |
1059 | |
1060 | @pytest.fixture |
1061 | -async def get_entity(model, get_unit, get_app): |
1062 | +async def get_entity(model, get_unit, get_app): # noqa: D202 |
1063 | """Return a unit or an application.""" |
1064 | + |
1065 | async def _get_entity(name): |
1066 | try: |
1067 | return await get_unit(name) |
1068 | @@ -93,61 +99,65 @@ async def get_entity(model, get_unit, get_app): |
1069 | return await get_app(name) |
1070 | except JujuError: |
1071 | raise JujuError("Cannot find entity {}".format(name)) |
1072 | + |
1073 | return _get_entity |
1074 | |
1075 | |
1076 | @pytest.fixture |
1077 | -async def run_command(get_unit): |
1078 | - """ |
1079 | - Run a command on a unit. |
1080 | +async def run_command(get_unit): # noqa: D202 |
1081 | + """Run a command on an unit.""" |
1082 | |
1083 | - :param cmd: Command to be run |
1084 | - :param target: Unit object or unit name string |
1085 | - """ |
1086 | async def _run_command(cmd, target): |
1087 | - unit = ( |
1088 | - target |
1089 | - if type(target) is juju.unit.Unit |
1090 | - else await get_unit(target) |
1091 | - ) |
1092 | + """Run a command on a unit. |
1093 | + |
1094 | + :param cmd: Command to be run |
1095 | + :param target: Unit object or unit name string |
1096 | + """ |
1097 | + unit = target if type(target) is juju.unit.Unit else await get_unit(target) |
1098 | action = await unit.run(cmd) |
1099 | return action.results |
1100 | + |
1101 | return _run_command |
1102 | |
1103 | |
1104 | @pytest.fixture |
1105 | -async def file_stat(run_command): |
1106 | - """ |
1107 | - Run stat on a file. |
1108 | +async def file_stat(run_command): # noqa: D202 |
1109 | + """Get file stat from an unit.""" |
1110 | |
1111 | - :param path: File path |
1112 | - :param target: Unit object or unit name string |
1113 | - """ |
1114 | async def _file_stat(path, target): |
1115 | + """Run stat on a file. |
1116 | + |
1117 | + :param path: File path |
1118 | + :param target: Unit object or unit name string |
1119 | + """ |
1120 | cmd = STAT_FILE % path |
1121 | results = await run_command(cmd, target) |
1122 | - return json.loads(results['Stdout']) |
1123 | + return json.loads(results["Stdout"]) |
1124 | + |
1125 | return _file_stat |
1126 | |
1127 | |
1128 | @pytest.fixture |
1129 | -async def file_contents(run_command): |
1130 | - """ |
1131 | - Return the contents of a file. |
1132 | +async def file_contents(run_command): # noqa: D202 |
1133 | + """Return the contents of a file.""" |
1134 | |
1135 | - :param path: File path |
1136 | - :param target: Unit object or unit name string |
1137 | - """ |
1138 | async def _file_contents(path, target): |
1139 | - cmd = 'cat {}'.format(path) |
1140 | + """Return the contents of a file. |
1141 | + |
1142 | + :param path: File path |
1143 | + :param target: Unit object or unit name string |
1144 | + """ |
1145 | + cmd = "cat {}".format(path) |
1146 | results = await run_command(cmd, target) |
1147 | - return results['Stdout'] |
1148 | + return results["Stdout"] |
1149 | + |
1150 | return _file_contents |
1151 | |
1152 | |
1153 | @pytest.fixture |
1154 | -async def reconfigure_app(get_app, model): |
1155 | +async def reconfigure_app(get_app, model): # noqa: D202 |
1156 | """Apply a different config to the requested app.""" |
1157 | + |
1158 | async def _reconfigure_app(cfg, target): |
1159 | application = ( |
1160 | target |
1161 | @@ -156,14 +166,17 @@ async def reconfigure_app(get_app, model): |
1162 | ) |
1163 | await application.set_config(cfg) |
1164 | await application.get_config() |
1165 | - await model.block_until(lambda: application.status == 'active') |
1166 | + await model.block_until(lambda: application.status == "active") |
1167 | + |
1168 | return _reconfigure_app |
1169 | |
1170 | |
1171 | @pytest.fixture |
1172 | -async def create_group(run_command): |
1173 | +async def create_group(run_command): # noqa: D202 |
1174 | """Create the UNIX group specified.""" |
1175 | + |
1176 | async def _create_group(group_name, target): |
1177 | cmd = "sudo groupadd %s" % group_name |
1178 | await run_command(cmd, target) |
1179 | + |
1180 | return _create_group |
1181 | diff --git a/tests/functional/requirements.txt b/src/tests/functional/requirements.txt |
1182 | similarity index 100% |
1183 | rename from tests/functional/requirements.txt |
1184 | rename to src/tests/functional/requirements.txt |
1185 | diff --git a/tests/functional/test_deploy.py b/src/tests/functional/test_deploy.py |
1186 | similarity index 51% |
1187 | rename from tests/functional/test_deploy.py |
1188 | rename to src/tests/functional/test_deploy.py |
1189 | index cdb5ae4..11e8297 100644 |
1190 | --- a/tests/functional/test_deploy.py |
1191 | +++ b/src/tests/functional/test_deploy.py |
1192 | @@ -1,4 +1,5 @@ |
1193 | #!/usr/bin/python3.6 |
1194 | +"""Charm functional tests.""" |
1195 | |
1196 | import os |
1197 | |
1198 | @@ -6,14 +7,16 @@ import pytest |
1199 | |
1200 | pytestmark = pytest.mark.asyncio |
1201 | |
1202 | -charm_build_dir = os.getenv('CHARM_BUILD_DIR', '..').rstrip('/') |
1203 | +charm_build_dir = os.getenv("CHARM_BUILD_DIR").rstrip("/") |
1204 | |
1205 | |
1206 | -sources = [('local', '{}/builds/sudo-pair'.format(charm_build_dir))] |
1207 | +sources = [("local", "{}/sudo-pair".format(charm_build_dir))] |
1208 | |
1209 | -series = ['xenial', |
1210 | - 'bionic', |
1211 | - ] |
1212 | +series = [ |
1213 | + "xenial", |
1214 | + "bionic", |
1215 | + "focal", |
1216 | +] |
1217 | |
1218 | |
1219 | ############ |
1220 | @@ -36,8 +39,8 @@ def source(request): |
1221 | @pytest.fixture |
1222 | async def app(model, series, source): |
1223 | """Return application of the charm under test.""" |
1224 | - app_name = 'sudo-pair-{}'.format(series) |
1225 | - return await model._wait_for_new('application', app_name) |
1226 | + app_name = "sudo-pair-{}".format(series) |
1227 | + return await model._wait_for_new("application", app_name) |
1228 | |
1229 | |
1230 | @pytest.fixture |
1231 | @@ -50,108 +53,92 @@ async def unit(app): |
1232 | # TESTS # |
1233 | ######### |
1234 | |
1235 | + |
1236 | async def test_deploy_app(model, series, source): |
1237 | """Deploy the sudo_pair app as a subordinate of ubuntu.""" |
1238 | await model.deploy( |
1239 | - 'ubuntu', |
1240 | - application_name='ubuntu-' + series, |
1241 | - series=series, |
1242 | - channel='stable' |
1243 | + "ubuntu", application_name="ubuntu-" + series, series=series, channel="stable" |
1244 | ) |
1245 | sudo_pair_app = await model.deploy( |
1246 | source[1], |
1247 | - application_name='sudo-pair-' + series, |
1248 | + application_name="sudo-pair-" + series, |
1249 | series=series, |
1250 | num_units=0, |
1251 | config={ |
1252 | - 'bypass_cmds': '/bin/ls', |
1253 | - 'groups_enforced': 'ubuntu', |
1254 | - 'bypass_group': 'warthogs', |
1255 | - } |
1256 | + "bypass_cmds": "/bin/ls", |
1257 | + "groups_enforced": "ubuntu", |
1258 | + "bypass_group": "warthogs", |
1259 | + }, |
1260 | ) |
1261 | await model.add_relation( |
1262 | - 'ubuntu-{}:juju-info'.format(series), |
1263 | - 'sudo-pair-{}:juju-info'.format(series)) |
1264 | + "ubuntu-{}:juju-info".format(series), "sudo-pair-{}:juju-info".format(series) |
1265 | + ) |
1266 | |
1267 | - await model.block_until(lambda: sudo_pair_app.status == 'active') |
1268 | + await model.block_until(lambda: sudo_pair_app.status == "active") |
1269 | # no need to cleanup since the model will be be torn down at the end of the |
1270 | # testing |
1271 | |
1272 | |
1273 | async def test_status(app): |
1274 | """Check that the app is in active state.""" |
1275 | - assert app.status == 'active' |
1276 | - |
1277 | - |
1278 | -@pytest.mark.parametrize("path,expected_stat", [ |
1279 | - ('/usr/lib/sudo/sudo_pair.so', { |
1280 | - 'gid': 0, |
1281 | - 'uid': 0, |
1282 | - 'mode': '0o100644'}), |
1283 | - ('/usr/bin/sudo_approve', { |
1284 | - 'gid': 0, |
1285 | - 'uid': 0, |
1286 | - 'mode': '0o100755'}), |
1287 | - ('/etc/sudo_pair.prompt.user', { |
1288 | - 'gid': 0, |
1289 | - 'uid': 0, |
1290 | - 'mode': '0o100644'}), |
1291 | - ('/etc/sudo_pair.prompt.pair', { |
1292 | - 'gid': 0, |
1293 | - 'uid': 0, |
1294 | - 'mode': '0o100644'}), |
1295 | - ('/var/run/sudo_pair', { |
1296 | - 'gid': 0, |
1297 | - 'uid': 0, |
1298 | - 'mode': '0o40644'})]) |
1299 | + assert app.status == "active" |
1300 | + |
1301 | + |
1302 | +@pytest.mark.parametrize( |
1303 | + "path,expected_stat", |
1304 | + [ |
1305 | + ("/usr/lib/sudo/sudo_pair.so", {"gid": 0, "uid": 0, "mode": "0o100644"}), |
1306 | + ("/usr/bin/sudo_approve", {"gid": 0, "uid": 0, "mode": "0o100755"}), |
1307 | + ("/etc/sudo_pair.prompt.user", {"gid": 0, "uid": 0, "mode": "0o100644"}), |
1308 | + ("/etc/sudo_pair.prompt.pair", {"gid": 0, "uid": 0, "mode": "0o100644"}), |
1309 | + ("/var/run/sudo_pair", {"gid": 0, "uid": 0, "mode": "0o40644"}), |
1310 | + ], |
1311 | +) |
1312 | async def test_stats(path, expected_stat, unit, file_stat): |
1313 | """Check that created files have the correct permissions.""" |
1314 | test_stat = await file_stat(path, unit) |
1315 | - assert test_stat['size'] > 0 |
1316 | - assert test_stat['gid'] == expected_stat['gid'] |
1317 | - assert test_stat['uid'] == expected_stat['uid'] |
1318 | - assert test_stat['mode'] == expected_stat['mode'] |
1319 | + assert test_stat["size"] > 0 |
1320 | + assert test_stat["gid"] == expected_stat["gid"] |
1321 | + assert test_stat["uid"] == expected_stat["uid"] |
1322 | + assert test_stat["mode"] == expected_stat["mode"] |
1323 | |
1324 | |
1325 | async def test_sudoers(file_contents, unit): |
1326 | """Check the content of sudoers file.""" |
1327 | sudoers_content = await file_contents("/etc/sudoers", unit) |
1328 | - assert 'Defaults log_output' in sudoers_content |
1329 | + assert "Defaults log_output" in sudoers_content |
1330 | |
1331 | |
1332 | async def test_sudoers_bypass_conf(file_contents, unit): |
1333 | """Check the content of sudoers bypass command file.""" |
1334 | path = "/etc/sudoers.d/91-bypass-sudopair-cmds" |
1335 | - sudoers_bypass_content = await file_contents(path=path, |
1336 | - target=unit) |
1337 | - content = '%warthogs ALL = (ALL) NOLOG_OUTPUT: /bin/ls' |
1338 | + sudoers_bypass_content = await file_contents(path=path, target=unit) |
1339 | + content = "%warthogs ALL = (ALL) NOLOG_OUTPUT: /bin/ls" |
1340 | assert content in sudoers_bypass_content |
1341 | |
1342 | |
1343 | async def test_reconfigure(reconfigure_app, file_contents, unit, app): |
1344 | - """Change a charm config parameter and verify that it has been propagated to the unit.""" |
1345 | - sudo_approve_path = '/usr/bin/sudo_approve' |
1346 | - await reconfigure_app(cfg={'auto_approve': 'false'}, |
1347 | - target=app) |
1348 | - sudo_approve_content = await file_contents(path=sudo_approve_path, |
1349 | - target=unit) |
1350 | + """Change a charm config parameter and verify it is applied.""" |
1351 | + sudo_approve_path = "/usr/bin/sudo_approve" |
1352 | + await reconfigure_app(cfg={"auto_approve": "false"}, target=app) |
1353 | + sudo_approve_content = await file_contents(path=sudo_approve_path, target=unit) |
1354 | new_content = 'echo "You can\'t approve your own session."' |
1355 | assert new_content in sudo_approve_content |
1356 | |
1357 | |
1358 | async def test_remove_relation(app, model, run_command): |
1359 | """Check that the relation is removed.""" |
1360 | - series = app.units[0].data['series'] |
1361 | - app_name = 'sudo-pair-{}'.format(series) |
1362 | - principalname = 'ubuntu-{}'.format(series) |
1363 | + series = app.units[0].data["series"] |
1364 | + app_name = "sudo-pair-{}".format(series) |
1365 | + principalname = "ubuntu-{}".format(series) |
1366 | await app.remove_relation( |
1367 | - '{}:juju-info'.format(app_name), |
1368 | - '{}:juju-info'.format(principalname)) |
1369 | + "{}:juju-info".format(app_name), "{}:juju-info".format(principalname) |
1370 | + ) |
1371 | await model.block_until(lambda: not app.relations) |
1372 | principal = model.applications[principalname].units[0] |
1373 | - res = await run_command('test -f /etc/sudo.conf || echo gone', target=principal) |
1374 | - assert res['Stdout'].strip() == 'gone' |
1375 | + res = await run_command("test -f /etc/sudo.conf || echo gone", target=principal) |
1376 | + assert res["Stdout"].strip() == "gone" |
1377 | await model.add_relation( |
1378 | - '{}:juju-info'.format(principalname), |
1379 | - '{}:juju-info'.format(app_name)) |
1380 | + "{}:juju-info".format(principalname), "{}:juju-info".format(app_name) |
1381 | + ) |
1382 | await model.block_until(lambda: app.relations) |
1383 | diff --git a/tests/tests.yaml b/src/tests/tests.yaml |
1384 | similarity index 100% |
1385 | rename from tests/tests.yaml |
1386 | rename to src/tests/tests.yaml |
1387 | diff --git a/tests/unit/conftest.py b/src/tests/unit/conftest.py |
1388 | similarity index 82% |
1389 | rename from tests/unit/conftest.py |
1390 | rename to src/tests/unit/conftest.py |
1391 | index c0800ea..bc65d15 100644 |
1392 | --- a/tests/unit/conftest.py |
1393 | +++ b/src/tests/unit/conftest.py |
1394 | @@ -1,4 +1,5 @@ |
1395 | #!/usr/bin/python3 |
1396 | +"""Unit tests utilities.""" |
1397 | |
1398 | import grp |
1399 | import os |
1400 | @@ -10,29 +11,33 @@ import pytest |
1401 | |
1402 | @pytest.fixture |
1403 | def mock_hookenv_config(monkeypatch): |
1404 | + """Mock hookenv config.""" |
1405 | import yaml |
1406 | |
1407 | def mock_config(): |
1408 | cfg = {} |
1409 | - yml = yaml.load(open('./config.yaml')) |
1410 | + yml = yaml.safe_load(open("./config.yaml")) |
1411 | |
1412 | # Load all defaults |
1413 | - for key, value in yml['options'].items(): |
1414 | - cfg[key] = value['default'] |
1415 | + for key, value in yml["options"].items(): |
1416 | + cfg[key] = value["default"] |
1417 | |
1418 | return cfg |
1419 | |
1420 | - monkeypatch.setattr('charmhelpers.core.hookenv.config', mock_config) |
1421 | + monkeypatch.setattr("charmhelpers.core.hookenv.config", mock_config) |
1422 | |
1423 | |
1424 | @pytest.fixture |
1425 | def mock_charm_dir(monkeypatch): |
1426 | - monkeypatch.setattr('charmhelpers.core.hookenv.charm_dir', lambda: '.') |
1427 | + """Mock charm dir.""" |
1428 | + monkeypatch.setattr("charmhelpers.core.hookenv.charm_dir", lambda: ".") |
1429 | |
1430 | |
1431 | @pytest.fixture |
1432 | def sph(mock_hookenv_config, mock_charm_dir, tmpdir): |
1433 | + """Return SudoPairHelpers with mocks.""" |
1434 | from libsudopair import SudoPairHelper |
1435 | + |
1436 | sph = SudoPairHelper() |
1437 | sph.owner = pwd.getpwuid(os.getuid()).pw_name |
1438 | sph.group = grp.getgrgid(os.getgid()).gr_name |
1439 | @@ -49,4 +54,5 @@ def sph(mock_hookenv_config, mock_charm_dir, tmpdir): |
1440 | sph.sudoers_bypass_path = tmpdir.join(sph.sudoers_bypass_path) |
1441 | sph.socket_dir_perms = 0o775 |
1442 | sph.sudo_conf_perms = 0o644 |
1443 | + sph.pagerduty_path = tmpdir.join(sph.pagerduty_path) |
1444 | return sph |
1445 | diff --git a/tests/unit/requirements.txt b/src/tests/unit/requirements.txt |
1446 | similarity index 100% |
1447 | rename from tests/unit/requirements.txt |
1448 | rename to src/tests/unit/requirements.txt |
1449 | diff --git a/src/tests/unit/test_actions.py b/src/tests/unit/test_actions.py |
1450 | new file mode 100644 |
1451 | index 0000000..fd0758a |
1452 | --- /dev/null |
1453 | +++ b/src/tests/unit/test_actions.py |
1454 | @@ -0,0 +1,23 @@ |
1455 | +"""Unit tests for charm actions.""" |
1456 | + |
1457 | +import os |
1458 | +import sys |
1459 | +import unittest.mock as mock |
1460 | + |
1461 | +action_path = os.path.join(os.path.dirname(__file__), "..", "..", "actions") |
1462 | +sys.path.append(action_path) |
1463 | +import actions # NOQA |
1464 | + |
1465 | + |
1466 | +@mock.patch("libsudopair.SudoPairHelper") |
1467 | +@mock.patch("actions.action_set") |
1468 | +@mock.patch("actions.status_set") |
1469 | +@mock.patch("actions.subprocess.run") |
1470 | +def test_remove_action(subprocess_run, status_set, action_set, sudo_pair_helper): |
1471 | + """Verify remove action.""" |
1472 | + actions.remove() |
1473 | + msg = "Successfully removed sudo-pair config and binaries" |
1474 | + action_set.assert_called_with({"message": msg}) |
1475 | + status_set.assert_called_with("active", msg) |
1476 | + sudo_pair_helper().deconfigure.assert_called() |
1477 | + subprocess_run.assert_called_with(["/usr/bin/pagerdutyevent.py", msg]) |
1478 | diff --git a/src/tests/unit/test_libsudopair.py b/src/tests/unit/test_libsudopair.py |
1479 | new file mode 100644 |
1480 | index 0000000..8d730ad |
1481 | --- /dev/null |
1482 | +++ b/src/tests/unit/test_libsudopair.py |
1483 | @@ -0,0 +1,206 @@ |
1484 | +"""Sudopair lib unit tests.""" |
1485 | + |
1486 | +import filecmp |
1487 | +import grp |
1488 | +import os |
1489 | + |
1490 | +from libsudopair import check_valid_group, group_id |
1491 | + |
1492 | + |
1493 | +def test_check_valid_group(): |
1494 | + """Check an unix group is valid.""" |
1495 | + assert not check_valid_group("fake_group") |
1496 | + assert check_valid_group(grp.getgrgid(os.getgid()).gr_name) |
1497 | + |
1498 | + |
1499 | +def test_group_id(): |
1500 | + """Verify group_id() is correct.""" |
1501 | + assert group_id(grp.getgrgid(os.getgid()).gr_name) == os.getgid() |
1502 | + |
1503 | + |
1504 | +class TestSudoPairHelper: |
1505 | + """Module to test SudoPairHelper lib.""" |
1506 | + |
1507 | + def test_pytest(self): |
1508 | + """Assert testing is carryied using pytest.""" |
1509 | + assert True |
1510 | + |
1511 | + def test_sph(self, sph): |
1512 | + """See if the sph fixture works to load charm configs.""" |
1513 | + assert isinstance(sph.charm_config, dict) |
1514 | + |
1515 | + def test_get_config(self, sph): |
1516 | + """Check if config contains all the required entries.""" |
1517 | + default_keywords = [ |
1518 | + "binary_path", |
1519 | + "user_prompt_path", |
1520 | + "pair_prompt_path", |
1521 | + "socket_dir", |
1522 | + "gids_enforced", |
1523 | + "gids_exempted", |
1524 | + ] |
1525 | + config = sph.get_config() |
1526 | + for option in default_keywords: |
1527 | + assert option in config |
1528 | + |
1529 | + def test_set_charm_config(self, sph): |
1530 | + """Set new config.""" |
1531 | + charm_config = { |
1532 | + "groups_enforced": "root", |
1533 | + "groups_exempted": "", |
1534 | + "bypass_cmds": "", |
1535 | + "bypass_group": "", |
1536 | + "auto_approve": True, |
1537 | + } |
1538 | + |
1539 | + sph.set_charm_config(charm_config) |
1540 | + |
1541 | + for option in charm_config: |
1542 | + assert option in sph.get_config() |
1543 | + assert sph.get_config()[option] == charm_config[option] |
1544 | + |
1545 | + def test_render_sudo_conf(self, sph, tmpdir): |
1546 | + """Check that sudo.conf is rendered correctly.""" |
1547 | + # Default config |
1548 | + content = sph.render_sudo_conf() |
1549 | + expected_content = ( |
1550 | + "Plugin sudo_pair sudo_pair.so binary_path={} " |
1551 | + "user_prompt_path={} " |
1552 | + "pair_prompt_path={} socket_dir={} gids_enforced={}".format( |
1553 | + tmpdir.join("/usr/bin/sudo_approve"), |
1554 | + tmpdir.join("/etc/sudo_pair.prompt.user"), |
1555 | + tmpdir.join("/etc/sudo_pair.prompt.pair"), |
1556 | + tmpdir.join("/var/run/sudo_pair"), |
1557 | + "0", |
1558 | + ) |
1559 | + ) |
1560 | + assert expected_content in content |
1561 | + |
1562 | + # Gid exempted |
1563 | + groups_exempted = grp.getgrgid(os.getgid()).gr_name |
1564 | + charm_config = { |
1565 | + "groups_enforced": "root", |
1566 | + "groups_exempted": groups_exempted, |
1567 | + "bypass_cmds": "", |
1568 | + "bypass_group": "", |
1569 | + "auto_approve": True, |
1570 | + } |
1571 | + |
1572 | + sph.set_charm_config(charm_config) |
1573 | + expected_content = ( |
1574 | + "Plugin sudo_pair sudo_pair.so binary_path={} user_prompt_path={} " |
1575 | + "pair_prompt_path={} socket_dir={} gids_enforced={} " |
1576 | + "gids_exempted={}".format( |
1577 | + tmpdir.join("/usr/bin/sudo_approve"), |
1578 | + tmpdir.join("/etc/sudo_pair.prompt.user"), |
1579 | + tmpdir.join("/etc/sudo_pair.prompt.pair"), |
1580 | + tmpdir.join("/var/run/sudo_pair"), |
1581 | + "0", |
1582 | + os.getgid(), |
1583 | + ) |
1584 | + ) |
1585 | + |
1586 | + content = sph.render_sudo_conf() |
1587 | + assert expected_content in content |
1588 | + |
1589 | + # Groups enforced |
1590 | + groups_enforced = "root," + grp.getgrgid(os.getgid()).gr_name |
1591 | + charm_config = { |
1592 | + "groups_enforced": groups_enforced, |
1593 | + "groups_exempted": "", |
1594 | + "bypass_cmds": "", |
1595 | + "bypass_group": "", |
1596 | + "auto_approve": True, |
1597 | + } |
1598 | + sph.set_charm_config(charm_config) |
1599 | + expected_content = ( |
1600 | + "Plugin sudo_pair sudo_pair.so binary_path={} user_prompt_path={} " |
1601 | + "pair_prompt_path={} socket_dir={} gids_enforced={}".format( |
1602 | + tmpdir.join("/usr/bin/sudo_approve"), |
1603 | + tmpdir.join("/etc/sudo_pair.prompt.user"), |
1604 | + tmpdir.join("/etc/sudo_pair.prompt.pair"), |
1605 | + tmpdir.join("/var/run/sudo_pair"), |
1606 | + "0,{}".format(os.getgid()), |
1607 | + ) |
1608 | + ) |
1609 | + content = sph.render_sudo_conf() |
1610 | + assert expected_content in content |
1611 | + |
1612 | + def test_render_bypass_cmds(self, sph): |
1613 | + """Check that sudoers file is rendered correctly.""" |
1614 | + # Root bypass /bin/ls |
1615 | + expected_content = "%root ALL = (ALL) NOLOG_OUTPUT: /bin/ls" |
1616 | + charm_config = { |
1617 | + "groups_enforced": "root", |
1618 | + "groups_exempted": "", |
1619 | + "bypass_cmds": "/bin/ls", |
1620 | + "bypass_group": "root", |
1621 | + "auto_approve": True, |
1622 | + } |
1623 | + sph.set_charm_config(charm_config) |
1624 | + content = sph.render_bypass_cmds() |
1625 | + assert expected_content in content |
1626 | + |
1627 | + def test_render_sudo_approve(self, sph, tmpdir): |
1628 | + """Check that sudo_approve file is rendered correctly.""" |
1629 | + # Auto Approve true |
1630 | + expected_content = "logger -p auth.warn $log_line" |
1631 | + socket_dir = tmpdir.join("/var/run/sudo_pair") |
1632 | + expected_content_socket_dir = 'declare -r SUDO_SOCKET_PATH="{}"'.format( |
1633 | + socket_dir |
1634 | + ) |
1635 | + content = sph.render_sudo_approve() |
1636 | + assert expected_content in content |
1637 | + assert expected_content_socket_dir in content |
1638 | + |
1639 | + # Auto Approve false |
1640 | + expected_content = 'echo "You can\'t approve your own session."' |
1641 | + charm_config = { |
1642 | + "groups_enforced": "root", |
1643 | + "groups_exempted": "", |
1644 | + "bypass_cmds": "/bin/ls", |
1645 | + "bypass_group": "root", |
1646 | + "auto_approve": False, |
1647 | + } |
1648 | + sph.set_charm_config(charm_config) |
1649 | + content = sph.render_sudo_approve() |
1650 | + assert expected_content in content |
1651 | + |
1652 | + def test_create_socket_dir(self, sph, tmpdir): |
1653 | + """Check that sudo_pair socket dir exists.""" |
1654 | + sph.create_socket_dir() |
1655 | + assert os.path.exists(tmpdir.join("/var/run/sudo_pair")) |
1656 | + |
1657 | + def test_create_tmpfiles_conf(self, sph, tmpdir): |
1658 | + """Check that sudo pair temporary conf is rendered correctly.""" |
1659 | + sph.create_tmpfiles_conf() |
1660 | + expected_content = "d {} 0755 - - -\n".format(sph.socket_dir) |
1661 | + with open(tmpdir.join("/usr/lib/tmpfiles.d/sudo_pair.conf")) as f: |
1662 | + content = f.read() |
1663 | + assert expected_content in content |
1664 | + |
1665 | + def test_install_sudo_pair_so(self, sph, tmpdir): |
1666 | + """Check that sudo system lib exists.""" |
1667 | + sph.install_sudo_pair_so() |
1668 | + assert filecmp.cmp( |
1669 | + "./files/sudo_pair.so", tmpdir.join("/usr/lib/sudo/sudo_pair.so") |
1670 | + ) |
1671 | + |
1672 | + def test_copy_user_prompt(self, sph, tmpdir): |
1673 | + """Check that user prompt exists.""" |
1674 | + sph.copy_user_prompt() |
1675 | + assert filecmp.cmp( |
1676 | + "./files/sudo.prompt.user", tmpdir.join("/etc/sudo_pair.prompt.user") |
1677 | + ) |
1678 | + |
1679 | + def test_copy_pair_prompt(self, sph, tmpdir): |
1680 | + """Check that pair prompt exists.""" |
1681 | + sph.copy_pair_prompt() |
1682 | + assert filecmp.cmp( |
1683 | + "./files/sudo.prompt.pair", tmpdir.join("/etc/sudo_pair.prompt.pair") |
1684 | + ) |
1685 | + |
1686 | + def test_copy_sudoers(self, sph, tmpdir): |
1687 | + """Check that sudoers file exists.""" |
1688 | + sph.copy_sudoers() |
1689 | + assert filecmp.cmp("./files/sudoers", tmpdir.join("/etc/sudoers")) |
1690 | diff --git a/tox.ini b/src/tox.ini |
1691 | similarity index 66% |
1692 | rename from tox.ini |
1693 | rename to src/tox.ini |
1694 | index fc364de..2900ead 100644 |
1695 | --- a/tox.ini |
1696 | +++ b/src/tox.ini |
1697 | @@ -1,40 +1,33 @@ |
1698 | [tox] |
1699 | skipsdist=True |
1700 | -envlist = unit, functional |
1701 | skip_missing_interpreters = True |
1702 | +envlist = lint, unit, func |
1703 | |
1704 | [testenv] |
1705 | basepython = python3 |
1706 | setenv = |
1707 | - PYTHONPATH = . |
1708 | - |
1709 | -[testenv:unit] |
1710 | -commands = pytest -v --ignore {toxinidir}/tests/functional \ |
1711 | - --cov=lib \ |
1712 | - --cov=reactive \ |
1713 | - --cov=actions \ |
1714 | - --cov-report=term \ |
1715 | - --cov-report=annotate:report/annotated \ |
1716 | - --cov-report=html:report/html |
1717 | -deps = -r{toxinidir}/tests/unit/requirements.txt |
1718 | - |
1719 | -setenv = PYTHONPATH={toxinidir}/lib |
1720 | - |
1721 | -[testenv:functional] |
1722 | + PYTHONPATH = {toxinidir}:{toxinidir}/lib/:{toxinidir}/hooks/ |
1723 | passenv = |
1724 | HOME |
1725 | - CHARM_BUILD_DIR |
1726 | PATH |
1727 | + CHARM_BUILD_DIR |
1728 | PYTEST_KEEP_MODEL |
1729 | PYTEST_CLOUD_NAME |
1730 | PYTEST_CLOUD_REGION |
1731 | -commands = pytest -v --ignore {toxinidir}/tests/unit |
1732 | -deps = -r{toxinidir}/tests/functional/requirements.txt |
1733 | - |
1734 | + PYTEST_MODEL |
1735 | + MODEL_SETTINGS |
1736 | + HTTP_PROXY |
1737 | + HTTPS_PROXY |
1738 | + NO_PROXY |
1739 | + SNAP_HTTP_PROXY |
1740 | + SNAP_HTTPS_PROXY |
1741 | |
1742 | [testenv:lint] |
1743 | -commands = flake8 |
1744 | +commands = |
1745 | + flake8 |
1746 | + black --check --exclude "/(\.eggs|\.git|\.tox|\.venv|\.build|dist|charmhelpers|mod)/" . |
1747 | deps = |
1748 | + black |
1749 | flake8 |
1750 | flake8-docstrings |
1751 | flake8-import-order |
1752 | @@ -42,10 +35,36 @@ deps = |
1753 | flake8-colors |
1754 | |
1755 | [flake8] |
1756 | -ignore = D100,D103 # Missing docstring in public module/function |
1757 | exclude = |
1758 | .git, |
1759 | __pycache__, |
1760 | .tox, |
1761 | -max-line-length = 120 |
1762 | + charmhelpers, |
1763 | + mod, |
1764 | + .build |
1765 | + |
1766 | +max-line-length = 88 |
1767 | max-complexity = 10 |
1768 | + |
1769 | +[testenv:black] |
1770 | +commands = |
1771 | + black --exclude "/(\.eggs|\.git|\.tox|\.venv|\.build|dist|charmhelpers|mod)/" . |
1772 | +deps = |
1773 | + black |
1774 | + |
1775 | +[testenv:unit] |
1776 | +commands = |
1777 | + pytest -v --ignore {toxinidir}/tests/functional \ |
1778 | + --cov=lib \ |
1779 | + --cov=reactive \ |
1780 | + --cov=actions \ |
1781 | + --cov=hooks \ |
1782 | + --cov=src \ |
1783 | + --cov-report=term \ |
1784 | + --cov-report=annotate:report/annotated \ |
1785 | + --cov-report=html:report/html |
1786 | +deps = -r{toxinidir}/tests/unit/requirements.txt |
1787 | + |
1788 | +[testenv:func] |
1789 | +commands = pytest -v --ignore {toxinidir}/tests/unit |
1790 | +deps = -r{toxinidir}/tests/functional/requirements.txt |
1791 | diff --git a/tests/unit/test_libsudopair.py b/tests/unit/test_libsudopair.py |
1792 | deleted file mode 100644 |
1793 | index 3a598af..0000000 |
1794 | --- a/tests/unit/test_libsudopair.py |
1795 | +++ /dev/null |
1796 | @@ -1,185 +0,0 @@ |
1797 | -import filecmp |
1798 | -import grp |
1799 | -import os |
1800 | - |
1801 | -from libsudopair import ( |
1802 | - check_valid_group, |
1803 | - group_id |
1804 | -) |
1805 | - |
1806 | - |
1807 | -def test_check_valid_group(): |
1808 | - assert not check_valid_group('fake_group') |
1809 | - assert check_valid_group(grp.getgrgid(os.getgid()).gr_name) |
1810 | - |
1811 | - |
1812 | -def test_group_id(): |
1813 | - assert group_id(grp.getgrgid(os.getgid()).gr_name) == os.getgid() |
1814 | - |
1815 | - |
1816 | -class TestSudoPairHelper(): |
1817 | - """Module to test SudoPairHelper lib.""" |
1818 | - |
1819 | - def test_pytest(self): |
1820 | - """Assert testing is carryied using pytest.""" |
1821 | - assert True |
1822 | - |
1823 | - def test_sph(self, sph): |
1824 | - """See if the sph fixture works to load charm configs.""" |
1825 | - assert isinstance(sph.charm_config, dict) |
1826 | - |
1827 | - def test_get_config(self, sph): |
1828 | - """Check if config contains all the required entries.""" |
1829 | - default_keywords = [ |
1830 | - 'binary_path', |
1831 | - 'user_prompt_path', |
1832 | - 'pair_prompt_path', |
1833 | - 'socket_dir', |
1834 | - 'gids_enforced', |
1835 | - 'gids_exempted', |
1836 | - ] |
1837 | - config = sph.get_config() |
1838 | - for option in default_keywords: |
1839 | - assert option in config |
1840 | - |
1841 | - def test_set_charm_config(self, sph): |
1842 | - """Set new config.""" |
1843 | - charm_config = { |
1844 | - 'groups_enforced': 'root', |
1845 | - 'groups_exempted': '', |
1846 | - 'bypass_cmds': '', |
1847 | - 'bypass_group': '', |
1848 | - 'auto_approve': True |
1849 | - } |
1850 | - |
1851 | - sph.set_charm_config(charm_config) |
1852 | - |
1853 | - for option in charm_config: |
1854 | - assert option in sph.get_config() |
1855 | - assert sph.get_config()[option] == charm_config[option] |
1856 | - |
1857 | - def test_render_sudo_conf(self, sph, tmpdir): |
1858 | - """Check that sudo.conf is rendered correctly.""" |
1859 | - # Default config |
1860 | - content = sph.render_sudo_conf() |
1861 | - expected_content = 'Plugin sudo_pair sudo_pair.so binary_path={} ' \ |
1862 | - 'user_prompt_path={} ' \ |
1863 | - 'pair_prompt_path={} socket_dir={} gids_enforced={}'.format( |
1864 | - tmpdir.join('/usr/bin/sudo_approve'), |
1865 | - tmpdir.join('/etc/sudo_pair.prompt.user'), |
1866 | - tmpdir.join('/etc/sudo_pair.prompt.pair'), |
1867 | - tmpdir.join('/var/run/sudo_pair'), |
1868 | - '0') |
1869 | - assert expected_content in content |
1870 | - |
1871 | - # Gid exempted |
1872 | - groups_exempted = grp.getgrgid(os.getgid()).gr_name |
1873 | - charm_config = { |
1874 | - 'groups_enforced': 'root', |
1875 | - 'groups_exempted': groups_exempted, |
1876 | - 'bypass_cmds': '', |
1877 | - 'bypass_group': '', |
1878 | - 'auto_approve': True |
1879 | - } |
1880 | - |
1881 | - sph.set_charm_config(charm_config) |
1882 | - expected_content = \ |
1883 | - 'Plugin sudo_pair sudo_pair.so binary_path={} user_prompt_path={} ' \ |
1884 | - 'pair_prompt_path={} socket_dir={} gids_enforced={} gids_exempted={}'.format( |
1885 | - tmpdir.join('/usr/bin/sudo_approve'), |
1886 | - tmpdir.join('/etc/sudo_pair.prompt.user'), |
1887 | - tmpdir.join('/etc/sudo_pair.prompt.pair'), |
1888 | - tmpdir.join('/var/run/sudo_pair'), '0', os.getgid()) |
1889 | - |
1890 | - content = sph.render_sudo_conf() |
1891 | - assert expected_content in content |
1892 | - |
1893 | - # Groups enforced |
1894 | - groups_enforced = 'root,' + grp.getgrgid(os.getgid()).gr_name |
1895 | - charm_config = { |
1896 | - 'groups_enforced': groups_enforced, |
1897 | - 'groups_exempted': '', |
1898 | - 'bypass_cmds': '', |
1899 | - 'bypass_group': '', |
1900 | - 'auto_approve': True |
1901 | - } |
1902 | - sph.set_charm_config(charm_config) |
1903 | - expected_content = 'Plugin sudo_pair sudo_pair.so binary_path={} user_prompt_path={} ' \ |
1904 | - 'pair_prompt_path={} socket_dir={} gids_enforced={}'.format( |
1905 | - tmpdir.join('/usr/bin/sudo_approve'), |
1906 | - tmpdir.join('/etc/sudo_pair.prompt.user'), |
1907 | - tmpdir.join('/etc/sudo_pair.prompt.pair'), |
1908 | - tmpdir.join('/var/run/sudo_pair'), '0,{}'.format(os.getgid())) |
1909 | - content = sph.render_sudo_conf() |
1910 | - assert expected_content in content |
1911 | - |
1912 | - def test_render_bypass_cmds(self, sph): |
1913 | - """Check that sudoers file is rendered correctly.""" |
1914 | - # Root bypass /bin/ls |
1915 | - expected_content = '%root ALL = (ALL) NOLOG_OUTPUT: /bin/ls' |
1916 | - charm_config = { |
1917 | - 'groups_enforced': 'root', |
1918 | - 'groups_exempted': '', |
1919 | - 'bypass_cmds': '/bin/ls', |
1920 | - 'bypass_group': 'root', |
1921 | - 'auto_approve': True |
1922 | - } |
1923 | - sph.set_charm_config(charm_config) |
1924 | - content = sph.render_bypass_cmds() |
1925 | - assert expected_content in content |
1926 | - |
1927 | - def test_render_sudo_approve(self, sph, tmpdir): |
1928 | - """Check that sudo_approve file is rendered correctly.""" |
1929 | - # Auto Approve true |
1930 | - expected_content = 'echo ${log_line} >> /var/log/sudo_pair.log' |
1931 | - socket_dir = tmpdir.join('/var/run/sudo_pair') |
1932 | - expected_content_socket_dir = 'declare -r SUDO_SOCKET_PATH="{}"'.format(socket_dir) |
1933 | - content = sph.render_sudo_approve() |
1934 | - assert expected_content in content |
1935 | - assert expected_content_socket_dir in content |
1936 | - |
1937 | - # Auto Approve false |
1938 | - expected_content = 'echo "You can\'t approve your own session."' |
1939 | - charm_config = { |
1940 | - 'groups_enforced': 'root', |
1941 | - 'groups_exempted': '', |
1942 | - 'bypass_cmds': '/bin/ls', |
1943 | - 'bypass_group': 'root', |
1944 | - 'auto_approve': False |
1945 | - } |
1946 | - sph.set_charm_config(charm_config) |
1947 | - content = sph.render_sudo_approve() |
1948 | - assert expected_content in content |
1949 | - |
1950 | - def test_create_socket_dir(self, sph, tmpdir): |
1951 | - """Check that sudo_pair socket dir exists.""" |
1952 | - sph.create_socket_dir() |
1953 | - assert os.path.exists(tmpdir.join('/var/run/sudo_pair')) |
1954 | - |
1955 | - def test_create_tmpfiles_conf(self, sph, tmpdir): |
1956 | - """Check that sudo pair temporary conf is rendered correctly.""" |
1957 | - sph.create_tmpfiles_conf() |
1958 | - expected_content = 'd {} 0755 - - -\n'.format(sph.socket_dir) |
1959 | - with open(tmpdir.join('/usr/lib/tmpfiles.d/sudo_pair.conf')) as f: |
1960 | - content = f.read() |
1961 | - assert expected_content in content |
1962 | - |
1963 | - def test_install_sudo_pair_so(self, sph, tmpdir): |
1964 | - """Check that sudo system lib exists.""" |
1965 | - sph.install_sudo_pair_so() |
1966 | - assert filecmp.cmp('./files/sudo_pair.so', tmpdir.join('/usr/lib/sudo/sudo_pair.so')) |
1967 | - |
1968 | - def test_copy_user_prompt(self, sph, tmpdir): |
1969 | - """Check that user prompt exists.""" |
1970 | - sph.copy_user_prompt() |
1971 | - assert filecmp.cmp('./files/sudo.prompt.user', tmpdir.join('/etc/sudo_pair.prompt.user')) |
1972 | - |
1973 | - def test_copy_pair_prompt(self, sph, tmpdir): |
1974 | - """Check that pair prompt exists.""" |
1975 | - sph.copy_pair_prompt() |
1976 | - assert filecmp.cmp('./files/sudo.prompt.pair', tmpdir.join('/etc/sudo_pair.prompt.pair')) |
1977 | - |
1978 | - def test_copy_sudoers(self, sph, tmpdir): |
1979 | - """Check that sudoers file exists.""" |
1980 | - sph.copy_sudoers() |
1981 | - assert filecmp.cmp('./files/sudoers', tmpdir.join('/etc/sudoers')) |
Small comment on the pagerduty_proxy that may be removed in favor of the juju model-config if any.
Comments in line.
Other than that looks good to me.