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