Merge ~mertkirpici/charm-local-users:lp/1983437 into charm-local-users:main

Proposed by Mert Kirpici
Status: Merged
Approved by: Eric Chen
Approved revision: 9f62485be1332d2359303e1f1565d9b5e38e1bc3
Merged at revision: fdc2f14b7406486ad805917e45a965547a6166b5
Proposed branch: ~mertkirpici/charm-local-users:lp/1983437
Merge into: charm-local-users:main
Diff against target: 623 lines (+338/-51)
19 files modified
Makefile (+1/-5)
config.yaml (+7/-0)
lib/local_users.py (+46/-19)
src/charm.py (+2/-1)
tests/functional/requirements.txt (+3/-0)
tests/functional/tests/bundles/base.yaml (+6/-0)
tests/functional/tests/bundles/bionic.yaml (+1/-0)
tests/functional/tests/bundles/focal.yaml (+1/-0)
tests/functional/tests/bundles/jammy.yaml (+1/-0)
tests/functional/tests/bundles/overlays/bionic.yaml.j2 (+1/-0)
tests/functional/tests/bundles/overlays/focal.yaml.j2 (+1/-0)
tests/functional/tests/bundles/overlays/jammy.yaml.j2 (+1/-0)
tests/functional/tests/bundles/overlays/local-charm-overlay.yaml.j2 (+3/-0)
tests/functional/tests/modules/__init__.py (+0/-0)
tests/functional/tests/modules/utils.py (+37/-0)
tests/functional/tests/test_local_users.py (+89/-0)
tests/functional/tests/tests.yaml (+16/-0)
tests/unit/test_local_users.py (+118/-22)
tox.ini (+4/-4)
Reviewer Review Type Date Requested Status
Eric Chen Approve
🤖 prod-jenkaas-bootstack (community) continuous-integration Approve
Gabriel Cocenza Approve
Erhan Sunar Pending
BootStack Reviewers Pending
Review via email: mp+430112@code.launchpad.net

Commit message

Close LP #1983437

Description of the change

config: add option ssh-authorized-keys

Introduce a new config option to set the path to the authorized keys file that will be used to write the ssh public key of the user for remote access. The config option also supports the usage of common variables $HOME, $USER and $UID which will be expanded for each user.

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

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

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
Mert Kirpici (mertkirpici) wrote :

since this MP adds the first functional tests for this repository, changed the jenkins job to include the `make functional` target and retriggered the job now. We should expect the results for that also. That will be the reason for the third comment from the ci-bot.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
Gabriel Cocenza (gabrielcocenza) :
review: Needs Information
Revision history for this message
Mert Kirpici (mertkirpici) wrote :

Hey Gabriel thanks for the review! I pushed an update and wrote some answers to comments inline.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
Gabriel Cocenza (gabrielcocenza) wrote :

Thanks for the fixing Mert. Generally the patch LGTM.

I have some comments in the code and I also would like to check with you to include lint and unit tests on the CI if possible. If I understood correctly just functional tests are running in the CI.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
Mert Kirpici (mertkirpici) wrote :

Hi there Gabriel, pushed an update that addresses your comments. Now we are seeing the coverage report line by line :)

Also I verified that the CI is running lint+unit+func, at the console output the lint and unittest output are only can be seen after clicking small link to show the "full output" since the functest output is huge

Revision history for this message
Gabriel Cocenza (gabrielcocenza) wrote :

Thanks Mert. LGTM

Lefty two nit comments in the unit tests

review: Approve
Revision history for this message
Eric Chen (eric-chen) wrote :

LGTM too.
After change Gabriel's last comment if it make sense to you then we can merge it. Thanks!

review: Needs Fixing
Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
Mert Kirpici (mertkirpici) wrote :

Hi Eric, I pushed a final update that:
- addresses Gabriel's comments to add assertions to chmod() calls
- squashes the commits so that it is a clean merge

The CI came clean, I think we are ready to merge

Revision history for this message
Eric Chen (eric-chen) :
review: Approve
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

