Skip to content

Commit 614e2af

Browse files
Address code review feedback - improve error handling and security
Co-authored-by: transientlunatic <4365778+transientlunatic@users.noreply.github.com>
1 parent bf4eb67 commit 614e2af

File tree

2 files changed

+102
-35
lines changed

2 files changed

+102
-35
lines changed

asimov/cli/monitor.py

Lines changed: 59 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@
1313
from asimov.scheduler_utils import get_configured_scheduler, create_job_from_dict, get_job_list
1414
from asimov.monitor_helpers import monitor_analysis
1515

16+
# Try to import crontab for Slurm cron support
17+
try:
18+
from crontab import CronTab
19+
CRONTAB_AVAILABLE = True
20+
except ImportError:
21+
CRONTAB_AVAILABLE = False
22+
1623
logger = logger.getChild("cli").getChild("monitor")
1724
logger.setLevel(LOGGER_LEVEL)
1825

@@ -109,8 +116,10 @@ def _start_htcondor_monitor(dry_run, use_scheduler_api):
109116

110117
def _start_slurm_monitor():
111118
"""Start monitoring using system cron job for Slurm."""
112-
import subprocess
113-
from crontab import CronTab
119+
if not CRONTAB_AVAILABLE:
120+
# python-crontab not available, provide manual instructions
121+
_start_slurm_monitor_manual()
122+
return
114123

115124
try:
116125
minute_expression = config.get("slurm", "cron_minute")
@@ -125,7 +134,6 @@ def _start_slurm_monitor():
125134
click.secho(" \t ● Error: asimov executable not found in PATH", fg="red")
126135
return
127136

128-
# Create a cron job using python-crontab
129137
try:
130138
# Use the user's crontab
131139
cron = CronTab(user=True)
@@ -158,25 +166,39 @@ def _start_slurm_monitor():
158166
click.secho(f" \t ● Asimov is running via cron ({job_comment})", fg="green")
159167
logger.info(f"Running asimov cronjob via cron: {job_comment}")
160168

161-
except ImportError:
162-
# If python-crontab is not available, provide manual instructions
163-
click.secho(" \t ● python-crontab not installed. Setting up cron manually...", fg="yellow")
164-
165-
# Create a shell script as an alternative
166-
script_path = os.path.join(".asimov", "asimov_monitor.sh")
167-
with open(script_path, 'w') as f:
168-
f.write("#!/bin/bash\n")
169-
f.write(f"cd {project_root}\n")
170-
f.write(f"{asimov_executable} monitor --chain >> {os.path.join('.asimov', 'asimov_cron.out')} 2>> {os.path.join('.asimov', 'asimov_cron.err')}\n")
171-
172-
os.chmod(script_path, 0o755)
173-
174-
click.echo(f"\nPlease add the following line to your crontab (crontab -e):")
175-
click.echo(f"{minute_expression} * * * * {script_path}")
176-
177-
# Save a marker in the ledger
178-
ledger.data["cronjob"] = "manual-cron"
179-
ledger.save()
169+
except Exception as e:
170+
logger.error(f"Failed to create cron job: {e}")
171+
click.secho(f" \t ● Error creating cron job: {e}", fg="red")
172+
_start_slurm_monitor_manual()
173+
174+
175+
def _start_slurm_monitor_manual():
176+
"""Provide manual instructions for setting up Slurm monitoring."""
177+
try:
178+
minute_expression = config.get("slurm", "cron_minute")
179+
except (configparser.NoOptionError, configparser.NoSectionError):
180+
minute_expression = "*/15"
181+
182+
project_root = os.getcwd()
183+
asimov_executable = shutil.which("asimov") or "asimov"
184+
185+
click.secho(" \t ● python-crontab not installed. Setting up cron manually...", fg="yellow")
186+
187+
# Create a shell script as an alternative
188+
script_path = os.path.join(".asimov", "asimov_monitor.sh")
189+
with open(script_path, 'w') as f:
190+
f.write("#!/bin/bash\n")
191+
f.write(f"cd {project_root}\n")
192+
f.write(f"{asimov_executable} monitor --chain >> {os.path.join('.asimov', 'asimov_cron.out')} 2>> {os.path.join('.asimov', 'asimov_cron.err')}\n")
193+
194+
os.chmod(script_path, 0o755)
195+
196+
click.echo(f"\nPlease add the following line to your crontab (crontab -e):")
197+
click.echo(f"{minute_expression} * * * * {script_path}")
198+
199+
# Save a marker in the ledger
200+
ledger.data["cronjob"] = "manual-cron"
201+
ledger.save()
180202

181203

182204
@click.option("--dry-run", "-n", "dry_run", is_flag=True)
@@ -226,7 +248,9 @@ def _stop_htcondor_monitor(dry_run, use_scheduler_api):
226248

227249
def _stop_slurm_monitor():
228250
"""Stop monitoring by removing cron job for Slurm."""
229-
from crontab import CronTab
251+
if not CRONTAB_AVAILABLE:
252+
_stop_slurm_monitor_manual()
253+
return
230254

231255
cronjob_id = ledger.data.get("cronjob", None)
232256

@@ -235,8 +259,7 @@ def _stop_slurm_monitor():
235259
return
236260

