Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
12.93% covered (danger)
12.93%
19 / 147
25.00% covered (danger)
25.00%
2 / 8
CRAP
n/a
0 / 0
pcg_healthcheck_capture_snapshot
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
90
pcg_healthcheck_after_update
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
342
pcg_healthcheck_log_rollback
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
pcg_healthcheck_is_plugin_update
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
pcg_healthcheck_is_plugin_pre_install_update
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
pcg_healthcheck_stash_notice
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
pcg_healthcheck_render_notice
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
42
pcg_healthcheck_describe_rollback
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2/**
3 * Post-update health check — probes the site once after a batch of plugin
4 * updates and rolls back every snapshot in the batch on failure.
5 *
6 * Complements `update-guard.php` (parse-error gate before install) with a
7 * runtime probe after install, so fatals that only surface at load or init
8 * (missing class, version drift, etc.) don't leave an active site broken.
9 *
10 * See README.md for the full flow.
11 *
12 * @package automattic/jetpack-mu-wpcom
13 */
14
15add_filter( 'upgrader_pre_install', 'pcg_healthcheck_capture_snapshot', 10, 2 );
16add_action( 'upgrader_process_complete', 'pcg_healthcheck_after_update', 99, 2 );
17add_action( 'admin_notices', 'pcg_healthcheck_render_notice' );
18
19/**
20 * Hooked on `upgrader_pre_install`. Captures a snapshot for every plugin
21 * that's about to be updated, so we have a version + was_active to fall
22 * back to when the post-update probe fails.
23 *
24 * @param bool|WP_Error $return     Upgrade status passed through.
25 * @param array         $hook_extra { type, action, plugin? }.
26 * @return bool|WP_Error Passed through unchanged.
27 */
28function pcg_healthcheck_capture_snapshot( $return, $hook_extra ) {
29    if ( ! apply_filters( 'pcg_guard_updates', true ) ) {
30        return $return;
31    }
32    if ( pcg_force_override_active( 'update_plugins' ) ) {
33        return $return;
34    }
35    if ( ! pcg_healthcheck_is_plugin_pre_install_update( $hook_extra ) ) {
36        return $return;
37    }
38    $plugin_file = (string) ( $hook_extra['plugin'] ?? '' );
39    if ( '' === $plugin_file ) {
40        return $return;
41    }
42    // Skip inactive (probe ignores them) and network-active (probe is
43    // per-site, rollback flips one plugin — wrong shape for network).
44    if ( ! function_exists( 'is_plugin_active' ) ) {
45        require_once ABSPATH . 'wp-admin/includes/plugin.php';
46    }
47    if ( ! is_plugin_active( $plugin_file ) ) {
48        return $return;
49    }
50    if ( is_multisite() && is_plugin_active_for_network( $plugin_file ) ) {
51        return $return;
52    }
53    PCG_Snapshot::capture( $plugin_file );
54    return $return;
55}
56
57/**
58 * Hooked on `upgrader_process_complete`. Probes the site once after the
59 * batch of plugin updates and, on fatal, rolls back every snapshotted
60 * plugin in the batch.
61 *
62 * One probe is enough because MODE_UPDATE checks whether the site as a
63 * whole bootstraps — it doesn't isolate which plugin caused the fatal.
64 * If the site is broken, we can't safely tell which plugin to blame, so
65 * we restore the whole batch.
66 *
67 * @param WP_Upgrader|null $upgrader   Upgrader instance (unused).
68 * @param array            $hook_extra { type, action, plugins? }.
69 * @return void
70 */
71function pcg_healthcheck_after_update( $upgrader, $hook_extra ) {
72    if ( ! apply_filters( 'pcg_guard_updates', true ) ) {
73        return;
74    }
75    if ( pcg_force_override_active( 'update_plugins' ) ) {
76        return;
77    }
78    if ( ! pcg_healthcheck_is_plugin_update( $hook_extra ) ) {
79        return;
80    }
81
82    $plugin_files = array();
83    if ( ! empty( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) {
84        $plugin_files = array_values( array_filter( array_map( 'strval', $hook_extra['plugins'] ) ) );
85    } elseif ( ! empty( $hook_extra['plugin'] ) ) {
86        $plugin_files[] = (string) $hook_extra['plugin'];
87    }
88
89    // Drain all stashed snapshots up front, keeping only the ones that
90    // were active and whose new files are still on disk. Anything else
91    // can't take the site down, so it doesn't need a probe.
92    if ( ! function_exists( 'get_plugin_data' ) ) {
93        require_once ABSPATH . 'wp-admin/includes/plugin.php';
94    }
95    $candidates = array();
96    foreach ( $plugin_files as $plugin_file ) {
97        $snapshot = PCG_Snapshot::consume( $plugin_file );
98        if ( ! is_array( $snapshot ) ) {
99            continue;
100        }
101        if ( empty( $snapshot['was_active'] ) ) {
102            PCG_Snapshot::cleanup_backup( $snapshot );
103            continue;
104        }
105        $plugin_main = WP_PLUGIN_DIR . '/' . $plugin_file;
106        if ( ! is_file( $plugin_main ) ) {
107            continue;
108        }
109
110        $new_data    = get_plugin_data( $plugin_main, false, false );
111        $plugin_name = (string) ( $new_data['Name'] ?? '' );
112        if ( '' === $plugin_name ) {
113            $plugin_name = $plugin_file;
114        }
115        $candidates[] = array(
116            'plugin_file' => $plugin_file,
117            'snapshot'    => $snapshot,
118            'plugin_main' => $plugin_main,
119            'plugin_name' => $plugin_name,
120            'new_version' => (string) ( $new_data['Version'] ?? '' ),
121        );
122    }
123
124    if ( empty( $candidates ) ) {
125        return;
126    }
127
128    // MODE_UPDATE skips require_once and just observes the bootstrap, so one
129    // probe suffices for the whole batch. The paths are only readability checks.
130    $tester       = new PCG_Load_Tester();
131    $plugin_mains = array_values( array_column( $candidates, 'plugin_main' ) );
132    $result       = $tester->test( $plugin_mains, PCG_Load_Tester::MODE_UPDATE );
133    $status       = (string) ( $result['status'] ?? '' );
134
135    // Anything other than a captured fatal is a no-op rollback-wise: ok =
136    // the update is fine; error = inconclusive transport failure we don't
137    // want to act on. Either way, drop local backups so they don't
138    // linger under the temp dir.
139    if ( 'fatal' !== $status && 'throwable' !== $status ) {
140        foreach ( $candidates as $candidate ) {
141            PCG_Snapshot::cleanup_backup( $candidate['snapshot'] );
142        }
143        return;
144    }
145
146    foreach ( $candidates as $candidate ) {
147        $rollback = PCG_Rollback::to_snapshot( $candidate['snapshot'] );
148        pcg_healthcheck_stash_notice( $candidate['plugin_file'], $result, $rollback, $candidate['plugin_name'], $candidate['new_version'] );
149        pcg_healthcheck_log_rollback( $candidate, $result, $rollback );
150
151        /**
152         * Fires after a post-update probe fails and rollback has been attempted.
153         *
154         * @param string $plugin_file Basename relative to WP_PLUGIN_DIR.
155         * @param array  $probe       Probe result from PCG_Load_Tester::test().
156         * @param array  $rollback    Result from PCG_Rollback::to_snapshot().
157         * @param array  $snapshot    The consumed snapshot.
158         */
159        do_action( 'pcg_post_update_diagnosis', $candidate['plugin_file'], $result, $rollback, $candidate['snapshot'] );
160    }
161}
162
163/**
164 * Log a post-update rollback to logstash. Best-effort.
165 *
166 * @param array $candidate Per-plugin context built in `pcg_healthcheck_after_update()`.
167 * @param array $probe     Shared probe verdict from `PCG_Load_Tester::test()`.
168 * @param array $rollback  Result from `PCG_Rollback::to_snapshot()`.
169 * @return void
170 */
171function pcg_healthcheck_log_rollback( array $candidate, array $probe, array $rollback ) {
172    pcg_log_event(
173        'Update rolled back',
174        array(
175            'plugin'           => (string) $candidate['plugin_file'],
176            'new_version'      => (string) $candidate['new_version'],
177            'previous_version' => (string) ( $candidate['snapshot']['version'] ?? '' ),
178            'probe_status'     => (string) ( $probe['status'] ?? '' ),
179            // Basename only — absolute paths leak install layout.
180            'probe_file'       => isset( $probe['file'] ) ? basename( (string) $probe['file'] ) : '',
181            'probe_line'       => (int) ( $probe['line'] ?? 0 ),
182            'probe_reason'     => (string) ( $probe['message'] ?? '' ),
183            'rollback_status'  => (string) ( $rollback['status'] ?? '' ),
184            'restored_to'      => (string) ( $rollback['restored_to'] ?? '' ),
185        )
186    );
187}
188
189/**
190 * Is this $hook_extra a plugin update (not an install, not a theme)?
191 *
192 * Use on `upgrader_process_complete`, where WP core always populates
193 * `type` and `action` even for bulk runs.
194 *
195 * @param array $hook_extra { type, action, ... }.
196 * @return bool
197 */
198function pcg_healthcheck_is_plugin_update( $hook_extra ) {
199    $type   = (string) ( $hook_extra['type'] ?? '' );
200    $action = (string) ( $hook_extra['action'] ?? '' );
201    return 'plugin' === $type && 'update' === $action;
202}
203
204/**
205 * Is this $hook_extra a plugin update at the `upgrader_pre_install` filter?
206 *
207 * `Plugin_Upgrader::bulk_upgrade()` only passes `plugin` (and `temp_backup`)
208 * in the per-plugin `hook_extra` — `type` and `action` aren't set, so the
209 * post-install predicate would miss every bulk update. We disambiguate by
210 * presence of the `plugin` key plus negative checks: if `type`/`action`
211 * happen to be set (single-update path), they must say plugin/update.
212 * Theme bulk updates use a `theme` key instead, and plugin installs don't
213 * set `plugin`, so neither false-matches.
214 *
215 * @param array $hook_extra { plugin, type?, action?, temp_backup?, ... }.
216 * @return bool
217 */
218function pcg_healthcheck_is_plugin_pre_install_update( $hook_extra ) {
219    if ( empty( $hook_extra['plugin'] ) ) {
220        return false;
221    }
222    if ( isset( $hook_extra['type'] ) && 'plugin' !== $hook_extra['type'] ) {
223        return false;
224    }
225    if ( isset( $hook_extra['action'] ) && 'update' !== $hook_extra['action'] ) {
226        return false;
227    }
228    return true;
229}
230
231/**
232 * Stash a site-wide admin notice describing the probe failure and rollback
233 * outcome. Site-wide (not per-user) so cron/CLI updates — which run with
234 * no current user — still surface a notice to admins on next page load.
235 *
236 * @param string $plugin_file Basename relative to WP_PLUGIN_DIR.
237 * @param array  $probe       Probe result from PCG_Load_Tester::test().
238 * @param array  $rollback    Result from PCG_Rollback::to_snapshot().
239 * @param string $plugin_name Human-readable plugin Name header (falls back to $plugin_file).
240 * @param string $new_version Version we tried to upgrade to (Version header on the new files).
241 * @return void
242 */
243function pcg_healthcheck_stash_notice( $plugin_file, $probe, $rollback, $plugin_name = '', $new_version = '' ) {
244    $key      = 'pcg_healthcheck_notice';
245    $existing = get_transient( $key );
246    if ( ! is_array( $existing ) ) {
247        $existing = array();
248    }
249    $existing[ $plugin_file ] = array(
250        'reason'      => pcg_guard_format_block_reason( $probe ),
251        'rollback'    => $rollback,
252        'plugin_name' => '' !== $plugin_name ? $plugin_name : $plugin_file,
253        'new_version' => $new_version,
254    );
255    set_transient( $key, $existing, 10 * MINUTE_IN_SECONDS );
256}
257
258/**
259 * Render any stashed post-update notices to users who can manage plugins.
260 *
261 * @return void
262 */
263function pcg_healthcheck_render_notice() {
264    if ( ! current_user_can( 'manage_options' ) ) {
265        return;
266    }
267    $key      = 'pcg_healthcheck_notice';
268    $messages = get_transient( $key );
269    if ( ! is_array( $messages ) || empty( $messages ) ) {
270        return;
271    }
272    delete_transient( $key );
273    ?>
274    <div class="notice notice-error">
275        <p><strong><?php esc_html_e( 'WordPress.com detected a fatal after a plugin update and attempted to restore the previous version:', 'jetpack-mu-wpcom' ); ?></strong></p>
276        <ul style="list-style:disc;padding-inline-start:24px;">
277            <?php
278            foreach ( $messages as $plugin => $info ) :
279                $name        = (string) ( $info['plugin_name'] ?? $plugin );
280                $new_version = (string) ( $info['new_version'] ?? '' );
281                $headline    = '' !== $new_version
282                    ? sprintf(
283                        /* translators: 1: plugin name, 2: version we attempted to upgrade to. */
284                        __( '%1$s (update to %2$s)', 'jetpack-mu-wpcom' ),
285                        $name,
286                        $new_version
287                    )
288                    : $name;
289                ?>
290                <li>
291                    <strong><?php echo esc_html( $headline ); ?></strong> — <?php echo esc_html( (string) $info['reason'] ); ?>
292                    <br />
293                    <em><?php echo esc_html( pcg_healthcheck_describe_rollback( $info['rollback'] ) ); ?></em>
294                </li>
295            <?php endforeach; ?>
296        </ul>
297    </div>
298    <?php
299}
300
301/**
302 * Human-readable summary of a rollback result.
303 *
304 * @param array $rollback Rollback result from PCG_Rollback::to_snapshot().
305 * @return string
306 */
307function pcg_healthcheck_describe_rollback( $rollback ) {
308    $status = (string) ( $rollback['status'] ?? '' );
309    $to     = (string) ( $rollback['restored_to'] ?? '' );
310    switch ( $status ) {
311        case 'reactivated':
312            return sprintf(
313                /* translators: %s: previous plugin version. */
314                __( 'Restored to version %s and reactivated.', 'jetpack-mu-wpcom' ),
315                $to
316            );
317        case 'restored':
318            return sprintf(
319                /* translators: %s: previous plugin version. */
320                __( 'Restored to version %s; left deactivated.', 'jetpack-mu-wpcom' ),
321                $to
322            );
323        case 'rollback_unavailable':
324            return __( 'Rollback unavailable (previous version not downloadable); plugin left deactivated.', 'jetpack-mu-wpcom' );
325        case 'rollback_failed':
326        default:
327            return __( 'Rollback failed; plugin left deactivated. Please investigate.', 'jetpack-mu-wpcom' );
328    }
329}