1 <?php 2 // This file is part of Moodle - http://moodle.org/ 3 // 4 // Moodle is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // Moodle is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU General Public License for more details. 13 // 14 // You should have received a copy of the GNU General Public License 15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17 defined('MOODLE_INTERNAL') || die(); 18 require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); 19 20 /** 21 * Copy helper class. 22 * 23 * @package core_backup 24 * @copyright 2022 Catalyst IT Australia Pty Ltd 25 * @author Cameron Ball <cameron@cameron1729.xyz> 26 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 27 */ 28 final class copy_helper { 29 30 /** 31 * Process raw form data from copy_form. 32 * 33 * @param \stdClass $formdata Raw formdata 34 * @return \stdClass Processed data for use with create_copy 35 */ 36 public static function process_formdata(\stdClass $formdata): \stdClass { 37 $requiredfields = [ 38 'courseid', // Course id integer. 39 'fullname', // Fullname of the destination course. 40 'shortname', // Shortname of the destination course. 41 'category', // Category integer ID that contains the destination course. 42 'visible', // Integer to detrmine of the copied course will be visible. 43 'startdate', // Integer timestamp of the start of the destination course. 44 'enddate', // Integer timestamp of the end of the destination course. 45 'idnumber', // ID of the destination course. 46 'userdata', // Integer to determine if the copied course will contain user data. 47 ]; 48 49 $missingfields = array_diff($requiredfields, array_keys((array)$formdata)); 50 if ($missingfields) { 51 throw new \moodle_exception('copyfieldnotfound', 'backup', '', null, implode(", ", $missingfields)); 52 } 53 54 // Remove any extra stuff in the form data. 55 $processed = (object)array_intersect_key((array)$formdata, array_flip($requiredfields)); 56 $processed->keptroles = []; 57 58 // Extract roles from the form data and add to keptroles. 59 foreach ($formdata as $key => $value) { 60 if ((substr($key, 0, 5) === 'role_') && ($value != 0)) { 61 $processed->keptroles[] = $value; 62 } 63 } 64 65 return $processed; 66 } 67 68 /** 69 * Creates a course copy. 70 * Sets up relevant controllers and adhoc task. 71 * 72 * @param \stdClass $copydata Course copy data from process_formdata 73 * @return array $copyids The backup and restore controller ids 74 */ 75 public static function create_copy(\stdClass $copydata): array { 76 global $USER; 77 $copyids = []; 78 79 // Create the initial backupcontoller. 80 $bc = new \backup_controller(\backup::TYPE_1COURSE, $copydata->courseid, \backup::FORMAT_MOODLE, 81 \backup::INTERACTIVE_NO, \backup::MODE_COPY, $USER->id, \backup::RELEASESESSION_YES); 82 $copyids['backupid'] = $bc->get_backupid(); 83 84 // Create the initial restore contoller. 85 list($fullname, $shortname) = \restore_dbops::calculate_course_names( 86 0, get_string('copyingcourse', 'backup'), get_string('copyingcourseshortname', 'backup')); 87 $newcourseid = \restore_dbops::create_new_course($fullname, $shortname, $copydata->category); 88 $rc = new \restore_controller($copyids['backupid'], $newcourseid, \backup::INTERACTIVE_NO, 89 \backup::MODE_COPY, $USER->id, \backup::TARGET_NEW_COURSE, null, 90 \backup::RELEASESESSION_NO, $copydata); 91 $copyids['restoreid'] = $rc->get_restoreid(); 92 93 $bc->set_status(\backup::STATUS_AWAITING); 94 $bc->get_status(); 95 $rc->save_controller(); 96 97 // Create the ad-hoc task to perform the course copy. 98 $asynctask = new \core\task\asynchronous_copy_task(); 99 $asynctask->set_blocking(false); 100 $asynctask->set_custom_data($copyids); 101 \core\task\manager::queue_adhoc_task($asynctask); 102 103 // Clean up the controller. 104 $bc->destroy(); 105 106 return $copyids; 107 } 108 109 /** 110 * Get the in progress course copy operations for a user. 111 * 112 * @param int $userid User id to get the course copies for. 113 * @param int|null $courseid The optional source course id to get copies for. 114 * @return array $copies Details of the inprogress copies. 115 */ 116 public static function get_copies(int $userid, ?int $courseid = null): array { 117 global $DB; 118 $copies = []; 119 [$insql, $inparams] = $DB->get_in_or_equal([\backup::STATUS_FINISHED_OK, \backup::STATUS_FINISHED_ERR]); 120 $params = [ 121 $userid, 122 \backup::EXECUTION_DELAYED, 123 \backup::MODE_COPY, 124 \backup::OPERATION_BACKUP, 125 \backup::STATUS_FINISHED_OK, 126 \backup::OPERATION_RESTORE 127 ]; 128 129 // We exclude backups that finished with OK. Therefore if a backup is missing, 130 // we can assume it finished properly. 131 // 132 // We exclude both failed and successful restores because both of those indicate that the whole 133 // operation has completed. 134 $sql = 'SELECT backupid, itemid, operation, status, timecreated, purpose 135 FROM {backup_controllers} 136 WHERE userid = ? 137 AND execution = ? 138 AND purpose = ? 139 AND ((operation = ? AND status <> ?) OR (operation = ? AND status NOT ' . $insql .')) 140 ORDER BY timecreated DESC'; 141 142 $copyrecords = $DB->get_records_sql($sql, array_merge($params, $inparams)); 143 $idtorc = self::map_backupids_to_restore_controller($copyrecords); 144 145 // Our SQL only gets controllers that have not finished successfully. 146 // So, no restores => all restores have finished (either failed or OK) => all backups have too 147 // Therefore there are no in progress copy operations, return early. 148 if (empty($idtorc)) { 149 return []; 150 } 151 152 foreach ($copyrecords as $copyrecord) { 153 try { 154 $isbackup = $copyrecord->operation == \backup::OPERATION_BACKUP; 155 156 // The mapping is guaranteed to exist for restore controllers, but not 157 // backup controllers. 158 // 159 // When processing backups we don't actually need it, so we just coalesce 160 // to null. 161 $rc = $idtorc[$copyrecord->backupid] ?? null; 162 163 $cid = $isbackup ? $copyrecord->itemid : $rc->get_copy()->courseid; 164 $course = get_course($cid); 165 $copy = clone ($copyrecord); 166 $copy->backupid = $isbackup ? $copyrecord->backupid : null; 167 $copy->restoreid = $rc ? $rc->get_restoreid() : null; 168 $copy->destination = $rc ? $rc->get_copy()->shortname : null; 169 $copy->source = $course->shortname; 170 $copy->sourceid = $course->id; 171 } catch (\Exception $e) { 172 continue; 173 } 174 175 // Filter out anything that's not relevant. 176 if ($courseid) { 177 if ($isbackup && $copyrecord->itemid != $courseid) { 178 continue; 179 } 180 181 if (!$isbackup && $rc->get_copy()->courseid != $courseid) { 182 continue; 183 } 184 } 185 186 // A backup here means that the associated restore controller has not started. 187 // 188 // There's a few situations to consider: 189 // 190 // 1. The backup is waiting or in progress 191 // 2. The backup failed somehow 192 // 3. Something went wrong (e.g., solar flare) and the backup controller saved, but the restore controller didn't 193 // 4. The restore hasn't been created yet (race condition) 194 // 195 // In the case of 1, we add it to the return list. In the case of 2, 3 and 4 we just ignore it and move on. 196 // The backup cleanup task will take care of updating/deleting invalid controllers. 197 if ($isbackup) { 198 if ($copyrecord->status != \backup::STATUS_FINISHED_ERR && !is_null($rc)) { 199 $copies[] = $copy; 200 } 201 202 continue; 203 } 204 205 // A backup in copyrecords, indicates that the associated backup has not 206 // successfully finished. We shouldn't do anything with this restore record. 207 if ($copyrecords[$rc->get_tempdir()] ?? null) { 208 continue; 209 } 210 211 // This is a restore record, and the backup has finished. Return it. 212 $copies[] = $copy; 213 } 214 215 return $copies; 216 } 217 218 /** 219 * Returns a mapping between copy controller IDs and the restore controller. 220 * For example if there exists a copy with backup ID abc and restore ID 123 221 * then this mapping will map both keys abc and 123 to the same (instantiated) 222 * restore controller. 223 * 224 * @param array $backuprecords An array of records from {backup_controllers} 225 * @return array An array of mappings between backup ids and restore controllers 226 */ 227 private static function map_backupids_to_restore_controller(array $backuprecords): array { 228 // Needed for PHP 7.3 - array_merge only accepts 0 parameters in PHP >= 7.4. 229 if (empty($backuprecords)) { 230 return []; 231 } 232 233 return array_merge( 234 ...array_map( 235 function (\stdClass $backuprecord): array { 236 $iscopyrestore = $backuprecord->operation == \backup::OPERATION_RESTORE && 237 $backuprecord->purpose == \backup::MODE_COPY; 238 $isfinished = $backuprecord->status == \backup::STATUS_FINISHED_OK; 239 240 if (!$iscopyrestore || $isfinished) { 241 return []; 242 } 243 244 $rc = \restore_controller::load_controller($backuprecord->backupid); 245 return [$backuprecord->backupid => $rc, $rc->get_tempdir() => $rc]; 246 }, 247 array_values($backuprecords) 248 ) 249 ); 250 } 251 252 /** 253 * Detects and deletes/fails controllers associated with a course copy that are 254 * in an invalid state. 255 * 256 * @param array $backuprecords An array of records from {backup_controllers} 257 * @param int $age How old a controller needs to be (in seconds) before its considered for cleaning 258 * @return void 259 */ 260 public static function cleanup_orphaned_copy_controllers(array $backuprecords, int $age = MINSECS): void { 261 global $DB; 262 263 $idtorc = self::map_backupids_to_restore_controller($backuprecords); 264 265 // Helpful to test if a backup exists in $backuprecords. 266 $bidstorecord = array_combine( 267 array_column($backuprecords, 'backupid'), 268 $backuprecords 269 ); 270 271 foreach ($backuprecords as $record) { 272 if ($record->purpose != \backup::MODE_COPY || $record->status == \backup::STATUS_FINISHED_OK) { 273 continue; 274 } 275 276 $isbackup = $record->operation == \backup::OPERATION_BACKUP; 277 $restoreexists = isset($idtorc[$record->backupid]); 278 $nsecondsago = time() - $age; 279 280 if ($isbackup) { 281 // Sometimes the backup controller gets created, ""something happens"" (like a solar flare) 282 // and the restore controller (and hence adhoc task) don't. 283 // 284 // If more than one minute has passed and the restore controller doesn't exist, it's likely that 285 // this backup controller is orphaned, so we should remove it as the adhoc task to process it will 286 // never be created. 287 if (!$restoreexists && $record->timecreated <= $nsecondsago) { 288 // It would be better to mark the backup as failed by loading the controller 289 // and marking it as failed with $bc->set_status(), but we can't: MDL-74711. 290 // 291 // Deleting it isn't ideal either as maybe we want to inspect the backup 292 // for debugging. So manually updating the column seems to be the next best. 293 $record->status = \backup::STATUS_FINISHED_ERR; 294 $DB->update_record('backup_controllers', $record); 295 } 296 continue; 297 } 298 299 if ($rc = $idtorc[$record->backupid] ?? null) { 300 $backuprecord = $bidstorecord[$rc->get_tempdir()] ?? null; 301 302 // Check the status of the associated backup. If it's failed, then mark this 303 // restore as failed too. 304 if ($backuprecord && $backuprecord->status == \backup::STATUS_FINISHED_ERR) { 305 $rc->set_status(\backup::STATUS_FINISHED_ERR); 306 } 307 } 308 } 309 } 310 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body