1 |
efrain |
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_custom_data($copyids);
|
|
|
100 |
\core\task\manager::queue_adhoc_task($asynctask);
|
|
|
101 |
|
|
|
102 |
// Clean up the controller.
|
|
|
103 |
$bc->destroy();
|
|
|
104 |
|
|
|
105 |
return $copyids;
|
|
|
106 |
}
|
|
|
107 |
|
|
|
108 |
/**
|
|
|
109 |
* Get the in progress course copy operations for a user.
|
|
|
110 |
*
|
|
|
111 |
* @param int $userid User id to get the course copies for.
|
|
|
112 |
* @param int|null $courseid The optional source course id to get copies for.
|
|
|
113 |
* @return array $copies Details of the inprogress copies.
|
|
|
114 |
*/
|
|
|
115 |
public static function get_copies(int $userid, ?int $courseid = null): array {
|
|
|
116 |
global $DB;
|
|
|
117 |
$copies = [];
|
|
|
118 |
[$insql, $inparams] = $DB->get_in_or_equal([\backup::STATUS_FINISHED_OK, \backup::STATUS_FINISHED_ERR]);
|
|
|
119 |
$params = [
|
|
|
120 |
$userid,
|
|
|
121 |
\backup::EXECUTION_DELAYED,
|
|
|
122 |
\backup::MODE_COPY,
|
|
|
123 |
\backup::OPERATION_BACKUP,
|
|
|
124 |
\backup::STATUS_FINISHED_OK,
|
|
|
125 |
\backup::OPERATION_RESTORE
|
|
|
126 |
];
|
|
|
127 |
|
|
|
128 |
// We exclude backups that finished with OK. Therefore if a backup is missing,
|
|
|
129 |
// we can assume it finished properly.
|
|
|
130 |
//
|
|
|
131 |
// We exclude both failed and successful restores because both of those indicate that the whole
|
|
|
132 |
// operation has completed.
|
|
|
133 |
$sql = 'SELECT backupid, itemid, operation, status, timecreated, purpose
|
|
|
134 |
FROM {backup_controllers}
|
|
|
135 |
WHERE userid = ?
|
|
|
136 |
AND execution = ?
|
|
|
137 |
AND purpose = ?
|
|
|
138 |
AND ((operation = ? AND status <> ?) OR (operation = ? AND status NOT ' . $insql .'))
|
|
|
139 |
ORDER BY timecreated DESC';
|
|
|
140 |
|
|
|
141 |
$copyrecords = $DB->get_records_sql($sql, array_merge($params, $inparams));
|
|
|
142 |
$idtorc = self::map_backupids_to_restore_controller($copyrecords);
|
|
|
143 |
|
|
|
144 |
// Our SQL only gets controllers that have not finished successfully.
|
|
|
145 |
// So, no restores => all restores have finished (either failed or OK) => all backups have too
|
|
|
146 |
// Therefore there are no in progress copy operations, return early.
|
|
|
147 |
if (empty($idtorc)) {
|
|
|
148 |
return [];
|
|
|
149 |
}
|
|
|
150 |
|
|
|
151 |
foreach ($copyrecords as $copyrecord) {
|
|
|
152 |
try {
|
|
|
153 |
$isbackup = $copyrecord->operation == \backup::OPERATION_BACKUP;
|
|
|
154 |
|
|
|
155 |
// The mapping is guaranteed to exist for restore controllers, but not
|
|
|
156 |
// backup controllers.
|
|
|
157 |
//
|
|
|
158 |
// When processing backups we don't actually need it, so we just coalesce
|
|
|
159 |
// to null.
|
|
|
160 |
$rc = $idtorc[$copyrecord->backupid] ?? null;
|
|
|
161 |
|
|
|
162 |
$cid = $isbackup ? $copyrecord->itemid : $rc->get_copy()->courseid;
|
|
|
163 |
$course = get_course($cid);
|
|
|
164 |
$copy = clone ($copyrecord);
|
|
|
165 |
$copy->backupid = $isbackup ? $copyrecord->backupid : null;
|
|
|
166 |
$copy->restoreid = $rc ? $rc->get_restoreid() : null;
|
|
|
167 |
$copy->destination = $rc ? $rc->get_copy()->shortname : null;
|
|
|
168 |
$copy->source = $course->shortname;
|
|
|
169 |
$copy->sourceid = $course->id;
|
|
|
170 |
} catch (\Exception $e) {
|
|
|
171 |
continue;
|
|
|
172 |
}
|
|
|
173 |
|
|
|
174 |
// Filter out anything that's not relevant.
|
|
|
175 |
if ($courseid) {
|
|
|
176 |
if ($isbackup && $copyrecord->itemid != $courseid) {
|
|
|
177 |
continue;
|
|
|
178 |
}
|
|
|
179 |
|
|
|
180 |
if (!$isbackup && $rc->get_copy()->courseid != $courseid) {
|
|
|
181 |
continue;
|
|
|
182 |
}
|
|
|
183 |
}
|
|
|
184 |
|
|
|
185 |
// A backup here means that the associated restore controller has not started.
|
|
|
186 |
//
|
|
|
187 |
// There's a few situations to consider:
|
|
|
188 |
//
|
|
|
189 |
// 1. The backup is waiting or in progress
|
|
|
190 |
// 2. The backup failed somehow
|
|
|
191 |
// 3. Something went wrong (e.g., solar flare) and the backup controller saved, but the restore controller didn't
|
|
|
192 |
// 4. The restore hasn't been created yet (race condition)
|
|
|
193 |
//
|
|
|
194 |
// 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.
|
|
|
195 |
// The backup cleanup task will take care of updating/deleting invalid controllers.
|
|
|
196 |
if ($isbackup) {
|
|
|
197 |
if ($copyrecord->status != \backup::STATUS_FINISHED_ERR && !is_null($rc)) {
|
|
|
198 |
$copies[] = $copy;
|
|
|
199 |
}
|
|
|
200 |
|
|
|
201 |
continue;
|
|
|
202 |
}
|
|
|
203 |
|
|
|
204 |
// A backup in copyrecords, indicates that the associated backup has not
|
|
|
205 |
// successfully finished. We shouldn't do anything with this restore record.
|
|
|
206 |
if ($copyrecords[$rc->get_tempdir()] ?? null) {
|
|
|
207 |
continue;
|
|
|
208 |
}
|
|
|
209 |
|
|
|
210 |
// This is a restore record, and the backup has finished. Return it.
|
|
|
211 |
$copies[] = $copy;
|
|
|
212 |
}
|
|
|
213 |
|
|
|
214 |
return $copies;
|
|
|
215 |
}
|
|
|
216 |
|
|
|
217 |
/**
|
|
|
218 |
* Returns a mapping between copy controller IDs and the restore controller.
|
|
|
219 |
* For example if there exists a copy with backup ID abc and restore ID 123
|
|
|
220 |
* then this mapping will map both keys abc and 123 to the same (instantiated)
|
|
|
221 |
* restore controller.
|
|
|
222 |
*
|
|
|
223 |
* @param array $backuprecords An array of records from {backup_controllers}
|
|
|
224 |
* @return array An array of mappings between backup ids and restore controllers
|
|
|
225 |
*/
|
|
|
226 |
private static function map_backupids_to_restore_controller(array $backuprecords): array {
|
|
|
227 |
// Needed for PHP 7.3 - array_merge only accepts 0 parameters in PHP >= 7.4.
|
|
|
228 |
if (empty($backuprecords)) {
|
|
|
229 |
return [];
|
|
|
230 |
}
|
|
|
231 |
|
|
|
232 |
return array_merge(
|
|
|
233 |
...array_map(
|
|
|
234 |
function (\stdClass $backuprecord): array {
|
|
|
235 |
$iscopyrestore = $backuprecord->operation == \backup::OPERATION_RESTORE &&
|
|
|
236 |
$backuprecord->purpose == \backup::MODE_COPY;
|
|
|
237 |
$isfinished = $backuprecord->status == \backup::STATUS_FINISHED_OK;
|
|
|
238 |
|
|
|
239 |
if (!$iscopyrestore || $isfinished) {
|
|
|
240 |
return [];
|
|
|
241 |
}
|
|
|
242 |
|
|
|
243 |
$rc = \restore_controller::load_controller($backuprecord->backupid);
|
|
|
244 |
return [$backuprecord->backupid => $rc, $rc->get_tempdir() => $rc];
|
|
|
245 |
},
|
|
|
246 |
array_values($backuprecords)
|
|
|
247 |
)
|
|
|
248 |
);
|
|
|
249 |
}
|
|
|
250 |
|
|
|
251 |
/**
|
|
|
252 |
* Detects and deletes/fails controllers associated with a course copy that are
|
|
|
253 |
* in an invalid state.
|
|
|
254 |
*
|
|
|
255 |
* @param array $backuprecords An array of records from {backup_controllers}
|
|
|
256 |
* @param int $age How old a controller needs to be (in seconds) before its considered for cleaning
|
|
|
257 |
* @return void
|
|
|
258 |
*/
|
|
|
259 |
public static function cleanup_orphaned_copy_controllers(array $backuprecords, int $age = MINSECS): void {
|
|
|
260 |
global $DB;
|
|
|
261 |
|
|
|
262 |
$idtorc = self::map_backupids_to_restore_controller($backuprecords);
|
|
|
263 |
|
|
|
264 |
// Helpful to test if a backup exists in $backuprecords.
|
|
|
265 |
$bidstorecord = array_combine(
|
|
|
266 |
array_column($backuprecords, 'backupid'),
|
|
|
267 |
$backuprecords
|
|
|
268 |
);
|
|
|
269 |
|
|
|
270 |
foreach ($backuprecords as $record) {
|
|
|
271 |
if ($record->purpose != \backup::MODE_COPY || $record->status == \backup::STATUS_FINISHED_OK) {
|
|
|
272 |
continue;
|
|
|
273 |
}
|
|
|
274 |
|
|
|
275 |
$isbackup = $record->operation == \backup::OPERATION_BACKUP;
|
|
|
276 |
$restoreexists = isset($idtorc[$record->backupid]);
|
|
|
277 |
$nsecondsago = time() - $age;
|
|
|
278 |
|
|
|
279 |
if ($isbackup) {
|
|
|
280 |
// Sometimes the backup controller gets created, ""something happens"" (like a solar flare)
|
|
|
281 |
// and the restore controller (and hence adhoc task) don't.
|
|
|
282 |
//
|
|
|
283 |
// If more than one minute has passed and the restore controller doesn't exist, it's likely that
|
|
|
284 |
// this backup controller is orphaned, so we should remove it as the adhoc task to process it will
|
|
|
285 |
// never be created.
|
|
|
286 |
if (!$restoreexists && $record->timecreated <= $nsecondsago) {
|
|
|
287 |
// It would be better to mark the backup as failed by loading the controller
|
|
|
288 |
// and marking it as failed with $bc->set_status(), but we can't: MDL-74711.
|
|
|
289 |
//
|
|
|
290 |
// Deleting it isn't ideal either as maybe we want to inspect the backup
|
|
|
291 |
// for debugging. So manually updating the column seems to be the next best.
|
|
|
292 |
$record->status = \backup::STATUS_FINISHED_ERR;
|
|
|
293 |
$DB->update_record('backup_controllers', $record);
|
|
|
294 |
}
|
|
|
295 |
continue;
|
|
|
296 |
}
|
|
|
297 |
|
|
|
298 |
if ($rc = $idtorc[$record->backupid] ?? null) {
|
|
|
299 |
$backuprecord = $bidstorecord[$rc->get_tempdir()] ?? null;
|
|
|
300 |
|
|
|
301 |
// Check the status of the associated backup. If it's failed, then mark this
|
|
|
302 |
// restore as failed too.
|
|
|
303 |
if ($backuprecord && $backuprecord->status == \backup::STATUS_FINISHED_ERR) {
|
|
|
304 |
$rc->set_status(\backup::STATUS_FINISHED_ERR);
|
|
|
305 |
}
|
|
|
306 |
}
|
|
|
307 |
}
|
|
|
308 |
}
|
|
|
309 |
}
|