Merge ~mertkirpici/charm-local-users:lp/1983437 into charm-local-users:main
- Git
- lp:~mertkirpici/charm-local-users
- lp/1983437
- Merge into main
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) |
Related bugs: |
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.
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
FAILED: Continuous integration, rev:b6695bb22c9
https:/
Executed test runs:
FAILURE: https:/
None: https:/
Click here to trigger a rebuild:
https:/
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
PASSED: Continuous integration, rev:e82f5cc0ee0
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
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.
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
PASSED: Continuous integration, rev:e82f5cc0ee0
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
Gabriel Cocenza (gabrielcocenza) : | # |
Mert Kirpici (mertkirpici) wrote : | # |
Hey Gabriel thanks for the review! I pushed an update and wrote some answers to comments inline.
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
FAILED: Continuous integration, rev:a20398312c5
https:/
Executed test runs:
FAILURE: https:/
None: https:/
Click here to trigger a rebuild:
https:/
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
PASSED: Continuous integration, rev:a20398312c5
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
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.
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
PASSED: Continuous integration, rev:1a33ac7031a
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
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
Gabriel Cocenza (gabrielcocenza) wrote : | # |
Thanks Mert. LGTM
Lefty two nit comments in the unit tests
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!
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
PASSED: Continuous integration, rev:9f62485be13
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
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
Eric Chen (eric-chen) : | # |
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
Change successfully merged at revision fdc2f14b7406486
Preview Diff
1 | diff --git a/Makefile b/Makefile |
2 | index 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 | |
18 | diff --git a/config.yaml b/config.yaml |
19 | index 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 |
36 | diff --git a/lib/local_users.py b/lib/local_users.py |
37 | index 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): |
169 | diff --git a/src/charm.py b/src/charm.py |
170 | index 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"] |
184 | diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt |
185 | new file mode 100644 |
186 | index 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 |
194 | diff --git a/tests/functional/tests/bundles/base.yaml b/tests/functional/tests/bundles/base.yaml |
195 | new file mode 100644 |
196 | index 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" ] |
206 | diff --git a/tests/functional/tests/bundles/bionic.yaml b/tests/functional/tests/bundles/bionic.yaml |
207 | new file mode 120000 |
208 | index 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 |
214 | diff --git a/tests/functional/tests/bundles/focal.yaml b/tests/functional/tests/bundles/focal.yaml |
215 | new file mode 120000 |
216 | index 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 |
222 | diff --git a/tests/functional/tests/bundles/jammy.yaml b/tests/functional/tests/bundles/jammy.yaml |
223 | new file mode 120000 |
224 | index 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 |
230 | diff --git a/tests/functional/tests/bundles/overlays/bionic.yaml.j2 b/tests/functional/tests/bundles/overlays/bionic.yaml.j2 |
231 | new file mode 100644 |
232 | index 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 |
238 | diff --git a/tests/functional/tests/bundles/overlays/focal.yaml.j2 b/tests/functional/tests/bundles/overlays/focal.yaml.j2 |
239 | new file mode 100644 |
240 | index 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 |
246 | diff --git a/tests/functional/tests/bundles/overlays/jammy.yaml.j2 b/tests/functional/tests/bundles/overlays/jammy.yaml.j2 |
247 | new file mode 100644 |
248 | index 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 |
254 | diff --git a/tests/functional/tests/bundles/overlays/local-charm-overlay.yaml.j2 b/tests/functional/tests/bundles/overlays/local-charm-overlay.yaml.j2 |
255 | new file mode 100644 |
256 | index 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" |
263 | diff --git a/tests/functional/tests/modules/__init__.py b/tests/functional/tests/modules/__init__.py |
264 | new file mode 100644 |
265 | index 0000000..e69de29 |
266 | --- /dev/null |
267 | +++ b/tests/functional/tests/modules/__init__.py |
268 | diff --git a/tests/functional/tests/modules/utils.py b/tests/functional/tests/modules/utils.py |
269 | new file mode 100644 |
270 | index 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 |
311 | diff --git a/tests/functional/tests/test_local_users.py b/tests/functional/tests/test_local_users.py |
312 | new file mode 100644 |
313 | index 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 | + ) |
406 | diff --git a/tests/functional/tests/tests.yaml b/tests/functional/tests/tests.yaml |
407 | new file mode 100644 |
408 | index 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 |
429 | diff --git a/tests/unit/test_local_users.py b/tests/unit/test_local_users.py |
430 | index 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 = [ |
598 | diff --git a/tox.ini b/tox.ini |
599 | index 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 |
This merge proposal is being monitored by mergebot. Change the status to Approved to merge.