Skip to content

Commit 5386621

Browse files
authored
Add docker utility module (#5002)
This PR introduces the `casp.utils.docker_utils` module, designed to handle docker commands for the upcoming init command. Key Changes: - Docker Setup Verification: The `check_docker_setup` function ensures that Docker is installed, running, and that the current user has the necessary permissions to interact with the Docker daemon. - Docker Image Pulling: The `pull_image` function handles the pulling of the ClusterFuzz Docker image. - Unit Testing.
1 parent 117a70f commit 5386621

File tree

3 files changed

+256
-0
lines changed

3 files changed

+256
-0
lines changed

cli/casp/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ classifiers = [
1818
]
1919
dependencies = [
2020
"click",
21+
"docker"
2122
]
2223

2324
[project.urls]
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Tests for docker utility functions.
15+
16+
For running, use (from the root of the project):
17+
python -m unittest discover -s cli/casp/src/casp/tests -p test_docker.py -v
18+
"""
19+
20+
import os
21+
import unittest
22+
from unittest.mock import call
23+
from unittest.mock import create_autospec
24+
from unittest.mock import patch
25+
26+
from casp.utils import docker_utils
27+
28+
import docker
29+
30+
31+
class CheckDockerSetupTest(unittest.TestCase):
32+
"""Tests for check_docker_setup."""
33+
34+
@patch('docker.from_env', autospec=True)
35+
@patch('click.secho', autospec=True)
36+
@patch('click.echo', autospec=True)
37+
def test_docker_setup_ok(self, mock_echo, mock_secho, mock_from_env):
38+
"""Tests when Docker is setup correctly."""
39+
mock_client = create_autospec(
40+
docker.DockerClient, instance=True, spec_set=True)
41+
mock_from_env.return_value = mock_client
42+
mock_client.ping.return_value = True
43+
44+
client = docker_utils.check_docker_setup()
45+
46+
self.assertIsNotNone(client)
47+
self.assertEqual(client, mock_client)
48+
mock_from_env.assert_called_once()
49+
mock_client.ping.assert_called_once()
50+
mock_echo.assert_not_called()
51+
mock_secho.assert_not_called()
52+
53+
@patch.dict(os.environ, {'USER': 'testuser'}, clear=True)
54+
@patch('docker.from_env', autospec=True)
55+
@patch('click.secho', autospec=True)
56+
@patch('click.echo', autospec=True)
57+
def test_docker_permission_denied(self, mock_echo, mock_secho, mock_from_env):
58+
"""Tests when DockerException is raised due to permission issues."""
59+
mock_from_env.side_effect = docker.errors.DockerException(
60+
"Permission denied while connecting to the Docker daemon")
61+
62+
client = docker_utils.check_docker_setup()
63+
64+
self.assertIsNone(client)
65+
mock_from_env.assert_called_once()
66+
mock_secho.assert_has_calls([
67+
call(
68+
'Error: Permission denied while connecting to the Docker daemon.',
69+
fg='red'),
70+
call(' sudo usermod -aG docker $testuser', fg='yellow')
71+
])
72+
mock_echo.assert_has_calls([
73+
call('Please add your user to the "docker" group by running:'),
74+
call('Then, log out and log back in for the change to take effect.')
75+
])
76+
77+
@patch('docker.from_env', autospec=True)
78+
@patch('click.secho', autospec=True)
79+
@patch('click.echo', autospec=True)
80+
def test_docker_not_running(self, mock_echo, mock_secho, mock_from_env):
81+
"""Tests when DockerException is raised for other reasons."""
82+
mock_from_env.side_effect = docker.errors.DockerException("Generic Docker "
83+
"error")
84+
85+
client = docker_utils.check_docker_setup()
86+
87+
self.assertIsNone(client)
88+
mock_from_env.assert_called_once()
89+
mock_secho.assert_called_once()
90+
args, _ = mock_secho.call_args
91+
self.assertIn('Docker is not running', args[0])
92+
mock_echo.assert_not_called()
93+
94+
@patch('docker.from_env', autospec=True)
95+
@patch('click.secho', autospec=True)
96+
@patch('click.echo', autospec=True)
97+
def test_docker_ping_fails(self, mock_echo, mock_secho, mock_from_env):
98+
"""Tests when client.ping() fails by raising an exception."""
99+
mock_client = create_autospec(
100+
docker.DockerClient, instance=True, spec_set=True)
101+
mock_from_env.return_value = mock_client
102+
mock_client.ping.side_effect = docker.errors.DockerException("Ping failed")
103+
104+
client = docker_utils.check_docker_setup()
105+
106+
self.assertIsNone(client)
107+
mock_from_env.assert_called_once()
108+
mock_client.ping.assert_called_once()
109+
mock_secho.assert_called_once()
110+
args, _ = mock_secho.call_args
111+
self.assertIn('Docker is not running', args[0])
112+
mock_echo.assert_not_called()
113+
114+
115+
class PullImageTest(unittest.TestCase):
116+
"""Tests for pull_image."""
117+
118+
@patch('casp.utils.docker_utils.check_docker_setup', autospec=True)
119+
@patch('click.secho', autospec=True)
120+
@patch('click.echo', autospec=True)
121+
def test_pull_image_success(self, mock_echo, mock_secho,
122+
mock_check_docker_setup):
123+
"""Tests successful image pull."""
124+
mock_client = create_autospec(
125+
docker.DockerClient, instance=True, spec_set=True)
126+
mock_check_docker_setup.return_value = mock_client
127+
128+
mock_images_collection = create_autospec(
129+
docker.models.images.ImageCollection, instance=True, spec_set=True)
130+
mock_client.images = mock_images_collection
131+
132+
result = docker_utils.pull_image()
133+
134+
self.assertTrue(result)
135+
mock_echo.assert_called_once()
136+
args, _ = mock_echo.call_args
137+
self.assertIn('Pulling Docker image:', args[0])
138+
mock_check_docker_setup.assert_called_once()
139+
mock_images_collection.pull.assert_called_once_with(
140+
docker_utils.DOCKER_IMAGE)
141+
mock_secho.assert_not_called()
142+
143+
@patch(
144+
'casp.utils.docker_utils.check_docker_setup',
145+
return_value=None,
146+
autospec=True)
147+
@patch('click.secho', autospec=True)
148+
@patch('click.echo', autospec=True)
149+
def test_pull_image_docker_setup_fails(self, mock_echo, mock_secho,
150+
mock_check_docker_setup):
151+
"""Tests when check_docker_setup returns None."""
152+
result = docker_utils.pull_image()
153+
154+
self.assertFalse(result)
155+
mock_check_docker_setup.assert_called_once()
156+
mock_echo.assert_not_called()
157+
mock_secho.assert_not_called()
158+
159+
@patch('casp.utils.docker_utils.check_docker_setup', autospec=True)
160+
@patch('click.secho', autospec=True)
161+
@patch('click.echo', autospec=True)
162+
def test_pull_image_not_found(self, mock_echo, mock_secho,
163+
mock_check_docker_setup):
164+
"""Tests when the image pull raises DockerException."""
165+
mock_client = create_autospec(
166+
docker.DockerClient, instance=True, spec_set=True)
167+
mock_check_docker_setup.return_value = mock_client
168+
169+
mock_images_collection = create_autospec(
170+
docker.models.images.ImageCollection, instance=True, spec_set=True)
171+
mock_client.images = mock_images_collection
172+
mock_images_collection.pull.side_effect = docker.errors.DockerException(
173+
"Image not found")
174+
175+
result = docker_utils.pull_image()
176+
177+
self.assertFalse(result)
178+
mock_echo.assert_called_once_with(
179+
f'Pulling Docker image: {docker_utils.DOCKER_IMAGE}...')
180+
mock_check_docker_setup.assert_called_once()
181+
mock_images_collection.pull.assert_called_once_with(
182+
docker_utils.DOCKER_IMAGE)
183+
mock_secho.assert_called_once_with(
184+
f'Error: Docker image {docker_utils.DOCKER_IMAGE} not found.', fg='red')
185+
186+
187+
if __name__ == '__main__':
188+
unittest.main()
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Docker utility functions."""
15+
16+
import os
17+
18+
import click
19+
20+
import docker
21+
22+
# TODO: Make this configurable.
23+
DOCKER_IMAGE = ("gcr.io/clusterfuzz-images/chromium/base/immutable/dev:"
24+
"20251008165901-utc-893e97e-640142509185-compute-d609115-prod")
25+
26+
27+
def check_docker_setup() -> docker.client.DockerClient | None:
28+
"""Checks if Docker is installed, running, and has correct permissions.
29+
30+
Returns:
31+
A docker.client object if setup is correct, None otherwise.
32+
"""
33+
try:
34+
client = docker.from_env()
35+
client.ping()
36+
return client
37+
except docker.errors.DockerException as e:
38+
if 'Permission denied' in str(e):
39+
click.secho(
40+
'Error: Permission denied while connecting to the Docker daemon.',
41+
fg='red')
42+
click.echo('Please add your user to the "docker" group by running:')
43+
click.secho(
44+
f' sudo usermod -aG docker ${os.environ.get("USER")}', fg='yellow')
45+
click.echo('Then, log out and log back in for the change to take effect.')
46+
else:
47+
click.secho(
48+
'Error: Docker is not running or is not installed. Please start '
49+
'Docker and try again.'
50+
'Exception: {e}',
51+
fg='red')
52+
return None
53+
54+
55+
def pull_image() -> bool:
56+
"""Pulls the docker image."""
57+
client = check_docker_setup()
58+
if not client:
59+
return False
60+
61+
try:
62+
click.echo(f'Pulling Docker image: {DOCKER_IMAGE}...')
63+
client.images.pull(DOCKER_IMAGE)
64+
return True
65+
except docker.errors.DockerException:
66+
click.secho(f'Error: Docker image {DOCKER_IMAGE} not found.', fg='red')
67+
return False

0 commit comments

Comments
 (0)