Change successfully merged at revision fdc2f14b7406486ad805917e45a965547a6166b5

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/Makefile b/Makefile
2index f335696..57a3fad 100644
3--- a/Makefile
4+++ b/Makefile
5@@ -82,11 +82,7 @@ unittests:
6 @echo "Running unit tests"
7 @tox -e unit
8
9-snap:
10- @echo "Downloading snap"
11- snap download juju-lint --basename juju-lint --target-directory tests/functional/tests/resources
12-
13-functional: build snap
14+functional: build
15 @echo "Executing functional tests in ${CHARM_BUILD_DIR}"
16 @CHARM_LOCATION=${PROJECTPATH} tox -e func
17
18diff --git a/config.yaml b/config.yaml
19index 2a7920c..3f5d8dd 100644
20--- a/config.yaml
21+++ b/config.yaml
22@@ -30,6 +30,13 @@ options:
23 description:
24 When set to False the charm will enter 'blocked' state when user exists in 'users' config and in the system, but not in the charm managed group.
25 Setting to True disables that protection and allows for pre-existing users to be added to the charm managed group.
26+ ssh-authorized-keys:
27+ default: "$HOME/.ssh/authorized_keys"
28+ type: string
29+ description: |
30+ The file to write the SSH public keys to.
31+ This option supports the usage of variables "$USER", "$HOME" and "$UID" in the path string.
32+ They will be expanded to the username, home directory and the user id of each user.
33 sudoers:
34 default: ""
35 type: string
36diff --git a/lib/local_users.py b/lib/local_users.py
37index 28dd13d..efe5ec3 100755
38--- a/lib/local_users.py
39+++ b/lib/local_users.py
40@@ -17,11 +17,14 @@
41 import grp
42 import logging
43 import os
44+import pwd
45 import re
46 import shutil
47 import subprocess
48 import tempfile
49 from collections import namedtuple
50+from pathlib import Path
51+from typing import Text
52
53 from charmhelpers.core import host
54
55@@ -29,7 +32,6 @@ log = logging.getLogger(__name__)
56
57 User = namedtuple("User", ["name", "gecos", "ssh_keys"])
58
59-HOME_DIR_PATH = "/home"
60 SUDOERS_FILE = "/etc/sudoers.d/70-local-users-charm"
61
62
63@@ -48,7 +50,7 @@ def add_user(username, shell="/bin/bash", home_dir=None, gecos=None):
64 subprocess.check_call(cmd)
65
66
67-def configure_user(user, group):
68+def configure_user(user, group, authorized_keys_path):
69 """Idempotently apply requested User configuration.
70
71 Create a new account if it doesn't exist. Ensure it belongs to the requested group.
72@@ -63,7 +65,7 @@ def configure_user(user, group):
73 gecos=user.gecos,
74 )
75 host.add_user_to_group(user.name, group)
76- set_ssh_authorized_keys(user)
77+ set_ssh_authorized_keys(user, authorized_keys_path)
78 update_gecos(user)
79
80
81@@ -121,7 +123,27 @@ def rename_group(old_name, new_name):
82 subprocess.check_call(cmd)
83
84
85-def set_ssh_authorized_keys(user):
86+def _substitute_path_vars_for_user(path: Path, username: Text) -> Path:
87+ """Substitute common variables in terms of a user.
88+
89+ Supports the variables $HOME, $USER and $UID.
90+ """
91+ passwd_entry = pwd.getpwnam(username)
92+ return Path(
93+ str(path)
94+ .replace("$HOME", passwd_entry.pw_dir)
95+ .replace("$USER", passwd_entry.pw_name)
96+ .replace("$UID", str(passwd_entry.pw_uid))
97+ )
98+
99+
100+def _path_under_user_home(path: Path, username: Text) -> bool:
101+ """Test whether the given path lies inside the user home."""
102+ passwd_entry = pwd.getpwnam(username)
103+ return str(path).startswith(passwd_entry.pw_dir + "/")
104+
105+
106+def set_ssh_authorized_keys(user, authorized_keys_path):
107 """Idempotently set up the SSH public key in `authorized_keys`."""
108 comment = "# charm-local-users"
109 authorized_keys = []
110@@ -129,21 +151,24 @@ def set_ssh_authorized_keys(user):
111 for ssh_key in user.ssh_keys:
112 authorized_key = " ".join([ssh_key, comment])
113 authorized_keys.append(authorized_key)
114- ssh_path = os.path.join(HOME_DIR_PATH, user.name, ".ssh")
115- authorized_keys_path = os.path.join(ssh_path, "authorized_keys")
116
117- if not os.path.exists(ssh_path):
118- os.makedirs(ssh_path, mode=0o700)
119- os.chmod(ssh_path, 0o700)
120- shutil.chown(ssh_path, user=user.name, group=user.name)
121+ authorized_keys_path = _substitute_path_vars_for_user(
122+ Path(authorized_keys_path), user.name
123+ )
124+ ssh_path = authorized_keys_path.parent
125+ ssh_path_under_user_home = _path_under_user_home(ssh_path, user.name)
126+
127+ ssh_path.mkdir(parents=True, exist_ok=True)
128+
129+ if ssh_path_under_user_home:
130+ ssh_path.chmod(mode=0o700)
131+ shutil.chown(ssh_path, user=user.name, group=user.name)
132
133 # get currently configured keys
134 current_keys = []
135
136- if os.path.exists(authorized_keys_path):
137- with open(authorized_keys_path, "r") as keys_file:
138- current_keys = keys_file.readlines()
139- keys_file.close()
140+ if authorized_keys_path.exists():
141+ current_keys = authorized_keys_path.read_text().splitlines()
142
143 # keep the non-managed keys
144 regex = re.compile(r"charm-local-users")
145@@ -152,16 +177,18 @@ def set_ssh_authorized_keys(user):
146
147 for authorized_key in authorized_keys:
148 new_keys.append(authorized_key + "\n")
149- with open(authorized_keys_path, "w+") as keys_file:
150+ with authorized_keys_path.open("w+") as keys_file:
151 for key in new_keys:
152 keys_file.write(key)
153- keys_file.close()
154
155 # ensure correct permissions
156
157- if os.path.exists(authorized_keys_path):
158- os.chmod(authorized_keys_path, 0o600)
159- shutil.chown(authorized_keys_path, user=user.name, group=user.name)
160+ if authorized_keys_path.exists():
161+ if ssh_path_under_user_home:
162+ authorized_keys_path.chmod(mode=0o600)
163+ shutil.chown(authorized_keys_path, user=user.name, group=user.name)
164+ else:
165+ authorized_keys_path.chmod(mode=0o644)
166
167
168 def get_gecos(username):
169diff --git a/src/charm.py b/src/charm.py
170index 933a225..2b79caa 100755
171--- a/src/charm.py
172+++ b/src/charm.py
173@@ -151,8 +151,9 @@ class CharmLocalUsersCharm(CharmBase):
174 delete_user(u, backup_path)
175
176 # configure user accounts specified in the config
177+ authorized_keys_path = self.config["ssh-authorized-keys"]
178 for user in userlist:
179- configure_user(user, group)
180+ configure_user(user, group, authorized_keys_path)
181
182 # Configure custom /etc/sudoers.d file
183 sudoers = self.config["sudoers"]
184diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt
185new file mode 100644
186index 0000000..ea401e0
187--- /dev/null
188+++ b/tests/functional/requirements.txt
189@@ -0,0 +1,3 @@
190+git+https://github.com/openstack-charmers/zaza.git#egg=zaza
191+cryptography
192+python-openstackclient
193\ No newline at end of file
194diff --git a/tests/functional/tests/bundles/base.yaml b/tests/functional/tests/bundles/base.yaml
195new file mode 100644
196index 0000000..86e83a1
197--- /dev/null
198+++ b/tests/functional/tests/bundles/base.yaml
199@@ -0,0 +1,6 @@
200+applications:
201+ ubuntu:
202+ charm: ubuntu
203+ num_units: 1
204+relations:
205+ - [ "ubuntu", "local-users" ]
206diff --git a/tests/functional/tests/bundles/bionic.yaml b/tests/functional/tests/bundles/bionic.yaml
207new file mode 120000
208index 0000000..f81f6ff
209--- /dev/null
210+++ b/tests/functional/tests/bundles/bionic.yaml
211@@ -0,0 +1 @@
212+base.yaml
213\ No newline at end of file
214diff --git a/tests/functional/tests/bundles/focal.yaml b/tests/functional/tests/bundles/focal.yaml
215new file mode 120000
216index 0000000..f81f6ff
217--- /dev/null
218+++ b/tests/functional/tests/bundles/focal.yaml
219@@ -0,0 +1 @@
220+base.yaml
221\ No newline at end of file
222diff --git a/tests/functional/tests/bundles/jammy.yaml b/tests/functional/tests/bundles/jammy.yaml
223new file mode 120000
224index 0000000..f81f6ff
225--- /dev/null
226+++ b/tests/functional/tests/bundles/jammy.yaml
227@@ -0,0 +1 @@
228+base.yaml
229\ No newline at end of file
230diff --git a/tests/functional/tests/bundles/overlays/bionic.yaml.j2 b/tests/functional/tests/bundles/overlays/bionic.yaml.j2
231new file mode 100644
232index 0000000..04930d7
233--- /dev/null
234+++ b/tests/functional/tests/bundles/overlays/bionic.yaml.j2
235@@ -0,0 +1 @@
236+series: bionic
237\ No newline at end of file
238diff --git a/tests/functional/tests/bundles/overlays/focal.yaml.j2 b/tests/functional/tests/bundles/overlays/focal.yaml.j2
239new file mode 100644
240index 0000000..1bdf16f
241--- /dev/null
242+++ b/tests/functional/tests/bundles/overlays/focal.yaml.j2
243@@ -0,0 +1 @@
244+series: focal
245\ No newline at end of file
246diff --git a/tests/functional/tests/bundles/overlays/jammy.yaml.j2 b/tests/functional/tests/bundles/overlays/jammy.yaml.j2
247new file mode 100644
248index 0000000..61d74ad
249--- /dev/null
250+++ b/tests/functional/tests/bundles/overlays/jammy.yaml.j2
251@@ -0,0 +1 @@
252+series: jammy
253\ No newline at end of file
254diff --git a/tests/functional/tests/bundles/overlays/local-charm-overlay.yaml.j2 b/tests/functional/tests/bundles/overlays/local-charm-overlay.yaml.j2
255new file mode 100644
256index 0000000..45065bc
257--- /dev/null
258+++ b/tests/functional/tests/bundles/overlays/local-charm-overlay.yaml.j2
259@@ -0,0 +1,3 @@
260+applications:
261+ {{ charm_name }}:
262+ charm: "{{ CHARM_LOCATION }}/{{ charm_name }}.charm"
263diff --git a/tests/functional/tests/modules/__init__.py b/tests/functional/tests/modules/__init__.py
264new file mode 100644
265index 0000000..e69de29
266--- /dev/null
267+++ b/tests/functional/tests/modules/__init__.py
268diff --git a/tests/functional/tests/modules/utils.py b/tests/functional/tests/modules/utils.py
269new file mode 100644
270index 0000000..d1fd713
271--- /dev/null
272+++ b/tests/functional/tests/modules/utils.py
273@@ -0,0 +1,37 @@
274+"""Utilities to be used during charm-local-users functests."""
275+import logging
276+
277+from cryptography.hazmat.primitives.asymmetric import rsa
278+from cryptography.hazmat.primitives import serialization
279+
280+logger = logging.getLogger(__name__)
281+
282+
283+def generate_keypair():
284+ """Generate a public/private keypair."""
285+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
286+ public_key = private_key.public_key()
287+
288+ private_key_string = (
289+ private_key.private_bytes(
290+ encoding=serialization.Encoding.PEM,
291+ format=serialization.PrivateFormat.TraditionalOpenSSL,
292+ encryption_algorithm=serialization.NoEncryption(),
293+ )
294+ .decode()
295+ .strip()
296+ )
297+
298+ public_key_string = (
299+ public_key.public_bytes(
300+ encoding=serialization.Encoding.OpenSSH,
301+ format=serialization.PublicFormat.OpenSSH,
302+ )
303+ .decode()
304+ .strip()
305+ )
306+
307+ logger.info(f"Generated public key:\n{public_key_string}")
308+ logger.info(f"Generated private key:\n{private_key_string}")
309+
310+ return public_key_string, private_key_string
311diff --git a/tests/functional/tests/test_local_users.py b/tests/functional/tests/test_local_users.py
312new file mode 100644
313index 0000000..9ce090b
314--- /dev/null
315+++ b/tests/functional/tests/test_local_users.py
316@@ -0,0 +1,89 @@
317+"""Functional tests for charm-local-users."""
318+from subprocess import check_output
319+from tempfile import NamedTemporaryFile
320+import unittest
321+
322+import zaza
323+
324+from tests.modules.utils import generate_keypair
325+
326+
327+class TestLocalUsers(unittest.TestCase):
328+ """Tests related to the local-users charm."""
329+
330+ @classmethod
331+ def setUpClass(cls):
332+ """Test setup."""
333+ cls.app_name = "local-users"
334+ cls.principal_app_name = "ubuntu"
335+ cls.principal_unit_name = cls.principal_app_name + "/0"
336+ cls.ssh_pub_key, cls.ssh_priv_key = generate_keypair()
337+
338+ def wait_for_application_states(self):
339+ """Wait for application states are ready.
340+
341+ zaza.model.block_until_all_applications_idle() seems to not wait for
342+ (config-changed) to settle. Causing a race.
343+ """
344+ zaza.model.wait_for_application_states(
345+ states={
346+ self.app_name: {
347+ "workload-status": "active",
348+ "workload-status-message-regex": "^$",
349+ },
350+ self.principal_app_name: {
351+ "workload-status": "active",
352+ "workload-status-message-regex": "^$",
353+ },
354+ },
355+ )
356+
357+ def test_10_default_ssh_login(self):
358+ """Test the default ssh login functionality.
359+
360+ The path of the authorized_keys file can be configured,
361+ we want the default configuration to allow logins
362+ via standard paths.
363+ """
364+
365+ zaza.model.set_application_config(
366+ self.app_name, {"users": f"testuser;Test User;{self.ssh_pub_key}"}
367+ )
368+ self.wait_for_application_states()
369+
370+ with NamedTemporaryFile(
371+ mode="w"
372+ ) as private_key_file, NamedTemporaryFile() as known_hosts:
373+ private_key_file.write(self.ssh_priv_key)
374+ private_key_file.flush()
375+
376+ cmd = [
377+ "ssh",
378+ "-o",
379+ "StrictHostKeyChecking=no",
380+ "-o",
381+ f"UserKnownHostsFile={known_hosts.name}",
382+ "-i",
383+ private_key_file.name,
384+ "-l",
385+ "testuser",
386+ zaza.model.get_app_ips(self.principal_app_name)[0],
387+ "whoami",
388+ ]
389+ stdout = check_output(cmd).decode().strip()
390+ self.assertEqual(stdout, "testuser")
391+
392+ def test_11_ssh_custom_authorized_keys_file(self):
393+ """Test the ssh-authorized-keys config option."""
394+ zaza.model.set_application_config(
395+ self.app_name,
396+ {"ssh-authorized-keys": "/etc/ssh/user-authorized-keys/$USER"},
397+ )
398+ self.wait_for_application_states()
399+
400+ expected_ssh_pub_key = self.ssh_pub_key + " # charm-local-users\n"
401+ zaza.model.block_until_file_has_contents(
402+ application_name=self.principal_app_name,
403+ remote_file="/etc/ssh/user-authorized-keys/testuser",
404+ expected_contents=expected_ssh_pub_key,
405+ )
406diff --git a/tests/functional/tests/tests.yaml b/tests/functional/tests/tests.yaml
407new file mode 100644
408index 0000000..6f8af3e
409--- /dev/null
410+++ b/tests/functional/tests/tests.yaml
411@@ -0,0 +1,16 @@
412+charm_name: local-users
413+gate_bundles:
414+ - jammy
415+ - focal
416+ - bionic
417+dev_bundles:
418+ - jammy
419+tests:
420+ - tests.test_local_users.TestLocalUsers
421+target_deploy_status:
422+ local-users:
423+ workload-status: active
424+ workload-status-message-regex: "^$"
425+ ubuntu:
426+ workload-status: active
427+ workload-status-message-regex: "^$"
428\ No newline at end of file
429diff --git a/tests/unit/test_local_users.py b/tests/unit/test_local_users.py
430index 6f587b5..1f6a545 100644
431--- a/tests/unit/test_local_users.py
432+++ b/tests/unit/test_local_users.py
433@@ -13,18 +13,68 @@
434 # limitations under the License.
435
436 import os
437+import pwd
438 import unittest
439 from collections import namedtuple
440+from pathlib import Path
441 from tempfile import TemporaryDirectory
442-from unittest.mock import patch
443+from unittest.mock import call, patch
444
445 from lib import local_users
446
447
448 class TestLocalUsers(unittest.TestCase):
449+ @patch("pwd.getpwnam")
450+ def test_substitute_path_vars_for_user(self, mock_getpwnam):
451+ mock_getpwnam.return_value = pwd.struct_passwd(
452+ ("testuser", "x", 99999, 99999, "Test User", "/home/testuser", "/bin/bash")
453+ )
454+ self.assertEqual(
455+ local_users._substitute_path_vars_for_user(
456+ path=Path("/etc/ssh/user-authorized-keys/$USER"), username="testuser"
457+ ),
458+ Path("/etc/ssh/user-authorized-keys/testuser"),
459+ )
460+ self.assertEqual(
461+ local_users._substitute_path_vars_for_user(
462+ path=Path("/etc/ssh/user-authorized-keys/$UID"), username="testuser"
463+ ),
464+ Path("/etc/ssh/user-authorized-keys/99999"),
465+ )
466+ self.assertEqual(
467+ local_users._substitute_path_vars_for_user(
468+ path=Path("$HOME/.ssh/authorized_keys"), username="testuser"
469+ ),
470+ Path("/home/testuser/.ssh/authorized_keys"),
471+ )
472+
473+ @patch("pwd.getpwnam")
474+ def test_path_under_user_home(self, mock_getpwnam):
475+ mock_getpwnam.return_value = pwd.struct_passwd(
476+ ("testuser", "x", 99999, 99999, "Test User", "/home/testuser", "/bin/bash")
477+ )
478+ self.assertTrue(
479+ local_users._path_under_user_home(
480+ path=Path("/home/testuser/.ssh"), username="testuser"
481+ )
482+ )
483+ self.assertFalse(
484+ local_users._path_under_user_home(
485+ path=Path("/etc/ssh"), username="testuser"
486+ )
487+ )
488+ self.assertFalse(
489+ local_users._path_under_user_home(
490+ path=Path("/var/home/testuser/.ssh"), username="testuser"
491+ )
492+ )
493+
494 @patch("shutil.chown")
495- @patch("os.chmod")
496- def test_set_ssh_authorized_keys_update(self, *args, **kwargs):
497+ @patch("pathlib.Path.chmod")
498+ @patch("pwd.getpwnam")
499+ def test_set_ssh_authorized_keys_update(
500+ self, mock_getpwnam, mock_chmod, *args, **kwargs
501+ ):
502 testuser = local_users.User(
503 "testuser", ["Test User", "", "", "", ""], ["ssh-rsa ABC testuser@testhost"]
504 )
505@@ -34,27 +84,73 @@ class TestLocalUsers(unittest.TestCase):
506 )
507
508 with TemporaryDirectory() as fake_home:
509+ mock_getpwnam.return_value = pwd.struct_passwd(
510+ ("testuser", "x", 99999, 99999, "Test User", fake_home, "/bin/bash")
511+ )
512+
513+ testfile_path = os.path.join(fake_home, ".ssh", "authorized_keys")
514+ local_users.set_ssh_authorized_keys(testuser, "$HOME/.ssh/authorized_keys")
515+ with open(testfile_path, "r") as f:
516+ keys = f.readlines()
517+ self.assertIn(
518+ "ssh-rsa ABC testuser@testhost # charm-local-users\n", keys
519+ )
520+
521+ mock_chmod.assert_has_calls(
522+ [
523+ call(mode=0o700), # .ssh directory
524+ call(mode=0o600), # auth_keys file
525+ ]
526+ )
527+ mock_chmod.reset_mock()
528+
529+ # update the key
530+ local_users.set_ssh_authorized_keys(testuser2, "$HOME/.ssh/authorized_keys")
531+ with open(testfile_path, "r") as f:
532+ keys = f.readlines()
533+ self.assertIn(
534+ "ssh-rsa XYZ testuser@testhost # charm-local-users\n", keys
535+ )
536+ self.assertNotIn(
537+ "ssh-rsa ABC testuser@testhost # charm-local-users\n", keys
538+ )
539+
540+ mock_chmod.assert_has_calls([call(mode=0o700), call(mode=0o600)])
541+
542+ @patch("pathlib.Path.chmod")
543+ @patch("pwd.getpwnam")
544+ def test_set_ssh_authorized_keys_in_etc(
545+ self, mock_getpwnam, mock_chmod, *args, **kwargs
546+ ):
547+ testuser = local_users.User(
548+ "testuser", ["Test User", "", "", "", ""], ["ssh-rsa ABC testuser@testhost"]
549+ )
550+
551+ with TemporaryDirectory() as fake_etc:
552+ mock_getpwnam.return_value = pwd.struct_passwd(
553+ (
554+ "testuser",
555+ "x",
556+ 99999,
557+ 99999,
558+ "Test User",
559+ "/home/testuser",
560+ "/bin/bash",
561+ )
562+ )
563+
564 testfile_path = os.path.join(
565- fake_home, "testuser", ".ssh", "authorized_keys"
566+ fake_etc, "ssh", "user-authorized-keys", "testuser"
567+ )
568+ local_users.set_ssh_authorized_keys(
569+ testuser, f"{fake_etc}/ssh/user-authorized-keys/$USER"
570 )
571- with patch("lib.local_users.HOME_DIR_PATH", fake_home):
572- local_users.set_ssh_authorized_keys(testuser)
573- with open(testfile_path, "r") as f:
574- keys = f.readlines()
575- self.assertIn(
576- "ssh-rsa ABC testuser@testhost # charm-local-users\n", keys
577- )
578-
579- # update the key
580- local_users.set_ssh_authorized_keys(testuser2)
581- with open(testfile_path, "r") as f:
582- keys = f.readlines()
583- self.assertIn(
584- "ssh-rsa XYZ testuser@testhost # charm-local-users\n", keys
585- )
586- self.assertNotIn(
587- "ssh-rsa ABC testuser@testhost # charm-local-users\n", keys
588- )
589+ with open(testfile_path, "r") as f:
590+ keys = f.readlines()
591+ self.assertIn(
592+ "ssh-rsa ABC testuser@testhost # charm-local-users\n", keys
593+ )
594+ mock_chmod.assert_called_once_with(mode=0o644)
595
596 def test_parse_gecos(self):
597 test_cases = [
598diff --git a/tox.ini b/tox.ini
599index 9dc2441..0b80e54 100644
600--- a/tox.ini
601+++ b/tox.ini
602@@ -25,8 +25,8 @@ commands = charmcraft build
603
604 [testenv:lint]
605 commands =
606- flake8 {posargs} src tests lib
607- black --check --exclude "/(\.eggs|\.git|\.tox|\.venv|\.build|build|dist|charmhelpers|mod)/" .
608+ flake8 --config {toxinidir}/.flake8 {posargs} src tests lib
609+ black --check --diff --color --exclude "/(\.eggs|\.git|\.tox|\.venv|\.build|build|dist|charmhelpers|mod)/" .
610 deps =
611 black
612 flake8
613@@ -43,8 +43,8 @@ deps =
614
615 [testenv:unit]
616 commands =
617- coverage run --source=src -m unittest discover -s {toxinidir}/tests/unit -v
618- coverage report --omit tests/*,mod/*,.tox/*
619+ coverage run --source=src,lib -m unittest discover -s {toxinidir}/tests/unit -v
620+ coverage report -m --omit tests/*,mod/*,.tox/*
621 coverage html --omit tests/*,mod/*,.tox/*
622 deps = -r{toxinidir}/tests/unit/requirements.txt
623 -r{toxinidir}/requirements.txt

Subscribers

People subscribed via source and target branches

to all changes: