MDL-31500 backup: Warn users about front page backups
[moodle.git] / lib / classes / update / checker.php
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/>.
17 /**
18  * Defines classes used for updates.
19  *
20  * @package    core
21  * @copyright  2011 David Mudrak <david@moodle.com>
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
24 namespace core\update;
26 use html_writer, coding_exception, core_component;
28 defined('MOODLE_INTERNAL') || die();
30 /**
31  * Singleton class that handles checking for available updates
32  */
33 class checker {
35     /** @var \core\update\checker holds the singleton instance */
36     protected static $singletoninstance;
37     /** @var null|int the timestamp of when the most recent response was fetched */
38     protected $recentfetch = null;
39     /** @var null|array the recent response from the update notification provider */
40     protected $recentresponse = null;
41     /** @var null|string the numerical version of the local Moodle code */
42     protected $currentversion = null;
43     /** @var null|string the release info of the local Moodle code */
44     protected $currentrelease = null;
45     /** @var null|string branch of the local Moodle code */
46     protected $currentbranch = null;
47     /** @var array of (string)frankestyle => (string)version list of additional plugins deployed at this site */
48     protected $currentplugins = array();
50     /**
51      * Direct initiation not allowed, use the factory method {@link self::instance()}
52      */
53     protected function __construct() {
54     }
56     /**
57      * Sorry, this is singleton
58      */
59     protected function __clone() {
60     }
62     /**
63      * Factory method for this class
64      *
65      * @return \core\update\checker the singleton instance
66      */
67     public static function instance() {
68         if (is_null(self::$singletoninstance)) {
69             self::$singletoninstance = new self();
70         }
71         return self::$singletoninstance;
72     }
74     /**
75      * Reset any caches
76      * @param bool $phpunitreset
77      */
78     public static function reset_caches($phpunitreset = false) {
79         if ($phpunitreset) {
80             self::$singletoninstance = null;
81         }
82     }
84     /**
85      * Is automatic deployment enabled?
86      *
87      * @return bool
88      */
89     public function enabled() {
90         global $CFG;
92         // The feature can be prohibited via config.php.
93         return empty($CFG->disableupdateautodeploy);
94     }
96     /**
97      * Returns the timestamp of the last execution of {@link fetch()}
98      *
99      * @return int|null null if it has never been executed or we don't known
100      */
101     public function get_last_timefetched() {
103         $this->restore_response();
105         if (!empty($this->recentfetch)) {
106             return $this->recentfetch;
108         } else {
109             return null;
110         }
111     }
113     /**
114      * Fetches the available update status from the remote site
115      *
116      * @throws checker_exception
117      */
118     public function fetch() {
119         $response = $this->get_response();
120         $this->validate_response($response);
121         $this->store_response($response);
122     }
124     /**
125      * Returns the available update information for the given component
126      *
127      * This method returns null if the most recent response does not contain any information
128      * about it. The returned structure is an array of available updates for the given
129      * component. Each update info is an object with at least one property called
130      * 'version'. Other possible properties are 'release', 'maturity', 'url' and 'downloadurl'.
131      *
132      * For the 'core' component, the method returns real updates only (those with higher version).
133      * For all other components, the list of all known remote updates is returned and the caller
134      * (usually the {@link core_plugin_manager}) is supposed to make the actual comparison of versions.
135      *
136      * @param string $component frankenstyle
137      * @param array $options with supported keys 'minmaturity' and/or 'notifybuilds'
138      * @return null|array null or array of \core\update\info objects
139      */
140     public function get_update_info($component, array $options = array()) {
142         if (!isset($options['minmaturity'])) {
143             $options['minmaturity'] = 0;
144         }
146         if (!isset($options['notifybuilds'])) {
147             $options['notifybuilds'] = false;
148         }
150         if ($component === 'core') {
151             $this->load_current_environment();
152         }
154         $this->restore_response();
156         if (empty($this->recentresponse['updates'][$component])) {
157             return null;
158         }
160         $updates = array();
161         foreach ($this->recentresponse['updates'][$component] as $info) {
162             $update = new info($component, $info);
163             if (isset($update->maturity) and ($update->maturity < $options['minmaturity'])) {
164                 continue;
165             }
166             if ($component === 'core') {
167                 if ($update->version <= $this->currentversion) {
168                     continue;
169                 }
170                 if (empty($options['notifybuilds']) and $this->is_same_release($update->release)) {
171                     continue;
172                 }
173             }
174             $updates[] = $update;
175         }
177         if (empty($updates)) {
178             return null;
179         }
181         return $updates;
182     }
184     /**
185      * The method being run via cron.php
186      */
187     public function cron() {
188         global $CFG;
190         if (!$this->cron_autocheck_enabled()) {
191             $this->cron_mtrace('Automatic check for available updates not enabled, skipping.');
192             return;
193         }
195         $now = $this->cron_current_timestamp();
197         if ($this->cron_has_fresh_fetch($now)) {
198             $this->cron_mtrace('Recently fetched info about available updates is still fresh enough, skipping.');
199             return;
200         }
202         if ($this->cron_has_outdated_fetch($now)) {
203             $this->cron_mtrace('Outdated or missing info about available updates, forced fetching ... ', '');
204             $this->cron_execute();
205             return;
206         }
208         $offset = $this->cron_execution_offset();
209         $start = mktime(1, 0, 0, date('n', $now), date('j', $now), date('Y', $now)); // 01:00 AM today local time
210         if ($now > $start + $offset) {
211             $this->cron_mtrace('Regular daily check for available updates ... ', '');
212             $this->cron_execute();
213             return;
214         }
215     }
217     /* === End of public API === */
219     /**
220      * Makes cURL request to get data from the remote site
221      *
222      * @return string raw request result
223      * @throws checker_exception
224      */
225     protected function get_response() {
226         global $CFG;
227         require_once($CFG->libdir.'/filelib.php');
229         $curl = new \curl(array('proxy' => true));
230         $response = $curl->post($this->prepare_request_url(), $this->prepare_request_params(), $this->prepare_request_options());
231         $curlerrno = $curl->get_errno();
232         if (!empty($curlerrno)) {
233             throw new checker_exception('err_response_curl', 'cURL error '.$curlerrno.': '.$curl->error);
234         }
235         $curlinfo = $curl->get_info();
236         if ($curlinfo['http_code'] != 200) {
237             throw new checker_exception('err_response_http_code', $curlinfo['http_code']);
238         }
239         return $response;
240     }
242     /**
243      * Makes sure the response is valid, has correct API format etc.
244      *
245      * @param string $response raw response as returned by the {@link self::get_response()}
246      * @throws checker_exception
247      */
248     protected function validate_response($response) {
250         $response = $this->decode_response($response);
252         if (empty($response)) {
253             throw new checker_exception('err_response_empty');
254         }
256         if (empty($response['status']) or $response['status'] !== 'OK') {
257             throw new checker_exception('err_response_status', $response['status']);
258         }
260         if (empty($response['apiver']) or $response['apiver'] !== '1.2') {
261             throw new checker_exception('err_response_format_version', $response['apiver']);
262         }
264         if (empty($response['forbranch']) or $response['forbranch'] !== moodle_major_version(true)) {
265             throw new checker_exception('err_response_target_version', $response['forbranch']);
266         }
267     }
269     /**
270      * Decodes the raw string response from the update notifications provider
271      *
272      * @param string $response as returned by {@link self::get_response()}
273      * @return array decoded response structure
274      */
275     protected function decode_response($response) {
276         return json_decode($response, true);
277     }
279     /**
280      * Stores the valid fetched response for later usage
281      *
282      * This implementation uses the config_plugins table as the permanent storage.
283      *
284      * @param string $response raw valid data returned by {@link self::get_response()}
285      */
286     protected function store_response($response) {
288         set_config('recentfetch', time(), 'core_plugin');
289         set_config('recentresponse', $response, 'core_plugin');
291         if (defined('CACHE_DISABLE_ALL') and CACHE_DISABLE_ALL) {
292             // Very nasty hack to work around cache coherency issues on admin/index.php?cache=0 page,
293             // we definitely need to keep caches in sync when writing into DB at all times!
294             \cache_helper::purge_all(true);
295         }
297         $this->restore_response(true);
298     }
300     /**
301      * Loads the most recent raw response record we have fetched
302      *
303      * After this method is called, $this->recentresponse is set to an array. If the
304      * array is empty, then either no data have been fetched yet or the fetched data
305      * do not have expected format (and thence they are ignored and a debugging
306      * message is displayed).
307      *
308      * This implementation uses the config_plugins table as the permanent storage.
309      *
310      * @param bool $forcereload reload even if it was already loaded
311      */
312     protected function restore_response($forcereload = false) {
314         if (!$forcereload and !is_null($this->recentresponse)) {
315             // We already have it, nothing to do.
316             return;
317         }
319         $config = get_config('core_plugin');
321         if (!empty($config->recentresponse) and !empty($config->recentfetch)) {
322             try {
323                 $this->validate_response($config->recentresponse);
324                 $this->recentfetch = $config->recentfetch;
325                 $this->recentresponse = $this->decode_response($config->recentresponse);
326             } catch (checker_exception $e) {
327                 // The server response is not valid. Behave as if no data were fetched yet.
328                 // This may happen when the most recent update info (cached locally) has been
329                 // fetched with the previous branch of Moodle (like during an upgrade from 2.x
330                 // to 2.y) or when the API of the response has changed.
331                 $this->recentresponse = array();
332             }
334         } else {
335             $this->recentresponse = array();
336         }
337     }
339     /**
340      * Compares two raw {@link $recentresponse} records and returns the list of changed updates
341      *
342      * This method is used to populate potential update info to be sent to site admins.
343      *
344      * @param array $old
345      * @param array $new
346      * @throws checker_exception
347      * @return array parts of $new['updates'] that have changed
348      */
349     protected function compare_responses(array $old, array $new) {
351         if (empty($new)) {
352             return array();
353         }
355         if (!array_key_exists('updates', $new)) {
356             throw new checker_exception('err_response_format');
357         }
359         if (empty($old)) {
360             return $new['updates'];
361         }
363         if (!array_key_exists('updates', $old)) {
364             throw new checker_exception('err_response_format');
365         }
367         $changes = array();
369         foreach ($new['updates'] as $newcomponent => $newcomponentupdates) {
370             if (empty($old['updates'][$newcomponent])) {
371                 $changes[$newcomponent] = $newcomponentupdates;
372                 continue;
373             }
374             foreach ($newcomponentupdates as $newcomponentupdate) {
375                 $inold = false;
376                 foreach ($old['updates'][$newcomponent] as $oldcomponentupdate) {
377                     if ($newcomponentupdate['version'] == $oldcomponentupdate['version']) {
378                         $inold = true;
379                     }
380                 }
381                 if (!$inold) {
382                     if (!isset($changes[$newcomponent])) {
383                         $changes[$newcomponent] = array();
384                     }
385                     $changes[$newcomponent][] = $newcomponentupdate;
386                 }
387             }
388         }
390         return $changes;
391     }
393     /**
394      * Returns the URL to send update requests to
395      *
396      * During the development or testing, you can set $CFG->alternativeupdateproviderurl
397      * to a custom URL that will be used. Otherwise the standard URL will be returned.
398      *
399      * @return string URL
400      */
401     protected function prepare_request_url() {
402         global $CFG;
404         if (!empty($CFG->config_php_settings['alternativeupdateproviderurl'])) {
405             return $CFG->config_php_settings['alternativeupdateproviderurl'];
406         } else {
407             return 'https://download.moodle.org/api/1.2/updates.php';
408         }
409     }
411     /**
412      * Sets the properties currentversion, currentrelease, currentbranch and currentplugins
413      *
414      * @param bool $forcereload
415      */
416     protected function load_current_environment($forcereload=false) {
417         global $CFG;
419         if (!is_null($this->currentversion) and !$forcereload) {
420             // Nothing to do.
421             return;
422         }
424         $version = null;
425         $release = null;
427         require($CFG->dirroot.'/version.php');
428         $this->currentversion = $version;
429         $this->currentrelease = $release;
430         $this->currentbranch = moodle_major_version(true);
432         $pluginman = \core_plugin_manager::instance();
433         foreach ($pluginman->get_plugins() as $type => $plugins) {
434             foreach ($plugins as $plugin) {
435                 if (!$plugin->is_standard()) {
436                     $this->currentplugins[$plugin->component] = $plugin->versiondisk;
437                 }
438             }
439         }
440     }
442     /**
443      * Returns the list of HTTP params to be sent to the updates provider URL
444      *
445      * @return array of (string)param => (string)value
446      */
447     protected function prepare_request_params() {
448         global $CFG;
450         $this->load_current_environment();
451         $this->restore_response();
453         $params = array();
454         $params['format'] = 'json';
456         if (isset($this->recentresponse['ticket'])) {
457             $params['ticket'] = $this->recentresponse['ticket'];
458         }
460         if (isset($this->currentversion)) {
461             $params['version'] = $this->currentversion;
462         } else {
463             throw new coding_exception('Main Moodle version must be already known here');
464         }
466         if (isset($this->currentbranch)) {
467             $params['branch'] = $this->currentbranch;
468         } else {
469             throw new coding_exception('Moodle release must be already known here');
470         }
472         $plugins = array();
473         foreach ($this->currentplugins as $plugin => $version) {
474             $plugins[] = $plugin.'@'.$version;
475         }
476         if (!empty($plugins)) {
477             $params['plugins'] = implode(',', $plugins);
478         }
480         return $params;
481     }
483     /**
484      * Returns the list of cURL options to use when fetching available updates data
485      *
486      * @return array of (string)param => (string)value
487      */
488     protected function prepare_request_options() {
489         $options = array(
490             'CURLOPT_SSL_VERIFYHOST' => 2,      // This is the default in {@link curl} class but just in case.
491             'CURLOPT_SSL_VERIFYPEER' => true,
492         );
494         return $options;
495     }
497     /**
498      * Returns the current timestamp
499      *
500      * @return int the timestamp
501      */
502     protected function cron_current_timestamp() {
503         return time();
504     }
506     /**
507      * Output cron debugging info
508      *
509      * @see mtrace()
510      * @param string $msg output message
511      * @param string $eol end of line
512      */
513     protected function cron_mtrace($msg, $eol = PHP_EOL) {
514         mtrace($msg, $eol);
515     }
517     /**
518      * Decide if the autocheck feature is disabled in the server setting
519      *
520      * @return bool true if autocheck enabled, false if disabled
521      */
522     protected function cron_autocheck_enabled() {
523         global $CFG;
525         if (empty($CFG->updateautocheck)) {
526             return false;
527         } else {
528             return true;
529         }
530     }
532     /**
533      * Decide if the recently fetched data are still fresh enough
534      *
535      * @param int $now current timestamp
536      * @return bool true if no need to re-fetch, false otherwise
537      */
538     protected function cron_has_fresh_fetch($now) {
539         $recent = $this->get_last_timefetched();
541         if (empty($recent)) {
542             return false;
543         }
545         if ($now < $recent) {
546             $this->cron_mtrace('The most recent fetch is reported to be in the future, this is weird!');
547             return true;
548         }
550         if ($now - $recent > 24 * HOURSECS) {
551             return false;
552         }
554         return true;
555     }
557     /**
558      * Decide if the fetch is outadated or even missing
559      *
560      * @param int $now current timestamp
561      * @return bool false if no need to re-fetch, true otherwise
562      */
563     protected function cron_has_outdated_fetch($now) {
564         $recent = $this->get_last_timefetched();
566         if (empty($recent)) {
567             return true;
568         }
570         if ($now < $recent) {
571             $this->cron_mtrace('The most recent fetch is reported to be in the future, this is weird!');
572             return false;
573         }
575         if ($now - $recent > 48 * HOURSECS) {
576             return true;
577         }
579         return false;
580     }
582     /**
583      * Returns the cron execution offset for this site
584      *
585      * The main {@link self::cron()} is supposed to run every night in some random time
586      * between 01:00 and 06:00 AM (local time). The exact moment is defined by so called
587      * execution offset, that is the amount of time after 01:00 AM. The offset value is
588      * initially generated randomly and then used consistently at the site. This way, the
589      * regular checks against the download.moodle.org server are spread in time.
590      *
591      * @return int the offset number of seconds from range 1 sec to 5 hours
592      */
593     protected function cron_execution_offset() {
594         global $CFG;
596         if (empty($CFG->updatecronoffset)) {
597             set_config('updatecronoffset', rand(1, 5 * HOURSECS));
598         }
600         return $CFG->updatecronoffset;
601     }
603     /**
604      * Fetch available updates info and eventually send notification to site admins
605      */
606     protected function cron_execute() {
608         try {
609             $this->restore_response();
610             $previous = $this->recentresponse;
611             $this->fetch();
612             $this->restore_response(true);
613             $current = $this->recentresponse;
614             $changes = $this->compare_responses($previous, $current);
615             $notifications = $this->cron_notifications($changes);
616             $this->cron_notify($notifications);
617             $this->cron_mtrace('done');
618         } catch (checker_exception $e) {
619             $this->cron_mtrace('FAILED!');
620         }
621     }
623     /**
624      * Given the list of changes in available updates, pick those to send to site admins
625      *
626      * @param array $changes as returned by {@link self::compare_responses()}
627      * @return array of \core\update\info objects to send to site admins
628      */
629     protected function cron_notifications(array $changes) {
630         global $CFG;
632         $notifications = array();
633         $pluginman = \core_plugin_manager::instance();
634         $plugins = $pluginman->get_plugins(true);
636         foreach ($changes as $component => $componentchanges) {
637             if (empty($componentchanges)) {
638                 continue;
639             }
640             $componentupdates = $this->get_update_info($component,
641                 array('minmaturity' => $CFG->updateminmaturity, 'notifybuilds' => $CFG->updatenotifybuilds));
642             if (empty($componentupdates)) {
643                 continue;
644             }
645             // Notify only about those $componentchanges that are present in $componentupdates
646             // to respect the preferences.
647             foreach ($componentchanges as $componentchange) {
648                 foreach ($componentupdates as $componentupdate) {
649                     if ($componentupdate->version == $componentchange['version']) {
650                         if ($component == 'core') {
651                             // In case of 'core', we already know that the $componentupdate
652                             // is a real update with higher version ({@see self::get_update_info()}).
653                             // We just perform additional check for the release property as there
654                             // can be two Moodle releases having the same version (e.g. 2.4.0 and 2.5dev shortly
655                             // after the release). We can do that because we have the release info
656                             // always available for the core.
657                             if ((string)$componentupdate->release === (string)$componentchange['release']) {
658                                 $notifications[] = $componentupdate;
659                             }
660                         } else {
661                             // Use the core_plugin_manager to check if the detected $componentchange
662                             // is a real update with higher version. That is, the $componentchange
663                             // is present in the array of {@link \core\update\info} objects
664                             // returned by the plugin's available_updates() method.
665                             list($plugintype, $pluginname) = core_component::normalize_component($component);
666                             if (!empty($plugins[$plugintype][$pluginname])) {
667                                 $availableupdates = $plugins[$plugintype][$pluginname]->available_updates();
668                                 if (!empty($availableupdates)) {
669                                     foreach ($availableupdates as $availableupdate) {
670                                         if ($availableupdate->version == $componentchange['version']) {
671                                             $notifications[] = $componentupdate;
672                                         }
673                                     }
674                                 }
675                             }
676                         }
677                     }
678                 }
679             }
680         }
682         return $notifications;
683     }
685     /**
686      * Sends the given notifications to site admins via messaging API
687      *
688      * @param array $notifications array of \core\update\info objects to send
689      */
690     protected function cron_notify(array $notifications) {
691         global $CFG;
693         if (empty($notifications)) {
694             return;
695         }
697         $admins = get_admins();
699         if (empty($admins)) {
700             return;
701         }
703         $this->cron_mtrace('sending notifications ... ', '');
705         $text = get_string('updatenotifications', 'core_admin') . PHP_EOL;
706         $html = html_writer::tag('h1', get_string('updatenotifications', 'core_admin')) . PHP_EOL;
708         $coreupdates = array();
709         $pluginupdates = array();
711         foreach ($notifications as $notification) {
712             if ($notification->component == 'core') {
713                 $coreupdates[] = $notification;
714             } else {
715                 $pluginupdates[] = $notification;
716             }
717         }
719         if (!empty($coreupdates)) {
720             $text .= PHP_EOL . get_string('updateavailable', 'core_admin') . PHP_EOL;
721             $html .= html_writer::tag('h2', get_string('updateavailable', 'core_admin')) . PHP_EOL;
722             $html .= html_writer::start_tag('ul') . PHP_EOL;
723             foreach ($coreupdates as $coreupdate) {
724                 $html .= html_writer::start_tag('li');
725                 if (isset($coreupdate->release)) {
726                     $text .= get_string('updateavailable_release', 'core_admin', $coreupdate->release);
727                     $html .= html_writer::tag('strong', get_string('updateavailable_release', 'core_admin', $coreupdate->release));
728                 }
729                 if (isset($coreupdate->version)) {
730                     $text .= ' '.get_string('updateavailable_version', 'core_admin', $coreupdate->version);
731                     $html .= ' '.get_string('updateavailable_version', 'core_admin', $coreupdate->version);
732                 }
733                 if (isset($coreupdate->maturity)) {
734                     $text .= ' ('.get_string('maturity'.$coreupdate->maturity, 'core_admin').')';
735                     $html .= ' ('.get_string('maturity'.$coreupdate->maturity, 'core_admin').')';
736                 }
737                 $text .= PHP_EOL;
738                 $html .= html_writer::end_tag('li') . PHP_EOL;
739             }
740             $text .= PHP_EOL;
741             $html .= html_writer::end_tag('ul') . PHP_EOL;
743             $a = array('url' => $CFG->wwwroot.'/'.$CFG->admin.'/index.php');
744             $text .= get_string('updateavailabledetailslink', 'core_admin', $a) . PHP_EOL;
745             $a = array('url' => html_writer::link($CFG->wwwroot.'/'.$CFG->admin.'/index.php', $CFG->wwwroot.'/'.$CFG->admin.'/index.php'));
746             $html .= html_writer::tag('p', get_string('updateavailabledetailslink', 'core_admin', $a)) . PHP_EOL;
747         }
749         if (!empty($pluginupdates)) {
750             $text .= PHP_EOL . get_string('updateavailableforplugin', 'core_admin') . PHP_EOL;
751             $html .= html_writer::tag('h2', get_string('updateavailableforplugin', 'core_admin')) . PHP_EOL;
753             $html .= html_writer::start_tag('ul') . PHP_EOL;
754             foreach ($pluginupdates as $pluginupdate) {
755                 $html .= html_writer::start_tag('li');
756                 $text .= get_string('pluginname', $pluginupdate->component);
757                 $html .= html_writer::tag('strong', get_string('pluginname', $pluginupdate->component));
759                 $text .= ' ('.$pluginupdate->component.')';
760                 $html .= ' ('.$pluginupdate->component.')';
762                 $text .= ' '.get_string('updateavailable', 'core_plugin', $pluginupdate->version);
763                 $html .= ' '.get_string('updateavailable', 'core_plugin', $pluginupdate->version);
765                 $text .= PHP_EOL;
766                 $html .= html_writer::end_tag('li') . PHP_EOL;
767             }
768             $text .= PHP_EOL;
769             $html .= html_writer::end_tag('ul') . PHP_EOL;
771             $a = array('url' => $CFG->wwwroot.'/'.$CFG->admin.'/plugins.php');
772             $text .= get_string('updateavailabledetailslink', 'core_admin', $a) . PHP_EOL;
773             $a = array('url' => html_writer::link($CFG->wwwroot.'/'.$CFG->admin.'/plugins.php', $CFG->wwwroot.'/'.$CFG->admin.'/plugins.php'));
774             $html .= html_writer::tag('p', get_string('updateavailabledetailslink', 'core_admin', $a)) . PHP_EOL;
775         }
777         $a = array('siteurl' => $CFG->wwwroot);
778         $text .= get_string('updatenotificationfooter', 'core_admin', $a) . PHP_EOL;
779         $a = array('siteurl' => html_writer::link($CFG->wwwroot, $CFG->wwwroot));
780         $html .= html_writer::tag('footer', html_writer::tag('p', get_string('updatenotificationfooter', 'core_admin', $a),
781             array('style' => 'font-size:smaller; color:#333;')));
783         foreach ($admins as $admin) {
784             $message = new \stdClass();
785             $message->component         = 'moodle';
786             $message->name              = 'availableupdate';
787             $message->userfrom          = get_admin();
788             $message->userto            = $admin;
789             $message->subject           = get_string('updatenotificationsubject', 'core_admin', array('siteurl' => $CFG->wwwroot));
790             $message->fullmessage       = $text;
791             $message->fullmessageformat = FORMAT_PLAIN;
792             $message->fullmessagehtml   = $html;
793             $message->smallmessage      = get_string('updatenotifications', 'core_admin');
794             $message->notification      = 1;
795             message_send($message);
796         }
797     }
799     /**
800      * Compare two release labels and decide if they are the same
801      *
802      * @param string $remote release info of the available update
803      * @param null|string $local release info of the local code, defaults to $release defined in version.php
804      * @return boolean true if the releases declare the same minor+major version
805      */
806     protected function is_same_release($remote, $local=null) {
808         if (is_null($local)) {
809             $this->load_current_environment();
810             $local = $this->currentrelease;
811         }
813         $pattern = '/^([0-9\.\+]+)([^(]*)/';
815         preg_match($pattern, $remote, $remotematches);
816         preg_match($pattern, $local, $localmatches);
818         $remotematches[1] = str_replace('+', '', $remotematches[1]);
819         $localmatches[1] = str_replace('+', '', $localmatches[1]);
821         if ($remotematches[1] === $localmatches[1] and rtrim($remotematches[2]) === rtrim($localmatches[2])) {
822             return true;
823         } else {
824             return false;
825         }
826     }