237261
if cronjob_id == "manual-cron":
238-
click.secho(" \t ● Manual cron setup detected. Please remove from crontab manually.", fg="yellow")
239-
click.echo("Run 'crontab -e' and remove the line containing 'asimov_monitor.sh'")
262+
_stop_slurm_monitor_manual()
240263
return
241264

242265
try:
@@ -253,9 +276,17 @@ def _stop_slurm_monitor():
253276
else:
254277
click.secho(f" \t ● No cron job found with identifier: {cronjob_id}", fg="yellow")
255278

256-
except ImportError:
257-
click.secho(" \t ● python-crontab not installed. Please remove cron job manually.", fg="yellow")
258-
click.echo(f"Run 'crontab -e' and remove the line with comment: {cronjob_id}")
279+
except Exception as e:
280+
logger.error(f"Failed to remove cron job: {e}")
281+
click.secho(f" \t ● Error removing cron job: {e}", fg="red")
282+
_stop_slurm_monitor_manual()
283+
284+
285+
def _stop_slurm_monitor_manual():
286+
"""Provide manual instructions for removing Slurm monitoring."""
287+
cronjob_id = ledger.data.get("cronjob", "asimov_monitor.sh")
288+
click.secho(" \t ● Manual cron setup detected or python-crontab not installed.", fg="yellow")
289+
click.echo(f"Run 'crontab -e' and remove the line containing '{cronjob_id}'")
259290

260291

261292
@click.argument("event", default=None, required=False)

asimov/scheduler.py

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -411,8 +411,10 @@ def submit(self, job_description):
411411
# Clean up the temporary script file
412412
try:
413413
os.unlink(script_path)
414-
except Exception:
415-
pass
414+
except (OSError, FileNotFoundError) as e:
415+
# Log the failure for debugging but don't raise
416+
import logging
417+
logging.debug(f"Failed to remove temporary script file {script_path}: {e}")
416418

417419
def _create_batch_script(self, submit_dict):
418420
"""
@@ -526,9 +528,17 @@ def query(self, job_id=None, projection=None):
526528
List of job dictionaries.
527529
"""
528530
import subprocess
531+
import getpass
532+
533+
# Get username reliably across systems
534+
try:
535+
username = getpass.getuser()
536+
except Exception:
537+
# Fall back to environment variable if getpass fails
538+
username = os.environ.get('USER', 'unknown')
529539

530540
# Build the squeue command
531-
cmd = ['squeue', '--user', os.environ.get('USER', 'unknown'), '--format=%i|%j|%t|%N', '--noheader']
541+
cmd = ['squeue', '--user', username, '--format=%i|%j|%t|%N', '--noheader']
532542

533543
if job_id is not None:
534544
cmd.extend(['--job', str(job_id)])
@@ -812,6 +822,8 @@ def _parse_submit_file_for_slurm(self, submit_file, job_dir):
812822
str
813823
The command to execute.
814824
"""
825+
from pathlib import Path
826+
815827
executable = None
816828
arguments = ""
817829

@@ -827,16 +839,32 @@ def _parse_submit_file_for_slurm(self, submit_file, job_dir):
827839
arguments = arguments.strip('"').strip("'")
828840

829841
if executable:
830-
# Make executable path absolute if needed
842+
# Make executable path absolute if needed and validate
831843
if not os.path.isabs(executable):
832844
executable = os.path.join(job_dir, executable)
833845

846+
# Resolve path to canonical form and validate it's within expected boundaries
847+
try:
848+
executable_path = Path(executable).resolve()
849+
job_dir_path = Path(job_dir).resolve()
850+
851+
# Ensure the executable is within the job directory or is an absolute system path
852+
# This prevents directory traversal attacks
853+
if not (str(executable_path).startswith(str(job_dir_path)) or executable_path.is_absolute()):
854+
# If executable is not in job_dir and not absolute, treat it as potentially unsafe
855+
executable = str(executable_path)
856+
except (OSError, ValueError):
857+
# If path resolution fails, use the original path
858+
# The scheduler will fail later if the path is invalid
859+
pass
860+
834861
cmd = executable
835862
if arguments:
836863
cmd += f" {arguments}"
837864

838-
# Change to job directory before executing
839-
return f"cd {job_dir} && {cmd}"
865+
# Change to job directory before executing (using resolved path)
866+
job_dir_resolved = str(Path(job_dir).resolve())
867+
return f"cd {job_dir_resolved} && {cmd}"
840868
else:
841869
return f"cd {job_dir} && echo 'No executable found in submit file'"
842870

@@ -857,10 +885,18 @@ def query_all_jobs(self):
857885
- name: Job name
858886
"""
859887
import subprocess
888+
import getpass
889+
890+
# Get username reliably across systems
891+
try:
892+
username = getpass.getuser()
893+
except Exception:
894+
# Fall back to environment variable if getpass fails
895+
username = os.environ.get('USER', 'unknown')
860896

861897
try:
862898
result = subprocess.run(
863-
['squeue', '--user', os.environ.get('USER', 'unknown'),
899+
['squeue', '--user', username,
864900
'--format=%i|%j|%t|%N|%D', '--noheader'],
865901
capture_output=True,
866902
text=True,

0 commit comments

Comments
 (0)