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
diff --git a/.gitignore b/.gitignore
index eda8bea..6f1f367 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,33 +1,41 @@
1# Juju files
2.unit-state.db
3.go-cookies
4
5layers/*
6interfaces/*
7
1# Byte-compiled / optimized / DLL files8# Byte-compiled / optimized / DLL files
2__pycache__/9__pycache__/
3*.py[cod]10*.py[cod]
4*$py.class11*$py.class
512
13# Tests files and dir
14.pytest_cache/
15.coverage
16.tox
17report/
18htmlcov/
19
6# Log files20# Log files
7*.log21*.log
822
9.tox/23# pycharm
10src/.tox/24.idea/
11.coverage
1225
13# vi26# vi
14.*.swp27.*.swp
1528
16# pycharm
17.idea/
18.unit-state.db
19src/.unit-state.db
20
21# version data29# version data
22repo-info30repo-info
31version
2332
33# Python builds
34deb_dist/
35dist/
2436
25# reports37# Snaps
26report/*38*.snap
27src/report/*
28
29# virtual env
30venv/*
3139
32# builds
33builds/*
34\ No newline at end of file40\ No newline at end of file
41# Builds
42.build/
35\ No newline at end of file43\ No newline at end of file
diff --git a/Makefile b/Makefile
index c7506b2..0a84b5f 100644
--- a/Makefile
+++ b/Makefile
@@ -1,53 +1,80 @@
1PROJECTPATH = $(dir $(realpath $(MAKEFILE_LIST)))1PYTHON := /usr/bin/python3
2DIRNAME = $(notdir $(PROJECTPATH:%/=%))
32
3PROJECTPATH=$(dir $(realpath $(MAKEFILE_LIST)))
4ifndef CHARM_BUILD_DIR4ifndef CHARM_BUILD_DIR
5 CHARM_BUILD_DIR := /tmp/$(DIRNAME)-builds5 CHARM_BUILD_DIR=${PROJECTPATH}.build
6 $(warning Warning CHARM_BUILD_DIR was not set, defaulting to $(CHARM_BUILD_DIR))
7endif6endif
7ifndef CHARM_LAYERS_DIR
8 CHARM_LAYERS_DIR=${PROJECTPATH}/layers
9endif
10ifndef CHARM_INTERFACES_DIR
11 CHARM_INTERFACES_DIR=${PROJECTPATH}/interfaces
12endif
13METADATA_FILE="src/metadata.yaml"
14CHARM_NAME=$(shell cat ${PROJECTPATH}/${METADATA_FILE} | grep -E "^name:" | awk '{print $$2}')
815
9help:16help:
10 @echo "This project supports the following targets"17 @echo "This project supports the following targets"
11 @echo ""18 @echo ""
12 @echo " make help - show this text"19 @echo " make help - show this text"
13 @echo " make lint - run flake8"
14 @echo " make test - run the functional tests, unittests and lint"
15 @echo " make unittest - run the tests defined in the unittest subdirectory"
16 @echo " make functional - run the tests defined in the functional subdirectory"
17 @echo " make release - build the charm"
18 @echo " make clean - remove unneeded files"20 @echo " make clean - remove unneeded files"
21 @echo " make submodules - make sure that the submodules are up-to-date"
22 @echo " make submodules-update - update submodules to latest changes on remote branch"
23 @echo " make build - build the charm"
24 @echo " make release - run clean, submodules, and build targets"
25 @echo " make lint - run flake8 and black --check"
26 @echo " make black - run black and reformat files"
27 @echo " make proof - run charm proof"
28 @echo " make unittests - run the tests defined in the unittest subdirectory"
29 @echo " make functional - run the tests defined in the functional subdirectory"
30 @echo " make test - run lint, proof, unittests and functional targets"
19 @echo ""31 @echo ""
2032
21lint:33clean:
22 @echo "Running flake8"34 @echo "Cleaning files"
23 @tox -e lint35 @git clean -ffXd -e '!.idea'
2436 @echo "Cleaning existing build"
25test: lint unittest functional37 @rm -rf ${CHARM_BUILD_DIR}/${CHARM_NAME}
2638
27functional: build39submodules:
28 @PYTEST_KEEP_MODEL=$(PYTEST_KEEP_MODEL) \40 # @git submodule update --init --recursive
29 PYTEST_CLOUD_NAME=$(PYTEST_CLOUD_NAME) \41 @echo "No submodules. Skipping."
30 PYTEST_CLOUD_REGION=$(PYTEST_CLOUD_REGION) \
31 tox -e functional
3242
33unittest:43submodules-update:
34 @tox -e unit44 # @git submodule update --init --recursive --remote --merge
45 @echo "No submodules. Skipping."
3546
36build:47build:
37 @echo "Building charm to base directory $(CHARM_BUILD_DIR)"48 @echo "Building charm to directory ${CHARM_BUILD_DIR}/${CHARM_NAME}"
38 @-git describe --tags > ./repo-info49 @-git rev-parse --abbrev-ref HEAD > ./src/repo-info
39 @CHARM_LAYERS_DIR=./layers CHARM_INTERFACES_DIR=./interfaces TERM=linux\50 @CHARM_LAYERS_DIR=${CHARM_LAYERS_DIR} CHARM_INTERFACES_DIR=${CHARM_INTERFACES_DIR} \
40 charm build --output-dir $(CHARM_BUILD_DIR) $(PROJECTPATH) --force51 TERM=linux CHARM_BUILD_DIR=${CHARM_BUILD_DIR} charm build src/
4152
42release: clean build53release: clean build
43 @echo "Charm is built at $(CHARM_BUILD_DIR)/builds"54 @echo "Charm is built at ${CHARM_BUILD_DIR}/${CHARM_NAME}"
4455
45clean:56lint:
46 @echo "Cleaning files"57 @echo "Running lint checks"
47 @find $(PROJECTPATH) -iname __pycache__ -exec rm -r {} +58 @cd src && tox -e lint
48 @if [ -d $(CHARM_BUILD_DIR)/builds ] ; then rm -r $(CHARM_BUILD_DIR)/builds ; fi59
49 @if [ -d $(PROJECTPATH)/.tox ] ; then rm -r $(PROJECTPATH)/.tox ; fi60black:
50 @if [ -d $(PROJECTPATH)/.pytest_cache ] ; then rm -r $(PROJECTPATH)/.pytest_cache ; fi61 @echo "Reformat files with black"
62 @cd src && tox -e black
63
64proof: build
65 @echo "Running charm proof"
66 @charm proof ${CHARM_BUILD_DIR}/${CHARM_NAME}
67
68unittests:
69 @echo "Running unit tests"
70 @cd src && tox -e unit
71
72functional: build
73 @echo "Executing functional tests in ${CHARM_BUILD_DIR}"
74 @cd src && CHARM_BUILD_DIR=${CHARM_BUILD_DIR} tox -e func
75
76test: lint proof unittests functional
77 @echo "Tests completed for charm ${CHARM_NAME}."
5178
52# The targets below don't depend on a file79# The targets below don't depend on a file
53.PHONY: lint test unittest functional build release clean help80.PHONY: help submodules submodules-update clean build release lint black proof unittests functional test
diff --git a/actions/remove-sudopair b/actions/remove-sudopair
54deleted file mode 12000081deleted file mode 120000
index ff9536b..0000000
--- a/actions/remove-sudopair
+++ /dev/null
@@ -1 +0,0 @@
1./actions.py
2\ No newline at end of file0\ No newline at end of file
diff --git a/interfaces/.empty b/interfaces/.empty
3new file mode 1006441new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/interfaces/.empty
diff --git a/layers/.empty b/layers/.empty
4new file mode 1006442new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/layers/.empty
diff --git a/lib/libsudopair.py b/lib/libsudopair.py
5deleted file mode 1006443deleted file mode 100644
index 05d1f9d..0000000
--- a/lib/libsudopair.py
+++ /dev/null
@@ -1,151 +0,0 @@
1import grp
2import os
3
4from charmhelpers.core import hookenv, host, templating
5
6
7def check_valid_group(group_name):
8 """Check that a group exists."""
9 try:
10 grp.getgrnam(group_name)
11 return True
12 except KeyError:
13 return False
14
15
16def group_id(group_name):
17 """Check that a group exists."""
18 return grp.getgrnam(group_name).gr_gid
19
20
21def group_names_to_group_ids(group_names):
22 """
23 Return comma-separated list of Group Ids.
24
25 :param group_names: i.e. "root,user1,user2"
26 :return gids: i.e. "0,1001,1002"
27 """
28 group_names = list(filter(check_valid_group, group_names.split(',')))
29 return ','.join(map(str, (map(group_id, group_names))))
30
31
32def copy_file(source, destination, owner, group, perms):
33 """Copy a file on the unit."""
34 if destination is not None:
35 target_dir = os.path.dirname(destination)
36 if not os.path.exists(target_dir):
37 # This is a terrible default directory permission, as the file
38 # or its siblings will often contain secrets.
39 host.mkdir(os.path.dirname(destination), owner, group, perms=0o755)
40 with open(source, 'rb') as source_f:
41 host.write_file(destination, source_f.read(), perms=perms, owner=owner, group=group)
42
43
44class SudoPairHelper(object):
45 """Configure sudo-pair."""
46
47 def __init__(self):
48 """Retrieve charm config and set defaults."""
49 self.charm_config = hookenv.config()
50 self.binary_path = '/usr/bin/sudo_approve'
51 self.sudo_conf_path = '/etc/sudo.conf'
52 self.sudoers_path = '/etc/sudoers'
53 self.sudo_lib_path = '/usr/lib/sudo/sudo_pair.so'
54 self.sudoers_bypass_path = "/etc/sudoers.d/91-bypass-sudopair-cmds"
55 self.user_prompt_path = '/etc/sudo_pair.prompt.user'
56 self.pair_prompt_path = '/etc/sudo_pair.prompt.pair'
57 self.socket_dir = '/var/run/sudo_pair'
58 self.tmpfiles_conf = '/usr/lib/tmpfiles.d/sudo_pair.conf'
59 self.owner = 'root'
60 self.group = 'root'
61 self.socket_dir_perms = 0o644
62 self.sudo_pair_so_perms = 0o644
63 self.prompt_perms = 0o644
64 self.sudoers_perms = 0o440
65 self.sudo_conf_perms = 0o644
66 self.sudo_approve_perms = 0o755
67
68 def get_config(self):
69 """Return config as a dict."""
70 config = {
71 'binary_path': self.binary_path,
72 'user_prompt_path': self.user_prompt_path,
73 'pair_prompt_path': self.pair_prompt_path,
74 'socket_dir': self.socket_dir,
75 'gids_enforced': group_names_to_group_ids(self.charm_config['groups_enforced']),
76 'gids_exempted': group_names_to_group_ids(self.charm_config['groups_exempted']),
77 }
78
79 config.update(self.charm_config)
80 return config
81
82 def set_charm_config(self, charm_config):
83 """Update configuration."""
84 self.charm_config = charm_config
85
86 def render_sudo_conf(self):
87 """Render sudo.conf file."""
88 return templating.render('sudo.conf.tmpl', self.sudo_conf_path, self.get_config(),
89 perms=self.sudo_conf_perms, owner=self.owner, group=self.group)
90
91 def create_socket_dir(self):
92 """Create socket dir."""
93 host.mkdir(self.socket_dir, perms=self.socket_dir_perms, owner=self.owner, group=self.group)
94
95 def create_tmpfiles_conf(self):
96 """Create temporary conf file."""
97 with open(self.tmpfiles_conf, "w") as f:
98 f.write("d {} 0755 - - -\n".format(self.socket_dir))
99
100 def install_sudo_pair_so(self):
101 """Install sudo-pair lib."""
102 sudo_pair_lib = os.path.join(hookenv.charm_dir(), 'files', 'sudo_pair.so')
103 copy_file(sudo_pair_lib, self.sudo_lib_path, self.owner, self.group, self.sudo_pair_so_perms)
104
105 def copy_user_prompt(self):
106 """Copy user prompt on the unit."""
107 prompt_file = os.path.join(hookenv.charm_dir(), 'files', 'sudo.prompt.user')
108 copy_file(prompt_file, self.user_prompt_path, self.owner, self.group, self.prompt_perms)
109
110 def copy_pair_prompt(self):
111 """Copy pair prompt on the unit."""
112 prompt_file = os.path.join(hookenv.charm_dir(), 'files', 'sudo.prompt.pair')
113 copy_file(prompt_file, self.pair_prompt_path, self.owner, self.group, self.prompt_perms)
114
115 def copy_sudoers(self):
116 """Copy sudoers file on the unit."""
117 sudoers_file = os.path.join(hookenv.charm_dir(), 'files', 'sudoers')
118 copy_file(sudoers_file, self.sudoers_path, self.owner, self.group, self.sudoers_perms)
119
120 def render_sudo_approve(self):
121 """Render sudo-approve file."""
122 hookenv.log("Rendering sudo_approve.tmpl to {}".format(self.binary_path))
123 return templating.render('sudo_approve.tmpl', self.binary_path, self.get_config(),
124 perms=self.sudo_approve_perms, owner=self.owner, group=self.group)
125
126 def render_bypass_cmds(self):
127 """Render bypass command file."""
128 if self.get_config()['bypass_cmds'] != "" and self.get_config()['bypass_group'] != "":
129 hookenv.log("Render bypass cmds to {}".format(self.sudoers_bypass_path))
130 return templating.render('91-bypass-sudopair-cmds.tmpl', self.sudoers_bypass_path,
131 self.get_config(), perms=0o440, owner=self.owner, group=self.group)
132 return None
133
134 def deconfigure(self):
135 """Remove sudo-pair configuration."""
136 paths = [
137 self.sudo_conf_path,
138 self.sudo_lib_path,
139 self.binary_path,
140 self.user_prompt_path,
141 self.pair_prompt_path,
142 self.sudoers_bypass_path,
143 self.tmpfiles_conf
144 ]
145 hookenv.log("Deleting: {}".format(paths))
146 for path in paths:
147 try:
148 os.unlink(path)
149 except Exception as e:
150 # We're trying hard to delete all files, even if some might fail
151 hookenv.log("Got exception unlinking {}: {}, continuing".format(path, e))
diff --git a/README.md b/src/README.md
152similarity index 98%0similarity index 98%
153rename from README.md1rename from README.md
154rename to src/README.md2rename to src/README.md
index 0804cca..3c80295 100644
--- a/README.md
+++ b/src/README.md
@@ -45,7 +45,7 @@ tox -e functional
4545
4646
47# Contact Information47# Contact Information
48Giuseppe Petralia <giuseppe.petralia@canonical.com>48BootStack Charmers <bootstack-charmers@lists.canonical.com>
4949
50[service]: https://github.com/square/sudo_pair50[service]: https://github.com/square/sudo_pair
51[icon guidelines]: https://jujucharms.com/docs/stable/authors-charm-icon51[icon guidelines]: https://jujucharms.com/docs/stable/authors-charm-icon
diff --git a/actions.yaml b/src/actions.yaml
52similarity index 100%52similarity index 100%
53rename from actions.yaml53rename from actions.yaml
54rename to src/actions.yaml54rename to src/actions.yaml
diff --git a/actions/actions.py b/src/actions/actions.py
55similarity index 78%55similarity index 78%
56rename from actions/actions.py56rename from actions/actions.py
57rename to src/actions/actions.py57rename to src/actions/actions.py
index ccf3ffc..6ab26a2 100755
--- a/actions/actions.py
+++ b/src/actions/actions.py
@@ -1,4 +1,5 @@
1#!/usr/local/sbin/charm-env python31#!/usr/local/sbin/charm-env python3
2"""Sudo Pair actions."""
2#3#
3# Copyright 2016,2019,2020 Canonical Ltd4# Copyright 2016,2019,2020 Canonical Ltd
4#5#
@@ -13,10 +14,13 @@
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and15# See the License for the specific language governing permissions and
15# limitations under the License.16# limitations under the License.
17import logging
18import logging.handlers
16import os19import os
20import subprocess
17import sys21import sys
1822
19from charmhelpers.core.hookenv import action_fail, action_set23from charmhelpers.core.hookenv import action_fail, action_set, status_set
2024
2125
22sys.path.append("lib")26sys.path.append("lib")
@@ -24,11 +28,23 @@ sys.path.append("lib")
24import libsudopair # NOQA28import libsudopair # NOQA
2529
2630
31logger = logging.getLogger("sudopair")
32logger.setLevel(logging.INFO)
33handler = logging.handlers.SysLogHandler(
34 address="/dev/log", facility=logging.handlers.SysLogHandler.LOG_AUTH
35)
36logger.addHandler(handler)
37
38
27def remove():39def remove():
28 """Remove sudo-pair config and binaries."""40 """Remove sudo-pair config and binaries."""
29 sph = libsudopair.SudoPairHelper()41 sph = libsudopair.SudoPairHelper()
30 sph.deconfigure()42 sph.deconfigure()
31 action_set({"message": "Successfully removed sudo-pair config and binaries"})43 msg = "Successfully removed sudo-pair config and binaries"
44 logger.warning(msg)
45 subprocess.run(["/usr/bin/pagerdutyevent.py", msg])
46 action_set({"message": msg})
47 status_set("active", msg)
3248
3349
34# A dictionary of all the defined actions to callables (which take50# A dictionary of all the defined actions to callables (which take
@@ -37,6 +53,7 @@ ACTIONS = {"remove-sudopair": remove}
3753
3854
39def main(args):55def main(args):
56 """Dispatch actions based on command arguments."""
40 action_name = os.path.basename(args[0])57 action_name = os.path.basename(args[0])
41 try:58 try:
42 ACTIONS[action_name]()59 ACTIONS[action_name]()
diff --git a/src/actions/remove-sudopair b/src/actions/remove-sudopair
43new file mode 12000060new file mode 120000
index 0000000..ff9536b
--- /dev/null
+++ b/src/actions/remove-sudopair
@@ -0,0 +1 @@
1./actions.py
0\ No newline at end of file2\ No newline at end of file
diff --git a/config.yaml b/src/config.yaml
1similarity index 86%3similarity index 86%
2rename from config.yaml4rename from config.yaml
3rename to src/config.yaml5rename to src/config.yaml
index 795269b..6089ac9 100644
--- a/config.yaml
+++ b/src/config.yaml
@@ -19,3 +19,11 @@ options:
19 type: boolean19 type: boolean
20 default: true20 default: true
21 description: "If true, auto approval is permitted."21 description: "If true, auto approval is permitted."
22 pagerduty_key:
23 type: string
24 default: ''
25 description: "If set, a pagerduty event will be triggered upon auto-approving"
26 pagerduty_context:
27 type: string
28 default: ''
29 description: "Prefix to add to pagerduty events"
diff --git a/src/copyright b/src/copyright
22new file mode 10064430new file mode 100644
index 0000000..8fc50d9
--- /dev/null
+++ b/src/copyright
@@ -0,0 +1,16 @@
1Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0
2
3Files: *
4Copyright: 2018-2020, Canonical Ltd.
5License: Apache-2.0
6 Licensed under the Apache License, Version 2.0 (the "License"); you may
7 not use this file except in compliance with the License. You may obtain
8 a copy of the License at
9
10 http://www.apache.org/licenses/LICENSE-2.0
11
12 Unless required by applicable law or agreed to in writing, software
13 distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14 WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
15 License for the specific language governing permissions and limitations
16 under the License.
diff --git a/src/files/pagerdutyevent.py b/src/files/pagerdutyevent.py
0new file mode 10064417new file mode 100644
index 0000000..f33be42
--- /dev/null
+++ b/src/files/pagerdutyevent.py
@@ -0,0 +1,68 @@
1#!/usr/bin/env python3
2"""Send events to pagerduty."""
3
4import argparse
5import configparser
6import json
7import logging
8import logging.handlers
9
10import requests
11
12logger = logging.getLogger("sudopair")
13logger.setLevel(logging.INFO)
14handler = logging.handlers.SysLogHandler(
15 address="/dev/log", facility=logging.handlers.SysLogHandler.LOG_AUTH
16)
17logger.addHandler(handler)
18
19
20def args():
21 """Get cli args."""
22 parser = argparse.ArgumentParser()
23 parser.add_argument("summary")
24 return parser.parse_args()
25
26
27def trigger_incident(pd_key, summary, source, https_proxy=None):
28 """Send an event to the PD events endpoint."""
29 header = {"Content-Type": "application/json"}
30
31 payload = {
32 "routing_key": pd_key,
33 "event_action": "trigger",
34 "payload": {"summary": summary, "source": source, "severity": "info"},
35 }
36 if https_proxy:
37 proxies = {"https": https_proxy}
38 else:
39 proxies = None
40 response = requests.post(
41 "https://events.pagerduty.com/v2/enqueue",
42 data=json.dumps(payload),
43 proxies=proxies,
44 headers=header,
45 )
46
47 if response.json()["status"] == "success":
48 logger.info("Triggered alert with key {}".format(response.json()["dedup_key"]))
49 else:
50 logger.warning("Failed to send pagerduty alert: {}".format(response.text))
51
52
53def get_conf():
54 """Return config options from file."""
55 config = configparser.ConfigParser()
56 config.read("/etc/sudo_pair.pagerduty.ini")
57 sec = config["DEFAULT"]
58 return sec["pagerduty_key"], sec["pagerduty_source"], sec.get("https_proxy")
59
60
61def main():
62 """Run the script."""
63 pd_key, source, https_proxy = get_conf()
64 trigger_incident(pd_key, args().summary, source, https_proxy)
65
66
67if __name__ == "__main__":
68 main()
diff --git a/files/sudo.prompt.pair b/src/files/sudo.prompt.pair
0similarity index 100%69similarity index 100%
1rename from files/sudo.prompt.pair70rename from files/sudo.prompt.pair
2rename to src/files/sudo.prompt.pair71rename to src/files/sudo.prompt.pair
diff --git a/files/sudo.prompt.user b/src/files/sudo.prompt.user
3similarity index 100%72similarity index 100%
4rename from files/sudo.prompt.user73rename from files/sudo.prompt.user
5rename to src/files/sudo.prompt.user74rename to src/files/sudo.prompt.user
diff --git a/files/sudo_pair.so b/src/files/sudo_pair.so
6similarity index 100%75similarity index 100%
7rename from files/sudo_pair.so76rename from files/sudo_pair.so
8rename to src/files/sudo_pair.so77rename to src/files/sudo_pair.so
9Binary files a/files/sudo_pair.so and b/src/files/sudo_pair.so differ78Binary files a/files/sudo_pair.so and b/src/files/sudo_pair.so differ
diff --git a/files/sudoers b/src/files/sudoers
10similarity index 100%79similarity index 100%
11rename from files/sudoers80rename from files/sudoers
12rename to src/files/sudoers81rename to src/files/sudoers
diff --git a/layer.yaml b/src/layer.yaml
13similarity index 100%82similarity index 100%
14rename from layer.yaml83rename from layer.yaml
15rename to src/layer.yaml84rename to src/layer.yaml
diff --git a/src/lib/libsudopair.py b/src/lib/libsudopair.py
16new file mode 10064485new file mode 100644
index 0000000..c66873a
--- /dev/null
+++ b/src/lib/libsudopair.py
@@ -0,0 +1,237 @@
1"""Sudo pair utilities."""
2import grp
3import os
4
5from charmhelpers.core import hookenv, host, templating
6
7
8def check_valid_group(group_name):
9 """Check that a group exists."""
10 try:
11 grp.getgrnam(group_name)
12 return True
13 except KeyError:
14 return False
15
16
17def group_id(group_name):
18 """Check that a group exists."""
19 return grp.getgrnam(group_name).gr_gid
20
21
22def group_names_to_group_ids(group_names):
23 """
24 Return comma-separated list of Group Ids.
25
26 :param group_names: i.e. "root,user1,user2"
27 :return gids: i.e. "0,1001,1002"
28 """
29 group_names = list(filter(check_valid_group, group_names.split(",")))
30 return ",".join(map(str, (map(group_id, group_names))))
31
32
33def copy_file(source, destination, owner, group, perms):
34 """Copy a file on the unit."""
35 if destination is not None:
36 target_dir = os.path.dirname(destination)
37 if not os.path.exists(target_dir):
38 # This is a terrible default directory permission, as the file
39 # or its siblings will often contain secrets.
40 host.mkdir(os.path.dirname(destination), owner, group, perms=0o755)
41 with open(source, "rb") as source_f:
42 host.write_file(
43 destination, source_f.read(), perms=perms, owner=owner, group=group
44 )
45
46
47class SudoPairHelper(object):
48 """Configure sudo-pair."""
49
50 def __init__(self):
51 """Retrieve charm config and set defaults."""
52 self.charm_config = hookenv.config()
53 self.binary_path = "/usr/bin/sudo_approve"
54 self.sudo_conf_path = "/etc/sudo.conf"
55 self.sudoers_path = "/etc/sudoers"
56 self.sudo_lib_path = "/usr/lib/sudo/sudo_pair.so"
57 self.sudoers_bypass_path = "/etc/sudoers.d/91-bypass-sudopair-cmds"
58 self.user_prompt_path = "/etc/sudo_pair.prompt.user"
59 self.pair_prompt_path = "/etc/sudo_pair.prompt.pair"
60 self.socket_dir = "/var/run/sudo_pair"
61 self.tmpfiles_conf = "/usr/lib/tmpfiles.d/sudo_pair.conf"
62 self.owner = "root"
63 self.group = "root"
64 self.socket_dir_perms = 0o644
65 self.sudo_pair_so_perms = 0o644
66 self.prompt_perms = 0o644
67 self.sudoers_perms = 0o440
68 self.sudo_conf_perms = 0o644
69 self.sudo_approve_perms = 0o755
70 self.pagerduty_conf_path = "/etc/sudo_pair.pagerduty.ini"
71 self.pagerduty_path = "/usr/bin/pagerdutyevent.py"
72 self.pagerduty_perms = 0o755
73
74 def get_config(self):
75 """Return config as a dict."""
76 config = {
77 "binary_path": self.binary_path,
78 "user_prompt_path": self.user_prompt_path,
79 "pair_prompt_path": self.pair_prompt_path,
80 "socket_dir": self.socket_dir,
81 "gids_enforced": group_names_to_group_ids(
82 self.charm_config["groups_enforced"]
83 ),
84 "gids_exempted": group_names_to_group_ids(
85 self.charm_config["groups_exempted"]
86 ),
87 "pagerduty_key": self.charm_config.get("pagerduty_key"),
88 }
89 proxy_settings = hookenv.env_proxy_settings()
90 if proxy_settings:
91 config["https_proxy"] = proxy_settings.get("https_proxy")
92 config.update(self.charm_config)
93 return config
94
95 def set_charm_config(self, charm_config):
96 """Update configuration."""
97 self.charm_config = charm_config
98
99 def copy_pagerduty(self):
100 """Copy PD script in place."""
101 pd_file = os.path.join(hookenv.charm_dir(), "files", "pagerdutyevent.py")
102 copy_file(
103 pd_file, self.pagerduty_path, self.owner, self.group, self.pagerduty_perms
104 )
105
106 def render_sudo_conf(self):
107 """Render sudo.conf file."""
108 return templating.render(
109 "sudo.conf.tmpl",
110 self.sudo_conf_path,
111 self.get_config(),
112 perms=self.sudo_conf_perms,
113 owner=self.owner,
114 group=self.group,
115 )
116
117 def create_socket_dir(self):
118 """Create socket dir."""
119 host.mkdir(
120 self.socket_dir,
121 perms=self.socket_dir_perms,
122 owner=self.owner,
123 group=self.group,
124 )
125
126 def create_tmpfiles_conf(self):
127 """Create temporary conf file."""
128 with open(self.tmpfiles_conf, "w") as f:
129 f.write("d {} 0755 - - -\n".format(self.socket_dir))
130
131 def install_sudo_pair_so(self):
132 """Install sudo-pair lib."""
133 sudo_pair_lib = os.path.join(hookenv.charm_dir(), "files", "sudo_pair.so")
134 copy_file(
135 sudo_pair_lib,
136 self.sudo_lib_path,
137 self.owner,
138 self.group,
139 self.sudo_pair_so_perms,
140 )
141
142 def copy_user_prompt(self):
143 """Copy user prompt on the unit."""
144 prompt_file = os.path.join(hookenv.charm_dir(), "files", "sudo.prompt.user")
145 copy_file(
146 prompt_file,
147 self.user_prompt_path,
148 self.owner,
149 self.group,
150 self.prompt_perms,
151 )
152
153 def copy_pair_prompt(self):
154 """Copy pair prompt on the unit."""
155 prompt_file = os.path.join(hookenv.charm_dir(), "files", "sudo.prompt.pair")
156 copy_file(
157 prompt_file,
158 self.pair_prompt_path,
159 self.owner,
160 self.group,
161 self.prompt_perms,
162 )
163
164 def copy_sudoers(self):
165 """Copy sudoers file on the unit."""
166 sudoers_file = os.path.join(hookenv.charm_dir(), "files", "sudoers")
167 copy_file(
168 sudoers_file, self.sudoers_path, self.owner, self.group, self.sudoers_perms
169 )
170
171 def render_sudo_approve(self):
172 """Render sudo-approve file."""
173 hookenv.log("Rendering sudo_approve.tmpl to {}".format(self.binary_path))
174 return templating.render(
175 "sudo_approve.tmpl",
176 self.binary_path,
177 self.get_config(),
178 perms=self.sudo_approve_perms,
179 owner=self.owner,
180 group=self.group,
181 )
182
183 def render_bypass_cmds(self):
184 """Render bypass command file."""
185 if (
186 self.get_config()["bypass_cmds"] != ""
187 and self.get_config()["bypass_group"] != ""
188 ):
189 hookenv.log("Render bypass cmds to {}".format(self.sudoers_bypass_path))
190 return templating.render(
191 "91-bypass-sudopair-cmds.tmpl",
192 self.sudoers_bypass_path,
193 self.get_config(),
194 perms=0o440,
195 owner=self.owner,
196 group=self.group,
197 )
198 return None
199
200 def render_pagerduty_conf(self):
201 """Create the PD script configuration."""
202 pd_source = "{}-{}".format(
203 self.charm_config.get("pagerduty_context", "juju"), hookenv.principal_unit()
204 )
205 cfg = self.get_config()
206 cfg["pagerduty_source"] = pd_source
207 hookenv.log(
208 "Rendering pagerduty configuration to {}".format(self.pagerduty_conf_path)
209 )
210 return templating.render(
211 "sudo_pair.pagerduty.tmpl",
212 self.pagerduty_conf_path,
213 cfg,
214 owner=self.owner,
215 group=self.group,
216 )
217
218 def deconfigure(self):
219 """Remove sudo-pair configuration."""
220 paths = [
221 self.sudo_conf_path,
222 self.sudo_lib_path,
223 self.binary_path,
224 self.user_prompt_path,
225 self.pair_prompt_path,
226 self.sudoers_bypass_path,
227 self.tmpfiles_conf,
228 ]
229 hookenv.log("Deleting: {}".format(paths))
230 for path in paths:
231 try:
232 os.unlink(path)
233 except Exception as e:
234 # We're trying hard to delete all files, even if some might fail
235 hookenv.log(
236 "Got exception unlinking {}: {}, continuing".format(path, e)
237 )
diff --git a/metadata.yaml b/src/metadata.yaml
0similarity index 94%238similarity index 94%
1rename from metadata.yaml239rename from metadata.yaml
2rename to src/metadata.yaml240rename to src/metadata.yaml
index fe2a13c..8b6a9d4 100644
--- a/metadata.yaml
+++ b/src/metadata.yaml
@@ -1,7 +1,7 @@
1name: sudo-pair1name: sudo-pair
2display-name: sudo-pair2display-name: sudo-pair
3summary: sudo_pair is a sudo plugin to manage root privileges3summary: sudo_pair is a sudo plugin to manage root privileges
4maintainer: LMA Charmers <llama-charmers@lists.ubuntu.com>4maintainer: BootStack Charmers <bootstack-charmers@lists.canonical.com>
5description: |5description: |
6 sudo_pair is a sudo plugin that ensure that if a user tries to get root privileges,6 sudo_pair is a sudo plugin that ensure that if a user tries to get root privileges,
7 he will need an authorization from a pair7 he will need an authorization from a pair
diff --git a/reactive/sudo_pair.py b/src/reactive/sudo_pair.py
8similarity index 55%8similarity index 55%
9rename from reactive/sudo_pair.py9rename from reactive/sudo_pair.py
10rename to src/reactive/sudo_pair.py10rename to src/reactive/sudo_pair.py
index 974dd66..392383b 100644
--- a/reactive/sudo_pair.py
+++ b/src/reactive/sudo_pair.py
@@ -1,3 +1,5 @@
1"""Charm reactive hooks."""
2
1from charmhelpers.core import hookenv3from charmhelpers.core import hookenv
24
3from charms.reactive import hook, remove_state, set_state, when, when_not5from charms.reactive import hook, remove_state, set_state, when, when_not
@@ -7,10 +9,14 @@ from libsudopair import SudoPairHelper
7sph = SudoPairHelper()9sph = SudoPairHelper()
810
911
10@when('apt.installed.socat')12@when("apt.installed.socat")
11@when_not('sudo-pair.configured')13@when_not("sudo-pair.configured")
12def install_sudo_pair():14def install_sudo_pair():
13 # Install sudo_pair.so, create socket dir, copy sudo_approve to /usr/bin, copy prompts to /etc15 """Install sudo pair.
16
17 Install sudo_pair.so, create socket dir, copy sudo_approve to /usr/bin
18 and copy prompts to /etc.
19 """
14 sph.install_sudo_pair_so()20 sph.install_sudo_pair_so()
1521
16 sph.create_socket_dir()22 sph.create_socket_dir()
@@ -26,24 +32,33 @@ def install_sudo_pair():
26 # Add "Defaults log_output to /etc/sudoers32 # Add "Defaults log_output to /etc/sudoers
27 sph.copy_sudoers()33 sph.copy_sudoers()
2834
35 # Add pagerduty script and config
36 sph.copy_pagerduty()
37 sph.render_pagerduty_conf()
38
29 # If there are cmds to bypass sudo pairing create file unders sudoers.d39 # If there are cmds to bypass sudo pairing create file unders sudoers.d
30 sph.render_bypass_cmds()40 sph.render_bypass_cmds()
3141
32 # Add Plugin sudo_pair sudo_pair.so to sudo.conf42 # Add Plugin sudo_pair sudo_pair.so to sudo.conf
33 sph.render_sudo_conf()43 sph.render_sudo_conf()
3444
35 set_state('sudo-pair.installed')45 set_state("sudo-pair.installed")
36 set_state('sudo-pair.configured')46 set_state("sudo-pair.configured")
37 hookenv.status_set('active', 'sudo pairing for users groups: [{}]'.format(sph.get_config()['gids_enforced']))47 hookenv.status_set(
48 "active",
49 "sudo pairing for users groups: [{}]".format(sph.get_config()["gids_enforced"]),
50 )
3851
3952
40@hook('config-changed')53@hook("config-changed")
41def reconfigure_sudo_pair_charm():54def reconfigure_sudo_pair_charm():
55 """Run config-changed hook."""
42 sph.set_charm_config(hookenv.config())56 sph.set_charm_config(hookenv.config())
43 remove_state('sudo-pair.configured')57 remove_state("sudo-pair.configured")
4458
4559
46@hook('stop')60@hook("stop")
47def stop():61def stop():
62 """Run stop hook and remove charm configuration."""
48 sph.deconfigure()63 sph.deconfigure()
49 remove_state('sudo-pair.installed')64 remove_state("sudo-pair.installed")
diff --git a/templates/91-bypass-sudopair-cmds.tmpl b/src/templates/91-bypass-sudopair-cmds.tmpl
50similarity index 100%65similarity index 100%
51rename from templates/91-bypass-sudopair-cmds.tmpl66rename from templates/91-bypass-sudopair-cmds.tmpl
52rename to src/templates/91-bypass-sudopair-cmds.tmpl67rename to src/templates/91-bypass-sudopair-cmds.tmpl
diff --git a/templates/sudo.conf.tmpl b/src/templates/sudo.conf.tmpl
53similarity index 100%68similarity index 100%
54rename from templates/sudo.conf.tmpl69rename from templates/sudo.conf.tmpl
55rename to src/templates/sudo.conf.tmpl70rename to src/templates/sudo.conf.tmpl
diff --git a/templates/sudo_approve.tmpl b/src/templates/sudo_approve.tmpl
56similarity index 96%71similarity index 96%
57rename from templates/sudo_approve.tmpl72rename from templates/sudo_approve.tmpl
58rename to src/templates/sudo_approve.tmpl73rename to src/templates/sudo_approve.tmpl
index 7164b5a..49665d3 100755
--- a/templates/sudo_approve.tmpl
+++ b/src/templates/sudo_approve.tmpl
@@ -88,7 +88,7 @@ main() {
88 declare -r username88 declare -r username
8989
90 declare log_line90 declare log_line
91 log_line="$(date "+[%b %d %H:%M:%S] WARNING: ${username} approved is own sudo session.")"91 log_line="WARNING: ${username} approved own sudo session."
92 declare -r log_line92 declare -r log_line
9393
94 if [[ "${uid}" -eq "${ruid}" ]]; then94 if [[ "${uid}" -eq "${ruid}" ]]; then
@@ -97,7 +97,10 @@ main() {
97 exit 197 exit 1
98 {% else %}98 {% else %}
99 echo "You are approving your own session. The incident will be logged."99 echo "You are approving your own session. The incident will be logged."
100 echo ${log_line} >> /var/log/sudo_pair.log100 logger -p auth.warn $log_line
101 {% if pagerduty_key %}
102 /usr/bin/pagerdutyevent.py "$log_line"
103 {% endif %}
101 {% endif %}104 {% endif %}
102 fi105 fi
103106
diff --git a/src/templates/sudo_pair.pagerduty.tmpl b/src/templates/sudo_pair.pagerduty.tmpl
104new file mode 100644107new file mode 100644
index 0000000..2185cf9
--- /dev/null
+++ b/src/templates/sudo_pair.pagerduty.tmpl
@@ -0,0 +1,6 @@
1[DEFAULT]
2pagerduty_key: {{ pagerduty_key }}
3pagerduty_source: {{ pagerduty_source }}
4{% if https_proxy -%}
5https_proxy: {{ https_proxy }}
6{% endif %}
0\ No newline at end of file7\ No newline at end of file
diff --git a/tests/00-unit b/src/tests/00-unit
1similarity index 100%8similarity index 100%
2rename from tests/00-unit9rename from tests/00-unit
3rename to src/tests/00-unit10rename to src/tests/00-unit
diff --git a/tests/01-functional b/src/tests/01-functional
4similarity index 100%11similarity index 100%
5rename from tests/01-functional12rename from tests/01-functional
6rename to src/tests/01-functional13rename to src/tests/01-functional
diff --git a/tests/functional/conftest.py b/src/tests/functional/conftest.py
7similarity index 75%14similarity index 75%
8rename from tests/functional/conftest.py15rename from tests/functional/conftest.py
9rename to src/tests/functional/conftest.py16rename to src/tests/functional/conftest.py
index aa42a09..fdd4356 100644
--- a/tests/functional/conftest.py
+++ b/src/tests/functional/conftest.py
@@ -1,4 +1,5 @@
1#!/usr/bin/python31#!/usr/bin/python3
2"""Functional tests utilities."""
23
3import asyncio4import asyncio
4import json5import json
@@ -16,7 +17,7 @@ import pytest
16STAT_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: E50117STAT_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
1718
1819
19@pytest.yield_fixture(scope='module')20@pytest.yield_fixture(scope="module")
20def event_loop(request):21def event_loop(request):
21 """Override the default pytest event loop to allow for broaded scopedv fixtures."""22 """Override the default pytest event loop to allow for broaded scopedv fixtures."""
22 loop = asyncio.get_event_loop_policy().new_event_loop()23 loop = asyncio.get_event_loop_policy().new_event_loop()
@@ -27,7 +28,7 @@ def event_loop(request):
27 asyncio.set_event_loop(None)28 asyncio.set_event_loop(None)
2829
2930
30@pytest.fixture(scope='module')31@pytest.fixture(scope="module")
31async def controller():32async def controller():
32 """Connect to the current controller."""33 """Connect to the current controller."""
33 controller = Controller()34 controller = Controller()
@@ -36,21 +37,21 @@ async def controller():
36 await controller.disconnect()37 await controller.disconnect()
3738
3839
39@pytest.fixture(scope='module')40@pytest.fixture(scope="module")
40async def model(controller):41async def model(controller):
41 """Create a model that lives only for the duration of the test."""42 """Create a model that lives only for the duration of the test."""
42 model_name = "functest-{}".format(uuid.uuid4())43 model_name = "functest-{}".format(uuid.uuid4())
43 model = await controller.add_model(model_name)44 model = await controller.add_model(model_name)
44 yield model45 yield model
45 await model.disconnect()46 await model.disconnect()
46 if os.getenv('test_preserve_model'):47 if os.getenv("PYTEST_KEEP_MODEL"):
47 return48 return
48 await controller.destroy_model(model_name)49 await controller.destroy_model(model_name)
49 while model_name in await controller.list_models():50 while model_name in await controller.list_models():
50 await asyncio.sleep(1)51 await asyncio.sleep(1)
5152
5253
53@pytest.fixture(scope='module')54@pytest.fixture(scope="module")
54async def current_model():55async def current_model():
55 """Return the current model, does not create or destroy it."""56 """Return the current model, does not create or destroy it."""
56 model = Model()57 model = Model()
@@ -60,31 +61,36 @@ async def current_model():
6061
6162
62@pytest.fixture63@pytest.fixture
63async def get_app(model):64async def get_app(model): # noqa: D202
64 """Return the application requested."""65 """Return the application requested."""
66
65 async def _get_app(name):67 async def _get_app(name):
66 try:68 try:
67 return model.applications[name]69 return model.applications[name]
68 except KeyError:70 except KeyError:
69 raise JujuError("Cannot find application {}".format(name))71 raise JujuError("Cannot find application {}".format(name))
72
70 return _get_app73 return _get_app
7174
7275
73@pytest.fixture76@pytest.fixture
74async def get_unit(model):77async def get_unit(model): # noqa: D202
75 """Return the requested <app_name>/<unit_number> unit."""78 """Return the requested <app_name>/<unit_number> unit."""
79
76 async def _get_unit(name):80 async def _get_unit(name):
77 try:81 try:
78 (app_name, unit_number) = name.split('/')82 (app_name, unit_number) = name.split("/")
79 return model.applications[app_name].units[unit_number]83 return model.applications[app_name].units[unit_number]
80 except (KeyError, ValueError):84 except (KeyError, ValueError):
81 raise JujuError("Cannot find unit {}".format(name))85 raise JujuError("Cannot find unit {}".format(name))
86
82 return _get_unit87 return _get_unit
8388
8489
85@pytest.fixture90@pytest.fixture
86async def get_entity(model, get_unit, get_app):91async def get_entity(model, get_unit, get_app): # noqa: D202
87 """Return a unit or an application."""92 """Return a unit or an application."""
93
88 async def _get_entity(name):94 async def _get_entity(name):
89 try:95 try:
90 return await get_unit(name)96 return await get_unit(name)
@@ -93,61 +99,65 @@ async def get_entity(model, get_unit, get_app):
93 return await get_app(name)99 return await get_app(name)
94 except JujuError:100 except JujuError:
95 raise JujuError("Cannot find entity {}".format(name))101 raise JujuError("Cannot find entity {}".format(name))
102
96 return _get_entity103 return _get_entity
97104
98105
99@pytest.fixture106@pytest.fixture
100async def run_command(get_unit):107async def run_command(get_unit): # noqa: D202
101 """108 """Run a command on an unit."""
102 Run a command on a unit.
103109
104 :param cmd: Command to be run
105 :param target: Unit object or unit name string
106 """
107 async def _run_command(cmd, target):110 async def _run_command(cmd, target):
108 unit = (111 """Run a command on a unit.
109 target112
110 if type(target) is juju.unit.Unit113 :param cmd: Command to be run
111 else await get_unit(target)114 :param target: Unit object or unit name string
112 )115 """
116 unit = target if type(target) is juju.unit.Unit else await get_unit(target)
113 action = await unit.run(cmd)117 action = await unit.run(cmd)
114 return action.results118 return action.results
119
115 return _run_command120 return _run_command
116121
117122
118@pytest.fixture123@pytest.fixture
119async def file_stat(run_command):124async def file_stat(run_command): # noqa: D202
120 """125 """Get file stat from an unit."""
121 Run stat on a file.
122126
123 :param path: File path
124 :param target: Unit object or unit name string
125 """
126 async def _file_stat(path, target):127 async def _file_stat(path, target):
128 """Run stat on a file.
129
130 :param path: File path
131 :param target: Unit object or unit name string
132 """
127 cmd = STAT_FILE % path133 cmd = STAT_FILE % path
128 results = await run_command(cmd, target)134 results = await run_command(cmd, target)
129 return json.loads(results['Stdout'])135 return json.loads(results["Stdout"])
136
130 return _file_stat137 return _file_stat
131138
132139
133@pytest.fixture140@pytest.fixture
134async def file_contents(run_command):141async def file_contents(run_command): # noqa: D202
135 """142 """Return the contents of a file."""
136 Return the contents of a file.
137143
138 :param path: File path
139 :param target: Unit object or unit name string
140 """
141 async def _file_contents(path, target):144 async def _file_contents(path, target):
142 cmd = 'cat {}'.format(path)145 """Return the contents of a file.
146
147 :param path: File path
148 :param target: Unit object or unit name string
149 """
150 cmd = "cat {}".format(path)
143 results = await run_command(cmd, target)151 results = await run_command(cmd, target)
144 return results['Stdout']152 return results["Stdout"]
153
145 return _file_contents154 return _file_contents
146155
147156
148@pytest.fixture157@pytest.fixture
149async def reconfigure_app(get_app, model):158async def reconfigure_app(get_app, model): # noqa: D202
150 """Apply a different config to the requested app."""159 """Apply a different config to the requested app."""
160
151 async def _reconfigure_app(cfg, target):161 async def _reconfigure_app(cfg, target):
152 application = (162 application = (
153 target163 target
@@ -156,14 +166,17 @@ async def reconfigure_app(get_app, model):
156 )166 )
157 await application.set_config(cfg)167 await application.set_config(cfg)
158 await application.get_config()168 await application.get_config()
159 await model.block_until(lambda: application.status == 'active')169 await model.block_until(lambda: application.status == "active")
170
160 return _reconfigure_app171 return _reconfigure_app
161172
162173
163@pytest.fixture174@pytest.fixture
164async def create_group(run_command):175async def create_group(run_command): # noqa: D202
165 """Create the UNIX group specified."""176 """Create the UNIX group specified."""
177
166 async def _create_group(group_name, target):178 async def _create_group(group_name, target):
167 cmd = "sudo groupadd %s" % group_name179 cmd = "sudo groupadd %s" % group_name
168 await run_command(cmd, target)180 await run_command(cmd, target)
181
169 return _create_group182 return _create_group
diff --git a/tests/functional/requirements.txt b/src/tests/functional/requirements.txt
170similarity index 100%183similarity index 100%
171rename from tests/functional/requirements.txt184rename from tests/functional/requirements.txt
172rename to src/tests/functional/requirements.txt185rename to src/tests/functional/requirements.txt
diff --git a/tests/functional/test_deploy.py b/src/tests/functional/test_deploy.py
173similarity index 51%186similarity index 51%
174rename from tests/functional/test_deploy.py187rename from tests/functional/test_deploy.py
175rename to src/tests/functional/test_deploy.py188rename to src/tests/functional/test_deploy.py
index cdb5ae4..11e8297 100644
--- a/tests/functional/test_deploy.py
+++ b/src/tests/functional/test_deploy.py
@@ -1,4 +1,5 @@
1#!/usr/bin/python3.61#!/usr/bin/python3.6
2"""Charm functional tests."""
23
3import os4import os
45
@@ -6,14 +7,16 @@ import pytest
67
7pytestmark = pytest.mark.asyncio8pytestmark = pytest.mark.asyncio
89
9charm_build_dir = os.getenv('CHARM_BUILD_DIR', '..').rstrip('/')10charm_build_dir = os.getenv("CHARM_BUILD_DIR").rstrip("/")
1011
1112
12sources = [('local', '{}/builds/sudo-pair'.format(charm_build_dir))]13sources = [("local", "{}/sudo-pair".format(charm_build_dir))]
1314
14series = ['xenial',15series = [
15 'bionic',16 "xenial",
16 ]17 "bionic",
18 "focal",
19]
1720
1821
19############22############
@@ -36,8 +39,8 @@ def source(request):
36@pytest.fixture39@pytest.fixture
37async def app(model, series, source):40async def app(model, series, source):
38 """Return application of the charm under test."""41 """Return application of the charm under test."""
39 app_name = 'sudo-pair-{}'.format(series)42 app_name = "sudo-pair-{}".format(series)
40 return await model._wait_for_new('application', app_name)43 return await model._wait_for_new("application", app_name)
4144
4245
43@pytest.fixture46@pytest.fixture
@@ -50,108 +53,92 @@ async def unit(app):
50# TESTS #53# TESTS #
51#########54#########
5255
56
53async def test_deploy_app(model, series, source):57async def test_deploy_app(model, series, source):
54 """Deploy the sudo_pair app as a subordinate of ubuntu."""58 """Deploy the sudo_pair app as a subordinate of ubuntu."""
55 await model.deploy(59 await model.deploy(
56 'ubuntu',60 "ubuntu", application_name="ubuntu-" + series, series=series, channel="stable"
57 application_name='ubuntu-' + series,
58 series=series,
59 channel='stable'
60 )61 )
61 sudo_pair_app = await model.deploy(62 sudo_pair_app = await model.deploy(
62 source[1],63 source[1],
63 application_name='sudo-pair-' + series,64 application_name="sudo-pair-" + series,
64 series=series,65 series=series,
65 num_units=0,66 num_units=0,
66 config={67 config={
67 'bypass_cmds': '/bin/ls',68 "bypass_cmds": "/bin/ls",
68 'groups_enforced': 'ubuntu',69 "groups_enforced": "ubuntu",
69 'bypass_group': 'warthogs',70 "bypass_group": "warthogs",
70 }71 },
71 )72 )
72 await model.add_relation(73 await model.add_relation(
73 'ubuntu-{}:juju-info'.format(series),74 "ubuntu-{}:juju-info".format(series), "sudo-pair-{}:juju-info".format(series)
74 'sudo-pair-{}:juju-info'.format(series))75 )
7576
76 await model.block_until(lambda: sudo_pair_app.status == 'active')77 await model.block_until(lambda: sudo_pair_app.status == "active")
77 # no need to cleanup since the model will be be torn down at the end of the78 # no need to cleanup since the model will be be torn down at the end of the
78 # testing79 # testing
7980
8081
81async def test_status(app):82async def test_status(app):
82 """Check that the app is in active state."""83 """Check that the app is in active state."""
83 assert app.status == 'active'84 assert app.status == "active"
8485
8586
86@pytest.mark.parametrize("path,expected_stat", [87@pytest.mark.parametrize(
87 ('/usr/lib/sudo/sudo_pair.so', {88 "path,expected_stat",
88 'gid': 0,89 [
89 'uid': 0,90 ("/usr/lib/sudo/sudo_pair.so", {"gid": 0, "uid": 0, "mode": "0o100644"}),
90 'mode': '0o100644'}),91 ("/usr/bin/sudo_approve", {"gid": 0, "uid": 0, "mode": "0o100755"}),
91 ('/usr/bin/sudo_approve', {92 ("/etc/sudo_pair.prompt.user", {"gid": 0, "uid": 0, "mode": "0o100644"}),
92 'gid': 0,93 ("/etc/sudo_pair.prompt.pair", {"gid": 0, "uid": 0, "mode": "0o100644"}),
93 'uid': 0,94 ("/var/run/sudo_pair", {"gid": 0, "uid": 0, "mode": "0o40644"}),
94 'mode': '0o100755'}),95 ],
95 ('/etc/sudo_pair.prompt.user', {96)
96 'gid': 0,
97 'uid': 0,
98 'mode': '0o100644'}),
99 ('/etc/sudo_pair.prompt.pair', {
100 'gid': 0,
101 'uid': 0,
102 'mode': '0o100644'}),
103 ('/var/run/sudo_pair', {
104 'gid': 0,
105 'uid': 0,
106 'mode': '0o40644'})])
107async def test_stats(path, expected_stat, unit, file_stat):97async def test_stats(path, expected_stat, unit, file_stat):
108 """Check that created files have the correct permissions."""98 """Check that created files have the correct permissions."""
109 test_stat = await file_stat(path, unit)99 test_stat = await file_stat(path, unit)
110 assert test_stat['size'] > 0100 assert test_stat["size"] > 0
111 assert test_stat['gid'] == expected_stat['gid']101 assert test_stat["gid"] == expected_stat["gid"]
112 assert test_stat['uid'] == expected_stat['uid']102 assert test_stat["uid"] == expected_stat["uid"]
113 assert test_stat['mode'] == expected_stat['mode']103 assert test_stat["mode"] == expected_stat["mode"]
114104
115105
116async def test_sudoers(file_contents, unit):106async def test_sudoers(file_contents, unit):
117 """Check the content of sudoers file."""107 """Check the content of sudoers file."""
118 sudoers_content = await file_contents("/etc/sudoers", unit)108 sudoers_content = await file_contents("/etc/sudoers", unit)
119 assert 'Defaults log_output' in sudoers_content109 assert "Defaults log_output" in sudoers_content
120110
121111
122async def test_sudoers_bypass_conf(file_contents, unit):112async def test_sudoers_bypass_conf(file_contents, unit):
123 """Check the content of sudoers bypass command file."""113 """Check the content of sudoers bypass command file."""
124 path = "/etc/sudoers.d/91-bypass-sudopair-cmds"114 path = "/etc/sudoers.d/91-bypass-sudopair-cmds"
125 sudoers_bypass_content = await file_contents(path=path,115 sudoers_bypass_content = await file_contents(path=path, target=unit)
126 target=unit)116 content = "%warthogs ALL = (ALL) NOLOG_OUTPUT: /bin/ls"
127 content = '%warthogs ALL = (ALL) NOLOG_OUTPUT: /bin/ls'
128 assert content in sudoers_bypass_content117 assert content in sudoers_bypass_content
129118
130119
131async def test_reconfigure(reconfigure_app, file_contents, unit, app):120async def test_reconfigure(reconfigure_app, file_contents, unit, app):
132 """Change a charm config parameter and verify that it has been propagated to the unit."""121 """Change a charm config parameter and verify it is applied."""
133 sudo_approve_path = '/usr/bin/sudo_approve'122 sudo_approve_path = "/usr/bin/sudo_approve"
134 await reconfigure_app(cfg={'auto_approve': 'false'},123 await reconfigure_app(cfg={"auto_approve": "false"}, target=app)
135 target=app)124 sudo_approve_content = await file_contents(path=sudo_approve_path, target=unit)
136 sudo_approve_content = await file_contents(path=sudo_approve_path,
137 target=unit)
138 new_content = 'echo "You can\'t approve your own session."'125 new_content = 'echo "You can\'t approve your own session."'
139 assert new_content in sudo_approve_content126 assert new_content in sudo_approve_content
140127
141128
142async def test_remove_relation(app, model, run_command):129async def test_remove_relation(app, model, run_command):
143 """Check that the relation is removed."""130 """Check that the relation is removed."""
144 series = app.units[0].data['series']131 series = app.units[0].data["series"]
145 app_name = 'sudo-pair-{}'.format(series)132 app_name = "sudo-pair-{}".format(series)
146 principalname = 'ubuntu-{}'.format(series)133 principalname = "ubuntu-{}".format(series)
147 await app.remove_relation(134 await app.remove_relation(
148 '{}:juju-info'.format(app_name),135 "{}:juju-info".format(app_name), "{}:juju-info".format(principalname)
149 '{}:juju-info'.format(principalname))136 )
150 await model.block_until(lambda: not app.relations)137 await model.block_until(lambda: not app.relations)
151 principal = model.applications[principalname].units[0]138 principal = model.applications[principalname].units[0]
152 res = await run_command('test -f /etc/sudo.conf || echo gone', target=principal)139 res = await run_command("test -f /etc/sudo.conf || echo gone", target=principal)
153 assert res['Stdout'].strip() == 'gone'140 assert res["Stdout"].strip() == "gone"
154 await model.add_relation(141 await model.add_relation(
155 '{}:juju-info'.format(principalname),142 "{}:juju-info".format(principalname), "{}:juju-info".format(app_name)
156 '{}:juju-info'.format(app_name))143 )
157 await model.block_until(lambda: app.relations)144 await model.block_until(lambda: app.relations)
diff --git a/tests/tests.yaml b/src/tests/tests.yaml
158similarity index 100%145similarity index 100%
159rename from tests/tests.yaml146rename from tests/tests.yaml
160rename to src/tests/tests.yaml147rename to src/tests/tests.yaml
diff --git a/tests/unit/conftest.py b/src/tests/unit/conftest.py
161similarity index 82%148similarity index 82%
162rename from tests/unit/conftest.py149rename from tests/unit/conftest.py
163rename to src/tests/unit/conftest.py150rename to src/tests/unit/conftest.py
index c0800ea..bc65d15 100644
--- a/tests/unit/conftest.py
+++ b/src/tests/unit/conftest.py
@@ -1,4 +1,5 @@
1#!/usr/bin/python31#!/usr/bin/python3
2"""Unit tests utilities."""
23
3import grp4import grp
4import os5import os
@@ -10,29 +11,33 @@ import pytest
1011
11@pytest.fixture12@pytest.fixture
12def mock_hookenv_config(monkeypatch):13def mock_hookenv_config(monkeypatch):
14 """Mock hookenv config."""
13 import yaml15 import yaml
1416
15 def mock_config():17 def mock_config():
16 cfg = {}18 cfg = {}
17 yml = yaml.load(open('./config.yaml'))19 yml = yaml.safe_load(open("./config.yaml"))
1820
19 # Load all defaults21 # Load all defaults
20 for key, value in yml['options'].items():22 for key, value in yml["options"].items():
21 cfg[key] = value['default']23 cfg[key] = value["default"]
2224
23 return cfg25 return cfg
2426
25 monkeypatch.setattr('charmhelpers.core.hookenv.config', mock_config)27 monkeypatch.setattr("charmhelpers.core.hookenv.config", mock_config)
2628
2729
28@pytest.fixture30@pytest.fixture
29def mock_charm_dir(monkeypatch):31def mock_charm_dir(monkeypatch):
30 monkeypatch.setattr('charmhelpers.core.hookenv.charm_dir', lambda: '.')32 """Mock charm dir."""
33 monkeypatch.setattr("charmhelpers.core.hookenv.charm_dir", lambda: ".")
3134
3235
33@pytest.fixture36@pytest.fixture
34def sph(mock_hookenv_config, mock_charm_dir, tmpdir):37def sph(mock_hookenv_config, mock_charm_dir, tmpdir):
38 """Return SudoPairHelpers with mocks."""
35 from libsudopair import SudoPairHelper39 from libsudopair import SudoPairHelper
40
36 sph = SudoPairHelper()41 sph = SudoPairHelper()
37 sph.owner = pwd.getpwuid(os.getuid()).pw_name42 sph.owner = pwd.getpwuid(os.getuid()).pw_name
38 sph.group = grp.getgrgid(os.getgid()).gr_name43 sph.group = grp.getgrgid(os.getgid()).gr_name
@@ -49,4 +54,5 @@ def sph(mock_hookenv_config, mock_charm_dir, tmpdir):
49 sph.sudoers_bypass_path = tmpdir.join(sph.sudoers_bypass_path)54 sph.sudoers_bypass_path = tmpdir.join(sph.sudoers_bypass_path)
50 sph.socket_dir_perms = 0o77555 sph.socket_dir_perms = 0o775
51 sph.sudo_conf_perms = 0o64456 sph.sudo_conf_perms = 0o644
57 sph.pagerduty_path = tmpdir.join(sph.pagerduty_path)
52 return sph58 return sph
diff --git a/tests/unit/requirements.txt b/src/tests/unit/requirements.txt
53similarity index 100%59similarity index 100%
54rename from tests/unit/requirements.txt60rename from tests/unit/requirements.txt
55rename to src/tests/unit/requirements.txt61rename to src/tests/unit/requirements.txt
diff --git a/src/tests/unit/test_actions.py b/src/tests/unit/test_actions.py
56new file mode 10064462new file mode 100644
index 0000000..fd0758a
--- /dev/null
+++ b/src/tests/unit/test_actions.py
@@ -0,0 +1,23 @@
1"""Unit tests for charm actions."""
2
3import os
4import sys
5import unittest.mock as mock
6
7action_path = os.path.join(os.path.dirname(__file__), "..", "..", "actions")
8sys.path.append(action_path)
9import actions # NOQA
10
11
12@mock.patch("libsudopair.SudoPairHelper")
13@mock.patch("actions.action_set")
14@mock.patch("actions.status_set")
15@mock.patch("actions.subprocess.run")
16def test_remove_action(subprocess_run, status_set, action_set, sudo_pair_helper):
17 """Verify remove action."""
18 actions.remove()
19 msg = "Successfully removed sudo-pair config and binaries"
20 action_set.assert_called_with({"message": msg})
21 status_set.assert_called_with("active", msg)
22 sudo_pair_helper().deconfigure.assert_called()
23 subprocess_run.assert_called_with(["/usr/bin/pagerdutyevent.py", msg])
diff --git a/src/tests/unit/test_libsudopair.py b/src/tests/unit/test_libsudopair.py
0new file mode 10064424new file mode 100644
index 0000000..8d730ad
--- /dev/null
+++ b/src/tests/unit/test_libsudopair.py
@@ -0,0 +1,206 @@
1"""Sudopair lib unit tests."""
2
3import filecmp
4import grp
5import os
6
7from libsudopair import check_valid_group, group_id
8
9
10def test_check_valid_group():
11 """Check an unix group is valid."""
12 assert not check_valid_group("fake_group")
13 assert check_valid_group(grp.getgrgid(os.getgid()).gr_name)
14
15
16def test_group_id():
17 """Verify group_id() is correct."""
18 assert group_id(grp.getgrgid(os.getgid()).gr_name) == os.getgid()
19
20
21class TestSudoPairHelper:
22 """Module to test SudoPairHelper lib."""
23
24 def test_pytest(self):
25 """Assert testing is carryied using pytest."""
26 assert True
27
28 def test_sph(self, sph):
29 """See if the sph fixture works to load charm configs."""
30 assert isinstance(sph.charm_config, dict)
31
32 def test_get_config(self, sph):
33 """Check if config contains all the required entries."""
34 default_keywords = [
35 "binary_path",
36 "user_prompt_path",
37 "pair_prompt_path",
38 "socket_dir",
39 "gids_enforced",
40 "gids_exempted",
41 ]
42 config = sph.get_config()
43 for option in default_keywords:
44 assert option in config
45
46 def test_set_charm_config(self, sph):
47 """Set new config."""
48 charm_config = {
49 "groups_enforced": "root",
50 "groups_exempted": "",
51 "bypass_cmds": "",
52 "bypass_group": "",
53 "auto_approve": True,
54 }
55
56 sph.set_charm_config(charm_config)
57
58 for option in charm_config:
59 assert option in sph.get_config()
60 assert sph.get_config()[option] == charm_config[option]
61
62 def test_render_sudo_conf(self, sph, tmpdir):
63 """Check that sudo.conf is rendered correctly."""
64 # Default config
65 content = sph.render_sudo_conf()
66 expected_content = (
67 "Plugin sudo_pair sudo_pair.so binary_path={} "
68 "user_prompt_path={} "
69 "pair_prompt_path={} socket_dir={} gids_enforced={}".format(
70 tmpdir.join("/usr/bin/sudo_approve"),
71 tmpdir.join("/etc/sudo_pair.prompt.user"),
72 tmpdir.join("/etc/sudo_pair.prompt.pair"),
73 tmpdir.join("/var/run/sudo_pair"),
74 "0",
75 )
76 )
77 assert expected_content in content
78
79 # Gid exempted
80 groups_exempted = grp.getgrgid(os.getgid()).gr_name
81 charm_config = {
82 "groups_enforced": "root",
83 "groups_exempted": groups_exempted,
84 "bypass_cmds": "",
85 "bypass_group": "",
86 "auto_approve": True,
87 }
88
89 sph.set_charm_config(charm_config)
90 expected_content = (
91 "Plugin sudo_pair sudo_pair.so binary_path={} user_prompt_path={} "
92 "pair_prompt_path={} socket_dir={} gids_enforced={} "
93 "gids_exempted={}".format(
94 tmpdir.join("/usr/bin/sudo_approve"),
95 tmpdir.join("/etc/sudo_pair.prompt.user"),
96 tmpdir.join("/etc/sudo_pair.prompt.pair"),
97 tmpdir.join("/var/run/sudo_pair"),
98 "0",
99 os.getgid(),
100 )
101 )
102
103 content = sph.render_sudo_conf()
104 assert expected_content in content
105
106 # Groups enforced
107 groups_enforced = "root," + grp.getgrgid(os.getgid()).gr_name
108 charm_config = {
109 "groups_enforced": groups_enforced,
110 "groups_exempted": "",
111 "bypass_cmds": "",
112 "bypass_group": "",
113 "auto_approve": True,
114 }
115 sph.set_charm_config(charm_config)
116 expected_content = (
117 "Plugin sudo_pair sudo_pair.so binary_path={} user_prompt_path={} "
118 "pair_prompt_path={} socket_dir={} gids_enforced={}".format(
119 tmpdir.join("/usr/bin/sudo_approve"),
120 tmpdir.join("/etc/sudo_pair.prompt.user"),
121 tmpdir.join("/etc/sudo_pair.prompt.pair"),
122 tmpdir.join("/var/run/sudo_pair"),
123 "0,{}".format(os.getgid()),
124 )
125 )
126 content = sph.render_sudo_conf()
127 assert expected_content in content
128
129 def test_render_bypass_cmds(self, sph):
130 """Check that sudoers file is rendered correctly."""
131 # Root bypass /bin/ls
132 expected_content = "%root ALL = (ALL) NOLOG_OUTPUT: /bin/ls"
133 charm_config = {
134 "groups_enforced": "root",
135 "groups_exempted": "",
136 "bypass_cmds": "/bin/ls",
137 "bypass_group": "root",
138 "auto_approve": True,
139 }
140 sph.set_charm_config(charm_config)
141 content = sph.render_bypass_cmds()
142 assert expected_content in content
143
144 def test_render_sudo_approve(self, sph, tmpdir):
145 """Check that sudo_approve file is rendered correctly."""
146 # Auto Approve true
147 expected_content = "logger -p auth.warn $log_line"
148 socket_dir = tmpdir.join("/var/run/sudo_pair")
149 expected_content_socket_dir = 'declare -r SUDO_SOCKET_PATH="{}"'.format(
150 socket_dir
151 )
152 content = sph.render_sudo_approve()
153 assert expected_content in content
154 assert expected_content_socket_dir in content
155
156 # Auto Approve false
157 expected_content = 'echo "You can\'t approve your own session."'
158 charm_config = {
159 "groups_enforced": "root",
160 "groups_exempted": "",
161 "bypass_cmds": "/bin/ls",
162 "bypass_group": "root",
163 "auto_approve": False,
164 }
165 sph.set_charm_config(charm_config)
166 content = sph.render_sudo_approve()
167 assert expected_content in content
168
169 def test_create_socket_dir(self, sph, tmpdir):
170 """Check that sudo_pair socket dir exists."""
171 sph.create_socket_dir()
172 assert os.path.exists(tmpdir.join("/var/run/sudo_pair"))
173
174 def test_create_tmpfiles_conf(self, sph, tmpdir):
175 """Check that sudo pair temporary conf is rendered correctly."""
176 sph.create_tmpfiles_conf()
177 expected_content = "d {} 0755 - - -\n".format(sph.socket_dir)
178 with open(tmpdir.join("/usr/lib/tmpfiles.d/sudo_pair.conf")) as f:
179 content = f.read()
180 assert expected_content in content
181
182 def test_install_sudo_pair_so(self, sph, tmpdir):
183 """Check that sudo system lib exists."""
184 sph.install_sudo_pair_so()
185 assert filecmp.cmp(
186 "./files/sudo_pair.so", tmpdir.join("/usr/lib/sudo/sudo_pair.so")
187 )
188
189 def test_copy_user_prompt(self, sph, tmpdir):
190 """Check that user prompt exists."""
191 sph.copy_user_prompt()
192 assert filecmp.cmp(
193 "./files/sudo.prompt.user", tmpdir.join("/etc/sudo_pair.prompt.user")
194 )
195
196 def test_copy_pair_prompt(self, sph, tmpdir):
197 """Check that pair prompt exists."""
198 sph.copy_pair_prompt()
199 assert filecmp.cmp(
200 "./files/sudo.prompt.pair", tmpdir.join("/etc/sudo_pair.prompt.pair")
201 )
202
203 def test_copy_sudoers(self, sph, tmpdir):
204 """Check that sudoers file exists."""
205 sph.copy_sudoers()
206 assert filecmp.cmp("./files/sudoers", tmpdir.join("/etc/sudoers"))
diff --git a/tox.ini b/src/tox.ini
0similarity index 66%207similarity index 66%
1rename from tox.ini208rename from tox.ini
2rename to src/tox.ini209rename to src/tox.ini
index fc364de..2900ead 100644
--- a/tox.ini
+++ b/src/tox.ini
@@ -1,40 +1,33 @@
1[tox]1[tox]
2skipsdist=True2skipsdist=True
3envlist = unit, functional
4skip_missing_interpreters = True3skip_missing_interpreters = True
4envlist = lint, unit, func
55
6[testenv]6[testenv]
7basepython = python37basepython = python3
8setenv =8setenv =
9 PYTHONPATH = .9 PYTHONPATH = {toxinidir}:{toxinidir}/lib/:{toxinidir}/hooks/
10
11[testenv:unit]
12commands = pytest -v --ignore {toxinidir}/tests/functional \
13 --cov=lib \
14 --cov=reactive \
15 --cov=actions \
16 --cov-report=term \
17 --cov-report=annotate:report/annotated \
18 --cov-report=html:report/html
19deps = -r{toxinidir}/tests/unit/requirements.txt
20
21setenv = PYTHONPATH={toxinidir}/lib
22
23[testenv:functional]
24passenv =10passenv =
25 HOME11 HOME
26 CHARM_BUILD_DIR
27 PATH12 PATH
13 CHARM_BUILD_DIR
28 PYTEST_KEEP_MODEL14 PYTEST_KEEP_MODEL
29 PYTEST_CLOUD_NAME15 PYTEST_CLOUD_NAME
30 PYTEST_CLOUD_REGION16 PYTEST_CLOUD_REGION
31commands = pytest -v --ignore {toxinidir}/tests/unit17 PYTEST_MODEL
32deps = -r{toxinidir}/tests/functional/requirements.txt18 MODEL_SETTINGS
3319 HTTP_PROXY
20 HTTPS_PROXY
21 NO_PROXY
22 SNAP_HTTP_PROXY
23 SNAP_HTTPS_PROXY
3424
35[testenv:lint]25[testenv:lint]
36commands = flake826commands =
27 flake8
28 black --check --exclude "/(\.eggs|\.git|\.tox|\.venv|\.build|dist|charmhelpers|mod)/" .
37deps =29deps =
30 black
38 flake831 flake8
39 flake8-docstrings32 flake8-docstrings
40 flake8-import-order33 flake8-import-order
@@ -42,10 +35,36 @@ deps =
42 flake8-colors35 flake8-colors
4336
44[flake8]37[flake8]
45ignore = D100,D103 # Missing docstring in public module/function
46exclude =38exclude =
47 .git,39 .git,
48 __pycache__,40 __pycache__,
49 .tox,41 .tox,
50max-line-length = 12042 charmhelpers,
43 mod,
44 .build
45
46max-line-length = 88
51max-complexity = 1047max-complexity = 10
48
49[testenv:black]
50commands =
51 black --exclude "/(\.eggs|\.git|\.tox|\.venv|\.build|dist|charmhelpers|mod)/" .
52deps =
53 black
54
55[testenv:unit]
56commands =
57 pytest -v --ignore {toxinidir}/tests/functional \
58 --cov=lib \
59 --cov=reactive \
60 --cov=actions \
61 --cov=hooks \
62 --cov=src \
63 --cov-report=term \
64 --cov-report=annotate:report/annotated \
65 --cov-report=html:report/html
66deps = -r{toxinidir}/tests/unit/requirements.txt
67
68[testenv:func]
69commands = pytest -v --ignore {toxinidir}/tests/unit
70deps = -r{toxinidir}/tests/functional/requirements.txt
diff --git a/tests/unit/test_libsudopair.py b/tests/unit/test_libsudopair.py
52deleted file mode 10064471deleted file mode 100644
index 3a598af..0000000
--- a/tests/unit/test_libsudopair.py
+++ /dev/null
@@ -1,185 +0,0 @@
1import filecmp
2import grp
3import os
4
5from libsudopair import (
6 check_valid_group,
7 group_id
8)
9
10
11def test_check_valid_group():
12 assert not check_valid_group('fake_group')
13 assert check_valid_group(grp.getgrgid(os.getgid()).gr_name)
14
15
16def test_group_id():
17 assert group_id(grp.getgrgid(os.getgid()).gr_name) == os.getgid()
18
19
20class TestSudoPairHelper():
21 """Module to test SudoPairHelper lib."""
22
23 def test_pytest(self):
24 """Assert testing is carryied using pytest."""
25 assert True
26
27 def test_sph(self, sph):
28 """See if the sph fixture works to load charm configs."""
29 assert isinstance(sph.charm_config, dict)
30
31 def test_get_config(self, sph):
32 """Check if config contains all the required entries."""
33 default_keywords = [
34 'binary_path',
35 'user_prompt_path',
36 'pair_prompt_path',
37 'socket_dir',
38 'gids_enforced',
39 'gids_exempted',
40 ]
41 config = sph.get_config()
42 for option in default_keywords:
43 assert option in config
44
45 def test_set_charm_config(self, sph):
46 """Set new config."""
47 charm_config = {
48 'groups_enforced': 'root',
49 'groups_exempted': '',
50 'bypass_cmds': '',
51 'bypass_group': '',
52 'auto_approve': True
53 }
54
55 sph.set_charm_config(charm_config)
56
57 for option in charm_config:
58 assert option in sph.get_config()
59 assert sph.get_config()[option] == charm_config[option]
60
61 def test_render_sudo_conf(self, sph, tmpdir):
62 """Check that sudo.conf is rendered correctly."""
63 # Default config
64 content = sph.render_sudo_conf()
65 expected_content = 'Plugin sudo_pair sudo_pair.so binary_path={} ' \
66 'user_prompt_path={} ' \
67 'pair_prompt_path={} socket_dir={} gids_enforced={}'.format(
68 tmpdir.join('/usr/bin/sudo_approve'),
69 tmpdir.join('/etc/sudo_pair.prompt.user'),
70 tmpdir.join('/etc/sudo_pair.prompt.pair'),
71 tmpdir.join('/var/run/sudo_pair'),
72 '0')
73 assert expected_content in content
74
75 # Gid exempted
76 groups_exempted = grp.getgrgid(os.getgid()).gr_name
77 charm_config = {
78 'groups_enforced': 'root',
79 'groups_exempted': groups_exempted,
80 'bypass_cmds': '',
81 'bypass_group': '',
82 'auto_approve': True
83 }
84
85 sph.set_charm_config(charm_config)
86 expected_content = \
87 'Plugin sudo_pair sudo_pair.so binary_path={} user_prompt_path={} ' \
88 'pair_prompt_path={} socket_dir={} gids_enforced={} gids_exempted={}'.format(
89 tmpdir.join('/usr/bin/sudo_approve'),
90 tmpdir.join('/etc/sudo_pair.prompt.user'),
91 tmpdir.join('/etc/sudo_pair.prompt.pair'),
92 tmpdir.join('/var/run/sudo_pair'), '0', os.getgid())
93
94 content = sph.render_sudo_conf()
95 assert expected_content in content
96
97 # Groups enforced
98 groups_enforced = 'root,' + grp.getgrgid(os.getgid()).gr_name
99 charm_config = {
100 'groups_enforced': groups_enforced,
101 'groups_exempted': '',
102 'bypass_cmds': '',
103 'bypass_group': '',
104 'auto_approve': True
105 }
106 sph.set_charm_config(charm_config)
107 expected_content = 'Plugin sudo_pair sudo_pair.so binary_path={} user_prompt_path={} ' \
108 'pair_prompt_path={} socket_dir={} gids_enforced={}'.format(
109 tmpdir.join('/usr/bin/sudo_approve'),
110 tmpdir.join('/etc/sudo_pair.prompt.user'),
111 tmpdir.join('/etc/sudo_pair.prompt.pair'),
112 tmpdir.join('/var/run/sudo_pair'), '0,{}'.format(os.getgid()))
113 content = sph.render_sudo_conf()
114 assert expected_content in content
115
116 def test_render_bypass_cmds(self, sph):
117 """Check that sudoers file is rendered correctly."""
118 # Root bypass /bin/ls
119 expected_content = '%root ALL = (ALL) NOLOG_OUTPUT: /bin/ls'
120 charm_config = {
121 'groups_enforced': 'root',
122 'groups_exempted': '',
123 'bypass_cmds': '/bin/ls',
124 'bypass_group': 'root',
125 'auto_approve': True
126 }
127 sph.set_charm_config(charm_config)
128 content = sph.render_bypass_cmds()
129 assert expected_content in content
130
131 def test_render_sudo_approve(self, sph, tmpdir):
132 """Check that sudo_approve file is rendered correctly."""
133 # Auto Approve true
134 expected_content = 'echo ${log_line} >> /var/log/sudo_pair.log'
135 socket_dir = tmpdir.join('/var/run/sudo_pair')
136 expected_content_socket_dir = 'declare -r SUDO_SOCKET_PATH="{}"'.format(socket_dir)
137 content = sph.render_sudo_approve()
138 assert expected_content in content
139 assert expected_content_socket_dir in content
140
141 # Auto Approve false
142 expected_content = 'echo "You can\'t approve your own session."'
143 charm_config = {
144 'groups_enforced': 'root',
145 'groups_exempted': '',
146 'bypass_cmds': '/bin/ls',
147 'bypass_group': 'root',
148 'auto_approve': False
149 }
150 sph.set_charm_config(charm_config)
151 content = sph.render_sudo_approve()
152 assert expected_content in content
153
154 def test_create_socket_dir(self, sph, tmpdir):
155 """Check that sudo_pair socket dir exists."""
156 sph.create_socket_dir()
157 assert os.path.exists(tmpdir.join('/var/run/sudo_pair'))
158
159 def test_create_tmpfiles_conf(self, sph, tmpdir):
160 """Check that sudo pair temporary conf is rendered correctly."""
161 sph.create_tmpfiles_conf()
162 expected_content = 'd {} 0755 - - -\n'.format(sph.socket_dir)
163 with open(tmpdir.join('/usr/lib/tmpfiles.d/sudo_pair.conf')) as f:
164 content = f.read()
165 assert expected_content in content
166
167 def test_install_sudo_pair_so(self, sph, tmpdir):
168 """Check that sudo system lib exists."""
169 sph.install_sudo_pair_so()
170 assert filecmp.cmp('./files/sudo_pair.so', tmpdir.join('/usr/lib/sudo/sudo_pair.so'))
171
172 def test_copy_user_prompt(self, sph, tmpdir):
173 """Check that user prompt exists."""
174 sph.copy_user_prompt()
175 assert filecmp.cmp('./files/sudo.prompt.user', tmpdir.join('/etc/sudo_pair.prompt.user'))
176
177 def test_copy_pair_prompt(self, sph, tmpdir):
178 """Check that pair prompt exists."""
179 sph.copy_pair_prompt()
180 assert filecmp.cmp('./files/sudo.prompt.pair', tmpdir.join('/etc/sudo_pair.prompt.pair'))
181
182 def test_copy_sudoers(self, sph, tmpdir):
183 """Check that sudoers file exists."""
184 sph.copy_sudoers()
185 assert filecmp.cmp('./files/sudoers', tmpdir.join('/etc/sudoers'))

Subscribers

People subscribed via source and target branches