Skip to content

Commit 582190c

Browse files
authored
[CLI] Migrate run (#5057)
### Changes This PR adds two new commands to the `casp` CLI: `run local` and ` run container`. Both of these commands run a one-off script against the datastore, with the capability to use Clusterfuzz internal code. The difference between them is **where** they run, as explained below. #### Local This version runs the script on the host machine, using the local code of clusterfuzz. Basically, users need to have a cloned and configured clusterfuzz and clusterfuzz-config repositories locally. The script they want to run must be in the path `clusterfuzz/src/local/butler/scripts`, just like the `butler.py` command. #### Container This version runs inside a Docker container. Choose an image using the `-p` argument: `internal`, `external`, or `dev`. Then, provide the path to your script, which will be mounted inside the container. ### How it works: - `casp run container <script_name> -p <project>` runs the specified script inside a Docker container (requires a project target: `dev`, `internal`, or `external`). - `casp run local <script_name> -c <config_dir>` runs the script locally on the host machine (requires the path to the application config). - Both commands default to a dry run; use `--non-dry-run` to enable actual datastore writes. - Use `--script_args <text>` to pass specific arguments to the script being executed. ### Changes in butler The only change in butler was adding a more descriptive print when there are `script_args` in the `run` call. Here are screenshots from both master and this branch showing that the behavior is the same: <img width="2276" height="942" alt="image" src="https://github.com/user-attachments/assets/40d4e873-52f8-4281-8d19-cfe5b2435c31" /> <img width="2276" height="942" alt="image" src="https://github.com/user-attachments/assets/7718269e-0aae-4695-ba30-9d5ce6ac2d2e" /> ### Demo prints Here are some screenshots of `casp run` in action: 1. Running in local mode: - `casp run local list_testcases_by_job --script_args centipede_v8_asan -c ../clusterfuzz-config/configs/internal` <img width="2276" height="942" alt="image" src="https://github.com/user-attachments/assets/1b433277-f418-40d2-92a0-0ac7d41a61b9" /> 2. Running the same script in container mode (note that the script was placed in the root directory here): - `casp run container list_testcases_by_job.py --script_args centipede_v8_asan -p internal` <img width="2276" height="942" alt="image" src="https://github.com/user-attachments/assets/c50d9406-9041-4133-9daf-f084fe4e7c4d" /> Here's the script used in the demos for reproduction purposes: ```python """One-off script that lists all testcases of a given job""" from clusterfuzz._internal.datastore import data_types from clusterfuzz._internal.datastore import ndb_utils def execute(args): """Executes the script""" try: query = ndb_utils.get_all_from_query( data_types.Testcase.query(data_types.Testcase.job_type == args.script_args[0])) print(f'Those are the testcase ids of job {args.script_args[0]}') for testcase in query: print(testcase.key.id()) except Exception as e: print(e) ```
1 parent a48edfb commit 582190c

File tree

6 files changed

+333
-34
lines changed

6 files changed

+333
-34
lines changed

butler.py

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,28 @@ def _setup_args_for_remote(parser):
9898
subparsers.add_parser('reboot', help='Reboot with `sudo reboot`.')
9999

100100

101+
def _add_run_subparser(toplevel_subparsers):
102+
"""Adds a parser for the `run` command."""
103+
parser_run = toplevel_subparsers.add_parser(
104+
'run', help='Run a one-off script against a datastore (e.g. migration).')
105+
parser_run.add_argument(
106+
'script_name',
107+
help='The script module name under `./local/butler/scripts`.')
108+
parser_run.add_argument(
109+
'--script_args',
110+
action='extend',
111+
nargs='+',
112+
help='Script specific arguments')
113+
parser_run.add_argument(
114+
'--non-dry-run',
115+
action='store_true',
116+
help='Run with actual datastore writes. Default to dry-run.')
117+
parser_run.add_argument(
118+
'-c', '--config-dir', required=True, help='Path to application config.')
119+
parser_run.add_argument(
120+
'--local', action='store_true', help='Run against local server instance.')
121+
122+
101123
def _add_package_subparser(toplevel_subparsers):
102124
"""Adds a parser for the `package` command."""
103125
parser_package = toplevel_subparsers.add_parser(
@@ -391,25 +413,6 @@ def main():
391413
parser_run_server.add_argument(
392414
'--clean', action='store_true', help='Clear existing database data.')
393415

394-
parser_run = subparsers.add_parser(
395-
'run', help='Run a one-off script against a datastore (e.g. migration).')
396-
parser_run.add_argument(
397-
'script_name',
398-
help='The script module name under `./local/butler/scripts`.')
399-
parser_run.add_argument(
400-
'--script_args',
401-
action='extend',
402-
nargs='+',
403-
help='Script specific arguments')
404-
parser_run.add_argument(
405-
'--non-dry-run',
406-
action='store_true',
407-
help='Run with actual datastore writes. Default to dry-run.')
408-
parser_run.add_argument(
409-
'-c', '--config-dir', required=True, help='Path to application config.')
410-
parser_run.add_argument(
411-
'--local', action='store_true', help='Run against local server instance.')
412-
413416
parser_run_bot = subparsers.add_parser(
414417
'run_bot', help='Run a local clusterfuzz bot.')
415418
parser_run_bot.add_argument(
@@ -460,6 +463,7 @@ def main():
460463
default='us-central',
461464
help='Location for App Engine.')
462465

466+
_add_run_subparser(subparsers)
463467
_add_package_subparser(subparsers)
464468
_add_bootstrap_subparser(subparsers)
465469
_add_py_unittest_subparser(subparsers)

cli/casp/src/casp/commands/run.py

Lines changed: 149 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,163 @@
77
# http://www.apache.org/licenses/LICENSE-2.0
88
#
99
# Unless required by applicable law or agreed to in writing, software
10-
# distributed under the License is is "AS IS" BASIS,
10+
# distributed under the License is distributed on an "AS IS" BASIS,
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
"""Run command."""
1515

16+
from pathlib import Path
17+
import subprocess
18+
import sys
19+
20+
from casp.utils import config
21+
from casp.utils import container
22+
from casp.utils import docker_utils
23+
from casp.utils import local_butler
1624
import click
1725

1826

19-
@click.command(
27+
def common_options(func):
28+
"""Decorator to add common options to both
29+
local and container modes
30+
"""
31+
func = click.option(
32+
'--local',
33+
is_flag=True,
34+
default=False,
35+
help='Run against local server instance.')(
36+
func)
37+
func = click.option(
38+
'--non-dry-run',
39+
is_flag=True,
40+
default=False,
41+
help=('Run with actual datastore writes. '
42+
'Default to dry-run. The dry-run behavior needs to be implemented'
43+
' by the script itself, it is not enforced globally'))(
44+
func)
45+
func = click.option(
46+
'--script_args', multiple=True, help='Script specific arguments')(
47+
func)
48+
func = click.argument('script_name')(func)
49+
return func
50+
51+
52+
def _prepare_butler_run_args(non_dry_run: bool,
53+
local: bool,
54+
config_dir: str | None = None):
55+
"""Prepares common butler run arguments."""
56+
butler_args = {}
57+
if non_dry_run:
58+
butler_args['non_dry_run'] = None
59+
if local:
60+
butler_args['local'] = None
61+
if config_dir:
62+
butler_args['config_dir'] = config_dir
63+
64+
return butler_args
65+
66+
67+
def _get_script_args_list(script_args: list[str]) -> list[str]:
68+
"""Gets the script args list and return it with the correct format.
69+
(e.g) [arg1, arg2] -> --script_args=arg1 --script_args=arg2
70+
"""
71+
script_args_list = []
72+
for arg in script_args:
73+
script_args_list.append(f'--script_args={arg}')
74+
return script_args_list
75+
76+
77+
@click.group(
2078
name='run',
21-
help='Run a one-off script against a datastore (e.g. migration).')
79+
help=('Run a one-off script against a datastore (e.g. migration). '
80+
'If running locally, the script must be in path '
81+
'clusterfuzz/src/local/butler/scripts.'))
2282
def cli():
2383
"""Run a one-off script against a datastore (e.g. migration)."""
24-
click.echo('To be implemented...')
84+
85+
86+
@cli.command(name='local', help='Run the script locally (on the host machine).')
87+
@common_options
88+
@click.option(
89+
'--config-dir',
90+
'-c',
91+
required=True,
92+
type=click.Path(exists=True),
93+
help='Path to application config.')
94+
def local_cmd(script_name: str, script_args: list[str] | None,
95+
non_dry_run: bool, local: bool, config_dir: str):
96+
"""Run a one-off script locally."""
97+
try:
98+
butler_args = _prepare_butler_run_args(non_dry_run, local, config_dir)
99+
100+
command = local_butler.build_command('run', **butler_args)
101+
102+
command.append(script_name)
103+
if script_args is not None:
104+
script_args_list = _get_script_args_list(script_args)
105+
command.extend(script_args_list)
106+
except FileNotFoundError:
107+
click.echo('butler.py not found in this directory.', err=True)
108+
sys.exit(1)
109+
110+
try:
111+
subprocess.run(command, check=True)
112+
except FileNotFoundError:
113+
click.echo('python not found in PATH.', err=True)
114+
sys.exit(1)
115+
except subprocess.CalledProcessError as e:
116+
click.echo(f'Error running butler.py run: {e}', err=True)
117+
sys.exit(1)
118+
119+
120+
@cli.command(
121+
name='container',
122+
help=('Run the script inside a Docker container. '
123+
'The SCRIPT_NAME must be the path to it.'))
124+
@common_options
125+
@click.option(
126+
'--project',
127+
'-p',
128+
help='The ClusterFuzz project to use.',
129+
required=True,
130+
type=click.Choice(
131+
docker_utils.PROJECT_TO_IMAGE.keys(), case_sensitive=False),
132+
)
133+
def container_cmd(script_name: str, script_args: list[str] | None,
134+
non_dry_run: bool, local: bool, project: str):
135+
"""Run a one-off script inside a container."""
136+
cfg = config.load_and_validate_config()
137+
138+
volumes, container_config_dir = docker_utils.prepare_docker_volumes(
139+
cfg, str(container.CONTAINER_CONFIG_PATH / 'config'))
140+
141+
if not Path(script_name).exists():
142+
click.echo(
143+
f'Script {script_name} does not exist. Please provide the path to it',
144+
err=True)
145+
sys.exit(1)
146+
147+
host_script_path = Path(script_name).resolve()
148+
container_script_path = (
149+
container.CONTAINER_SCRIPTS_DIR / host_script_path.name)
150+
docker_utils.add_volume(volumes, str(container_script_path),
151+
str(host_script_path))
152+
153+
butler_args = _prepare_butler_run_args(
154+
non_dry_run, local, config_dir=str(container_config_dir))
155+
156+
subcommand = f'run {host_script_path.stem}'
157+
if script_args:
158+
script_args_list = _get_script_args_list(script_args)
159+
subcommand += ' ' + ' '.join(script_args_list)
160+
161+
command = container.build_butler_command(subcommand, **butler_args)
162+
163+
if not docker_utils.run_command(
164+
command,
165+
volumes,
166+
privileged=True,
167+
image=docker_utils.PROJECT_TO_IMAGE[project],
168+
):
169+
sys.exit(1)
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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 the run command.
15+
16+
For running all the tests, use (from the root of the project):
17+
python -m unittest discover -s cli/casp/src/casp/tests -p run_test.py -v
18+
"""
19+
20+
from pathlib import Path
21+
import unittest
22+
from unittest.mock import patch
23+
24+
from casp.commands import run as run_command
25+
from click.testing import CliRunner
26+
27+
28+
class RunLocalCliTest(unittest.TestCase):
29+
"""Tests for the local run command."""
30+
31+
def setUp(self):
32+
self.runner = CliRunner()
33+
self.mock_local_butler = self.enterContext(
34+
patch('casp.commands.run.local_butler', autospec=True))
35+
self.mock_subprocess_run = self.enterContext(
36+
patch('subprocess.run', autospec=True))
37+
38+
def test_run_local_success_basic(self):
39+
"""Tests successful execution of `casp run local` with minimal args."""
40+
# mock build_command to return a list that we can inspect
41+
self.mock_local_butler.build_command.return_value = [
42+
'cmd', '--config-dir=test_config'
43+
]
44+
45+
with self.runner.isolated_filesystem():
46+
Path('test_config').mkdir()
47+
result = self.runner.invoke(
48+
run_command.cli,
49+
['local', 'test_script', '--config-dir', 'test_config'])
50+
self.assertEqual(0, result.exit_code, msg=result.output)
51+
52+
self.mock_local_butler.build_command.assert_called_once_with(
53+
'run', config_dir='test_config')
54+
55+
expected_cmd = ['cmd', '--config-dir=test_config', 'test_script']
56+
self.mock_subprocess_run.assert_called_once_with(expected_cmd, check=True)
57+
58+
def test_run_local_success_all_options(self):
59+
"""Tests `casp run local` with all options."""
60+
# mock build_command behavior for flags
61+
self.mock_local_butler.build_command.return_value = [
62+
'cmd', '--non-dry-run', '--local', '--config-dir=test_config'
63+
]
64+
65+
with self.runner.isolated_filesystem():
66+
Path('test_config').mkdir()
67+
result = self.runner.invoke(run_command.cli, [
68+
'local', 'test_script', '--config-dir', 'test_config',
69+
'--non-dry-run', '--local', '--script_args', 'arg1', '--script_args',
70+
'arg2'
71+
])
72+
self.assertEqual(0, result.exit_code, msg=result.output)
73+
74+
self.mock_local_butler.build_command.assert_called_once_with(
75+
'run', non_dry_run=None, local=None, config_dir='test_config')
76+
77+
expected_cmd = [
78+
'cmd', '--non-dry-run', '--local', '--config-dir=test_config',
79+
'test_script', '--script_args=arg1', '--script_args=arg2'
80+
]
81+
self.mock_subprocess_run.assert_called_once_with(expected_cmd, check=True)
82+
83+
84+
class RunContainerCliTest(unittest.TestCase):
85+
"""Tests for the container run command."""
86+
87+
def setUp(self):
88+
self.runner = CliRunner()
89+
self.mock_config = self.enterContext(
90+
patch('casp.commands.run.config', autospec=True))
91+
self.mock_container = self.enterContext(
92+
patch('casp.commands.run.container', autospec=True))
93+
self.mock_docker_utils = self.enterContext(
94+
patch('casp.commands.run.docker_utils', autospec=True))
95+
96+
# Mock config
97+
self.mock_config.load_and_validate_config.return_value = {}
98+
99+
# Mock docker utils
100+
self.mock_docker_utils.PROJECT_TO_IMAGE = {'dev': 'test-image'}
101+
self.mock_docker_utils.prepare_docker_volumes.return_value = ({
102+
'vol': 'bind'
103+
}, Path('/container/config'))
104+
self.mock_docker_utils.run_command.return_value = True
105+
106+
# Mock container
107+
self.mock_container.CONTAINER_CONFIG_PATH = Path('/data/config')
108+
self.mock_container.build_butler_command.return_value = [
109+
'bash', '-c', 'cmd'
110+
]
111+
112+
def test_run_container_success(self):
113+
"""Tests successful execution of `casp run container`."""
114+
with self.runner.isolated_filesystem():
115+
Path('test_script').touch()
116+
result = self.runner.invoke(run_command.cli, [
117+
'container', 'test_script', '--project', 'dev', '--non-dry-run',
118+
'--local', '--script_args', 'arg1', '--script_args', 'arg2'
119+
])
120+
self.assertEqual(0, result.exit_code, msg=result.output)
121+
122+
self.mock_docker_utils.prepare_docker_volumes.assert_called_once()
123+
124+
# Expect None for flags (non_dry_run, local)
125+
self.mock_container.build_butler_command.assert_called_once_with(
126+
'run test_script --script_args=arg1 --script_args=arg2',
127+
non_dry_run=None,
128+
config_dir='/container/config',
129+
local=None)
130+
self.mock_docker_utils.run_command.assert_called_once_with(
131+
['bash', '-c', 'cmd'], {'vol': 'bind'},
132+
privileged=True,
133+
image='test-image')
134+
135+
def test_run_container_fail(self):
136+
"""Tests `casp run container` when docker run fails."""
137+
self.mock_docker_utils.run_command.return_value = False
138+
result = self.runner.invoke(
139+
run_command.cli, ['container', 'test_script', '--project', 'dev'])
140+
self.assertNotEqual(0, result.exit_code)
141+
142+
143+
if __name__ == '__main__':
144+
unittest.main()

cli/casp/src/casp/utils/container.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
# This allows the container to authenticate with Google Cloud services.
2727
CONTAINER_CREDENTIALS_PATH = Path('/root/.config/gcloud/')
2828

29+
# The path to the directory containing butler scripts inside the container.
30+
CONTAINER_SCRIPTS_DIR = SRC_ROOT / 'local' / 'butler' / 'scripts'
31+
2932
# The base command prefix for executing ClusterFuzz butler commands.
3033
# This ensures that commands are run with the correct Python environment
3134
# and logging settings within the container.

0 commit comments

Comments
 (0)