1import abc 2import datetime 3import glob 4import json 5import os 6import re 7import shutil 8 9import common 10from autotest_lib.client.common_lib import time_utils 11from autotest_lib.client.common_lib import utils 12from autotest_lib.server.cros.dynamic_suite import constants 13from autotest_lib.server.cros.dynamic_suite import frontend_wrappers 14 15 16_AFE = frontend_wrappers.RetryingAFE() 17 18SPECIAL_TASK_PATTERN = '.*/hosts/[^/]+/(\d+)-[^/]+' 19JOB_PATTERN = '.*/(\d+)-[^/]+' 20# Pattern of a job folder, e.g., 123-debug_user, where 123 is job id and 21# debug_user is the name of user starts the job. 22JOB_FOLDER_PATTERN = '.*/(\d+-[^/]+)' 23 24def is_job_expired(age_limit, timestamp): 25 """Check whether a job timestamp is older than an age limit. 26 27 @param age_limit: Minimum age, measured in days. If the value is 28 not positive, the job is always expired. 29 @param timestamp: Timestamp of the job whose age we are checking. 30 The format must match time_utils.TIME_FMT. 31 32 @returns True iff the job is old enough to be expired. 33 """ 34 if age_limit <= 0: 35 return True 36 job_time = time_utils.time_string_to_datetime(timestamp) 37 expiration = job_time + datetime.timedelta(days=age_limit) 38 return datetime.datetime.now() >= expiration 39 40 41def get_job_id_or_task_id(result_dir): 42 """Extract job id or special task id from result_dir 43 44 @param result_dir: path to the result dir. 45 For test job: 46 /usr/local/autotest/results/2032-chromeos-test/chromeos1-rack5-host6 47 The hostname at the end is optional. 48 For special task: 49 /usr/local/autotest/results/hosts/chromeos1-rack5-host6/1343-cleanup 50 51 @returns: integer representing the job id or task id. Returns None if fail 52 to parse job or task id from the result_dir. 53 """ 54 if not result_dir: 55 return 56 result_dir = os.path.abspath(result_dir) 57 # Result folder for job running inside container has only job id. 58 ssp_job_pattern = '.*/(\d+)$' 59 # Try to get the job ID from the last pattern of number-text. This avoids 60 # issue with path like 123-results/456-debug_user, in which 456 is the real 61 # job ID. 62 m_job = re.findall(JOB_PATTERN, result_dir) 63 if m_job: 64 return int(m_job[-1]) 65 m_special_task = re.match(SPECIAL_TASK_PATTERN, result_dir) 66 if m_special_task: 67 return int(m_special_task.group(1)) 68 m_ssp_job_pattern = re.match(ssp_job_pattern, result_dir) 69 if m_ssp_job_pattern and utils.is_in_container(): 70 return int(m_ssp_job_pattern.group(1)) 71 72 73def get_job_folder_name(result_dir): 74 """Extract folder name of a job from result_dir. 75 76 @param result_dir: path to the result dir. 77 For test job: 78 /usr/local/autotest/results/2032-chromeos-test/chromeos1-rack5-host6 79 The hostname at the end is optional. 80 For special task: 81 /usr/local/autotest/results/hosts/chromeos1-rack5-host6/1343-cleanup 82 83 @returns: The name of the folder of a job. Returns None if fail to parse 84 the name matching pattern JOB_FOLDER_PATTERN from the result_dir. 85 """ 86 if not result_dir: 87 return 88 m_job = re.findall(JOB_FOLDER_PATTERN, result_dir) 89 if m_job: 90 return m_job[-1] 91 92 93class _JobDirectory(object): 94 """State associated with a job to be offloaded. 95 96 The full life-cycle of a job (including failure events that 97 normally don't occur) looks like this: 98 1. The job's results directory is discovered by 99 `get_job_directories()`, and a job instance is created for it. 100 2. Calls to `offload()` have no effect so long as the job 101 isn't complete in the database and the job isn't expired 102 according to the `age_limit` parameter. 103 3. Eventually, the job is both finished and expired. The next 104 call to `offload()` makes the first attempt to offload the 105 directory to GS. Offload is attempted, but fails to complete 106 (e.g. because of a GS problem). 107 4. Finally, a call to `offload()` succeeds, and the directory no 108 longer exists. Now `is_offloaded()` is true, so the job 109 instance is deleted, and future failures will not mention this 110 directory any more. 111 112 Only steps 1. and 4. are guaranteed to occur. The others depend 113 on the timing of calls to `offload()`, and on the reliability of 114 the actual offload process. 115 116 """ 117 118 __metaclass__ = abc.ABCMeta 119 120 GLOB_PATTERN = None # must be redefined in subclass 121 122 def __init__(self, resultsdir): 123 self.dirname = resultsdir 124 self._id = get_job_id_or_task_id(resultsdir) 125 self.offload_count = 0 126 self.first_offload_start = 0 127 128 @classmethod 129 def get_job_directories(cls): 130 """Return a list of directories of jobs that need offloading.""" 131 return [d for d in glob.glob(cls.GLOB_PATTERN) if os.path.isdir(d)] 132 133 @abc.abstractmethod 134 def get_timestamp_if_finished(self): 135 """Return this job's timestamp from the database. 136 137 If the database has not marked the job as finished, return 138 `None`. Otherwise, return a timestamp for the job. The 139 timestamp is to be used to determine expiration in 140 `is_job_expired()`. 141 142 @return Return `None` if the job is still running; otherwise 143 return a string with a timestamp in the appropriate 144 format. 145 """ 146 raise NotImplementedError("_JobDirectory.get_timestamp_if_finished") 147 148 def process_gs_instructions(self): 149 """Process any gs_offloader instructions for this special task. 150 151 @returns True/False if there is anything left to offload. 152 """ 153 # Default support is to still offload the directory. 154 return True 155 156 157NO_OFFLOAD_README = """These results have been deleted rather than offloaded. 158This is the expected behavior for passing jobs from the Commit Queue.""" 159 160 161class RegularJobDirectory(_JobDirectory): 162 """Subclass of _JobDirectory for regular test jobs.""" 163 164 GLOB_PATTERN = '[0-9]*-*' 165 166 def process_gs_instructions(self): 167 """Process any gs_offloader instructions for this job. 168 169 @returns True/False if there is anything left to offload. 170 """ 171 # Go through the gs_offloader instructions file for each test in this job. 172 for path in glob.glob(os.path.join(self.dirname, '*', 173 constants.GS_OFFLOADER_INSTRUCTIONS)): 174 with open(path, 'r') as f: 175 gs_off_instructions = json.load(f) 176 if gs_off_instructions.get(constants.GS_OFFLOADER_NO_OFFLOAD): 177 dirname = os.path.dirname(path) 178 _remove_log_directory_contents(dirname) 179 180 # Finally check if there's anything left to offload. 181 if not os.listdir(self.dirname): 182 shutil.rmtree(self.dirname) 183 return False 184 return True 185 186 187 def get_timestamp_if_finished(self): 188 """Get the timestamp to use for finished jobs. 189 190 @returns the latest hqe finished_on time. If the finished_on times are null 191 returns the job's created_on time. 192 """ 193 entry = _AFE.get_jobs(id=self._id, finished=True) 194 if not entry: 195 return None 196 hqes = _AFE.get_host_queue_entries(finished_on__isnull=False, 197 job_id=self._id) 198 if not hqes: 199 return entry[0].created_on 200 # While most Jobs have 1 HQE, some can have multiple, so check them all. 201 return max([hqe.finished_on for hqe in hqes]) 202 203 204def _remove_log_directory_contents(dirpath): 205 """Remove log directory contents. 206 207 Leave a note explaining what has happened to the logs. 208 209 @param dirpath: Path to log directory. 210 """ 211 shutil.rmtree(dirpath) 212 os.mkdir(dirpath) 213 breadcrumb_name = os.path.join(dirpath, 'logs-removed-readme.txt') 214 with open(breadcrumb_name, 'w') as f: 215 f.write(NO_OFFLOAD_README) 216 217 218class SpecialJobDirectory(_JobDirectory): 219 """Subclass of _JobDirectory for special (per-host) jobs.""" 220 221 GLOB_PATTERN = 'hosts/*/[0-9]*-*' 222 223 def __init__(self, resultsdir): 224 super(SpecialJobDirectory, self).__init__(resultsdir) 225 226 def get_timestamp_if_finished(self): 227 entry = _AFE.get_special_tasks(id=self._id, is_complete=True) 228 return entry[0].time_finished if entry else None 229