Merge ~peter-sabaini/charm-sudo-pair:feature/add-pd-logging into ~sudo-pair-charmers/charm-sudo-pair:master

Proposed by Peter Sabaini
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)
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.

To post a comment you must log in.
Revision history for this message
Giuseppe Petralia (peppepetra) wrote : Posted in a previous version of this proposal

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.

review: Needs Fixing
Revision history for this message
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_HTTPS_PROXY variable to avoid proliferating proxy settings. Other than that I'm +1.

review: Needs Fixing
Revision history for this message
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.

Revision history for this message
Celia Wang (ziyiwang) wrote : Posted in a previous version of this proposal

LGTM.

Revision history for this message
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://code.launchpad.net/~peter-sabaini/charm-sudo-pair/+git/sudo-pair-charm/+merge/377884, except that it might be based off newer code. I don't see any changes re: proxy variable use; this code is still using pagerduty_proxy rather than e.g. JUJU_CHARM_HTTPS_PROXY.

review: Needs Fixing

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/~afreiberger/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

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.gitignore b/.gitignore
2index 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
65diff --git a/Makefile b/Makefile
66index 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
183diff --git a/actions/remove-sudopair b/actions/remove-sudopair
184deleted file mode 120000
185index ff9536b..0000000
186--- a/actions/remove-sudopair
187+++ /dev/null
188@@ -1 +0,0 @@
189-./actions.py
190\ No newline at end of file
191diff --git a/interfaces/.empty b/interfaces/.empty
192new file mode 100644
193index 0000000..e69de29
194--- /dev/null
195+++ b/interfaces/.empty
196diff --git a/layers/.empty b/layers/.empty
197new file mode 100644
198index 0000000..e69de29
199--- /dev/null
200+++ b/layers/.empty
201diff --git a/lib/libsudopair.py b/lib/libsudopair.py
202deleted file mode 100644
203index 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))
358diff --git a/README.md b/src/README.md
359similarity index 98%
360rename from README.md
361rename to src/README.md
362index 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
374diff --git a/actions.yaml b/src/actions.yaml
375similarity index 100%
376rename from actions.yaml
377rename to src/actions.yaml
378diff --git a/actions/actions.py b/src/actions/actions.py
379similarity index 78%
380rename from actions/actions.py
381rename to src/actions/actions.py
382index 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]()
439diff --git a/src/actions/remove-sudopair b/src/actions/remove-sudopair
440new file mode 120000
441index 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
447diff --git a/config.yaml b/src/config.yaml
448similarity index 86%
449rename from config.yaml
450rename to src/config.yaml
451index 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"
466diff --git a/src/copyright b/src/copyright
467new file mode 100644
468index 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.
488diff --git a/src/files/pagerdutyevent.py b/src/files/pagerdutyevent.py
489new file mode 100644
490index 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()
562diff --git a/files/sudo.prompt.pair b/src/files/sudo.prompt.pair
563similarity index 100%
564rename from files/sudo.prompt.pair
565rename to src/files/sudo.prompt.pair
566diff --git a/files/sudo.prompt.user b/src/files/sudo.prompt.user
567similarity index 100%
568rename from files/sudo.prompt.user
569rename to src/files/sudo.prompt.user
570diff --git a/files/sudo_pair.so b/src/files/sudo_pair.so
571similarity index 100%
572rename from files/sudo_pair.so
573rename to src/files/sudo_pair.so
574Binary files a/files/sudo_pair.so and b/src/files/sudo_pair.so differ
575diff --git a/files/sudoers b/src/files/sudoers
576similarity index 100%
577rename from files/sudoers
578rename to src/files/sudoers
579diff --git a/layer.yaml b/src/layer.yaml
580similarity index 100%
581rename from layer.yaml
582rename to src/layer.yaml
583diff --git a/src/lib/libsudopair.py b/src/lib/libsudopair.py
584new file mode 100644
585index 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+ )
826diff --git a/metadata.yaml b/src/metadata.yaml
827similarity index 94%
828rename from metadata.yaml
829rename to src/metadata.yaml
830index 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
842diff --git a/reactive/sudo_pair.py b/src/reactive/sudo_pair.py
843similarity index 55%
844rename from reactive/sudo_pair.py
845rename to src/reactive/sudo_pair.py
846index 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")
914diff --git a/templates/91-bypass-sudopair-cmds.tmpl b/src/templates/91-bypass-sudopair-cmds.tmpl
915similarity index 100%
916rename from templates/91-bypass-sudopair-cmds.tmpl
917rename to src/templates/91-bypass-sudopair-cmds.tmpl
918diff --git a/templates/sudo.conf.tmpl b/src/templates/sudo.conf.tmpl
919similarity index 100%
920rename from templates/sudo.conf.tmpl
921rename to src/templates/sudo.conf.tmpl
922diff --git a/templates/sudo_approve.tmpl b/src/templates/sudo_approve.tmpl
923similarity index 96%
924rename from templates/sudo_approve.tmpl
925rename to src/templates/sudo_approve.tmpl
926index 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
950diff --git a/src/templates/sudo_pair.pagerduty.tmpl b/src/templates/sudo_pair.pagerduty.tmpl
951new file mode 100644
952index 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
963diff --git a/tests/00-unit b/src/tests/00-unit
964similarity index 100%
965rename from tests/00-unit
966rename to src/tests/00-unit
967diff --git a/tests/01-functional b/src/tests/01-functional
968similarity index 100%
969rename from tests/01-functional
970rename to src/tests/01-functional
971diff --git a/tests/functional/conftest.py b/src/tests/functional/conftest.py
972similarity index 75%
973rename from tests/functional/conftest.py
974rename to src/tests/functional/conftest.py
975index 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
1181diff --git a/tests/functional/requirements.txt b/src/tests/functional/requirements.txt
1182similarity index 100%
1183rename from tests/functional/requirements.txt
1184rename to src/tests/functional/requirements.txt
1185diff --git a/tests/functional/test_deploy.py b/src/tests/functional/test_deploy.py
1186similarity index 51%
1187rename from tests/functional/test_deploy.py
1188rename to src/tests/functional/test_deploy.py
1189index 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)
1383diff --git a/tests/tests.yaml b/src/tests/tests.yaml
1384similarity index 100%
1385rename from tests/tests.yaml
1386rename to src/tests/tests.yaml
1387diff --git a/tests/unit/conftest.py b/src/tests/unit/conftest.py
1388similarity index 82%
1389rename from tests/unit/conftest.py
1390rename to src/tests/unit/conftest.py
1391index 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
1445diff --git a/tests/unit/requirements.txt b/src/tests/unit/requirements.txt
1446similarity index 100%
1447rename from tests/unit/requirements.txt
1448rename to src/tests/unit/requirements.txt
1449diff --git a/src/tests/unit/test_actions.py b/src/tests/unit/test_actions.py
1450new file mode 100644
1451index 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])
1478diff --git a/src/tests/unit/test_libsudopair.py b/src/tests/unit/test_libsudopair.py
1479new file mode 100644
1480index 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"))
1690diff --git a/tox.ini b/src/tox.ini
1691similarity index 66%
1692rename from tox.ini
1693rename to src/tox.ini
1694index 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
1791diff --git a/tests/unit/test_libsudopair.py b/tests/unit/test_libsudopair.py
1792deleted file mode 100644
1793index 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'))

Subscribers

People subscribed via source and target branches