Skip to content

Commit d7fb484

Browse files
authored
add: backend dependency readiness probe (#232)
* add: backend dependency readiness probe Pure-read probe reporting each federation backend's status (ok / missing / too_old / incompatible), source (bundled / standalone / none), installed version, and required minimum. Registers no hooks, writes no options, and does not touch the loader — callers (admin notices, status page, setup wizard, a future Requires Plugins cutover) render the answer. Refiled from the draft #165 as just the probe skeleton, dropping that PR's now-stale bundled-vs-released audit (its premise — the cutover is blocked on the next ActivityPub release — is dead now that AP 9.0.2 and Atmosphere 2.0.0 are released and bundled). Version floors are pinned to the bundled releases (AP 9.0.2, Atmosphere 2.0.0) with the placeholder/blurhash-release framing removed. * fix: report incompatible for unrecognized backend sources Review follow-ups (ce-code-review + codex): - STATUS_INCOMPATIBLE was declared (and whitelisted by a test) but no code path returned it, and a backend loaded from an unrecognized location wrongly reported OK. resolve_source() now returns SOURCE_UNKNOWN for a resolvable path that is neither the bundled tree nor under WP_PLUGIN_DIR (mu-plugin / platform shim / symlink outside the plugins dir), which evaluate() maps to STATUS_INCOMPATIBLE. Unresolvable paths still fall back to standalone so a transient FS hiccup can't flip to incompatible. - Normalize separators (wp_normalize_path) in the bundled/plugins prefix match so a genuinely bundled copy isn't misclassified as standalone on Windows (realpath returns backslashes there). - Tests: cover the unknown->incompatible path and a standalone strictly above the floor. * refactor: derive readiness floors from bundled version headers Per review feedback: the standalone floor should equal the version FOSSE bundles, and that version should have a single source of truth rather than a hand-maintained constant that drifts from the bundle. Replace the MIN_ACTIVITYPUB_VERSION / MIN_ATMOSPHERE_VERSION constants with min_activitypub_version() / min_atmosphere_version() accessors that read the bundled plugin's own Version: header via get_file_data() (memoized). Those headers are overwritten by tools/sync-bundled.sh on every resync, so the floor tracks the bundle automatically with zero code change and no drift. A new test reads the headers independently and asserts the accessors match, locking the floor to what actually ships. * test: exercise real standalone path; document the unknown source Address Copilot review on #232: - Document the SOURCE_UNKNOWN / STATUS_INCOMPATIBLE case in the class docblock's detection model (it was added during review but undocumented). - Add a test that classifies a RESOLVABLE dir under WP_PLUGIN_DIR as standalone via the real path-prefix match, rather than the unresolvable-path fallback the other standalone tests hit — closing the gap where those tests passed for a different reason than a live install. * test: exercise the standalone path without WP_Filesystem-flagged calls CI's Jetpack ruleset flags mkdir()/rmdir()/is_writable() (WP_Filesystem sniff) as failing warnings. Exercise the same resolvable-under-WP_PLUGIN_DIR standalone branch by passing WP_PLUGIN_DIR itself (resolves, matches the prefix by equality) instead of creating a fixture directory.
1 parent 3bf90d0 commit d7fb484

2 files changed

Lines changed: 563 additions & 0 deletions

File tree

src/class-backend-readiness.php

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
<?php
2+
/**
3+
* Readiness probe for FOSSE's federation backends.
4+
*
5+
* @package Automattic\Fosse
6+
*/
7+
8+
namespace Automattic\Fosse;
9+
10+
/**
11+
* Reports whether each federation backend (ActivityPub, Atmosphere) is
12+
* present, active, recent enough, and exposes the surface FOSSE consumes.
13+
*
14+
* This class does not register hooks, write options, or change loaders. It
15+
* is a pure read of the runtime: callers (admin notices, status page,
16+
* setup wizard, future `Requires Plugins` cutover) ask `*_status()` and
17+
* render the answer. The bundled vs. standalone load decision still lives
18+
* in `fosse.php`.
19+
*
20+
* Detection model:
21+
*
22+
* - "Source: bundled" — the loaded copy was required from
23+
* `<fosse>/bundled/<slug>/`. FOSSE controls the version on disk via
24+
* `tools/sync-bundled.sh`; the version constant on the bundle is the
25+
* last upstream tag, which can lag the actual code. We trust the bundle.
26+
* - "Source: standalone" — the loaded copy was required from
27+
* `WP_PLUGIN_DIR/<slug>/`. The constant reflects the released version
28+
* and is enforced against the bundled-version floor (see the
29+
* minimum-version policy below).
30+
* - "Source: none" — the backend's version constant is undefined, which
31+
* means neither copy is loaded.
32+
* - "Source: unknown" — the version constant IS defined but the loaded
33+
* copy resolves to a path that is neither `<fosse>/bundled/` nor under
34+
* `WP_PLUGIN_DIR` (an mu-plugin, platform shim, or symlink outside the
35+
* plugins dir). FOSSE can't vouch for such a loader's surface, so it is
36+
* reported `incompatible` rather than trusted. An unresolvable path
37+
* falls back to `standalone` (floor-enforced), not `unknown`.
38+
*
39+
* Minimum-version policy:
40+
*
41+
* The floor a *standalone* backend must meet is the version FOSSE currently
42+
* bundles and tests against (the reference implementation): a standalone
43+
* older than the bundle is not guaranteed to carry the hooks/filters FOSSE
44+
* relies on, so the probe conservatively reports it `too_old`. `too_old`
45+
* therefore means "older than what FOSSE ships", not "known-incompatible".
46+
*
47+
* The floor is read from the bundled plugin's own `Version:` header at
48+
* runtime — see {@see self::min_activitypub_version()} — NOT stored as a
49+
* duplicate constant. `tools/sync-bundled.sh` overwrites those headers on
50+
* every resync, so the floor tracks the bundle automatically with no code
51+
* change and no drift. (A `bundled` source is trusted regardless of its
52+
* version anyway — see the detection model above — so the floor only ever
53+
* gates a `standalone` source.)
54+
*/
55+
class Backend_Readiness {
56+
57+
public const STATUS_OK = 'ok';
58+
public const STATUS_MISSING = 'missing';
59+
public const STATUS_TOO_OLD = 'too_old';
60+
public const STATUS_INCOMPATIBLE = 'incompatible';
61+
62+
public const SOURCE_BUNDLED = 'bundled';
63+
public const SOURCE_STANDALONE = 'standalone';
64+
public const SOURCE_NONE = 'none';
65+
public const SOURCE_UNKNOWN = 'unknown';
66+
67+
/**
68+
* Bundled plugin main files (relative to this class) whose `Version:`
69+
* header is the single source of truth for each backend's standalone
70+
* floor. Refreshed on disk by `tools/sync-bundled.sh` on every resync.
71+
*
72+
* @var array<string, string>
73+
*/
74+
private const BUNDLED_MAIN_FILES = array(
75+
'activitypub' => '/../bundled/activitypub/activitypub.php',
76+
'atmosphere' => '/../bundled/atmosphere/atmosphere.php',
77+
);
78+
79+
/**
80+
* Request-scoped memo of each backend's bundled `Version:` header.
81+
*
82+
* @var array<string, string>
83+
*/
84+
private static $bundled_versions = array();
85+
86+
/**
87+
* Minimum ActivityPub version FOSSE supports as a standalone install:
88+
* the version FOSSE currently bundles, read from the bundled plugin's
89+
* `Version:` header so it never drifts from what actually ships.
90+
*
91+
* @return string
92+
*/
93+
public static function min_activitypub_version(): string {
94+
return self::bundled_version( 'activitypub' );
95+
}
96+
97+
/**
98+
* Minimum Atmosphere version FOSSE supports as a standalone install: the
99+
* currently-bundled version, read from its `Version:` header.
100+
*
101+
* @return string
102+
*/
103+
public static function min_atmosphere_version(): string {
104+
return self::bundled_version( 'atmosphere' );
105+
}
106+
107+
/**
108+
* Read (and memoize) the `Version:` header of a bundled backend's main
109+
* file. Returns '' when the file is unreadable — an impossible-in-practice
110+
* broken-deploy case where the whole probe is moot; an empty floor lets
111+
* every standalone pass rather than fabricating a version.
112+
*
113+
* @param string $slug Backend slug (`activitypub` or `atmosphere`).
114+
* @return string
115+
*/
116+
private static function bundled_version( string $slug ): string {
117+
if ( isset( self::$bundled_versions[ $slug ] ) ) {
118+
return self::$bundled_versions[ $slug ];
119+
}
120+
121+
$relative = self::BUNDLED_MAIN_FILES[ $slug ] ?? '';
122+
$version = '';
123+
124+
if ( '' !== $relative && function_exists( 'get_file_data' ) ) {
125+
$file = __DIR__ . $relative;
126+
if ( is_readable( $file ) ) {
127+
$data = get_file_data( $file, array( 'Version' => 'Version' ) );
128+
$version = isset( $data['Version'] ) ? (string) $data['Version'] : '';
129+
}
130+
}
131+
132+
self::$bundled_versions[ $slug ] = $version;
133+
134+
return $version;
135+
}
136+
137+
/**
138+
* Readiness of the ActivityPub backend.
139+
*
140+
* @return array{
141+
* slug: string,
142+
* status: string,
143+
* source: string,
144+
* installed_version: string|null,
145+
* required_version: string,
146+
* }
147+
*/
148+
public static function activitypub_status(): array {
149+
return self::evaluate(
150+
'activitypub',
151+
defined( 'ACTIVITYPUB_PLUGIN_VERSION' ) ? ACTIVITYPUB_PLUGIN_VERSION : null,
152+
defined( 'ACTIVITYPUB_PLUGIN_DIR' ) ? ACTIVITYPUB_PLUGIN_DIR : null,
153+
self::min_activitypub_version()
154+
);
155+
}
156+
157+
/**
158+
* Readiness of the Atmosphere backend.
159+
*
160+
* @return array{
161+
* slug: string,
162+
* status: string,
163+
* source: string,
164+
* installed_version: string|null,
165+
* required_version: string,
166+
* }
167+
*/
168+
public static function atmosphere_status(): array {
169+
return self::evaluate(
170+
'atmosphere',
171+
defined( 'ATMOSPHERE_VERSION' ) ? ATMOSPHERE_VERSION : null,
172+
defined( 'ATMOSPHERE_PLUGIN_DIR' ) ? ATMOSPHERE_PLUGIN_DIR : null,
173+
self::min_atmosphere_version()
174+
);
175+
}
176+
177+
/**
178+
* Aggregate status across both backends.
179+
*
180+
* @return array<string, array<string, mixed>> Keyed by slug.
181+
*/
182+
public static function all(): array {
183+
return array(
184+
'activitypub' => self::activitypub_status(),
185+
'atmosphere' => self::atmosphere_status(),
186+
);
187+
}
188+
189+
/**
190+
* Whether the two backends together expose everything FOSSE needs.
191+
*
192+
* Returns true when both report `STATUS_OK`. Callers that want to
193+
* degrade per-feature (e.g. AP works, Atmosphere doesn't) should
194+
* look at the individual `*_status()` results instead.
195+
*/
196+
public static function is_ready(): bool {
197+
foreach ( self::all() as $report ) {
198+
if ( self::STATUS_OK !== $report['status'] ) {
199+
return false;
200+
}
201+
}
202+
return true;
203+
}
204+
205+
/**
206+
* Build a single backend's status report.
207+
*
208+
* @param string $slug Plugin slug (`activitypub` / `atmosphere`).
209+
* @param string|null $version Loaded plugin's `*_VERSION` constant, or null.
210+
* @param string|null $plugin_dir Loaded plugin's `*_PLUGIN_DIR` constant, or null.
211+
* @param string $min_version Required minimum version.
212+
*/
213+
private static function evaluate( string $slug, ?string $version, ?string $plugin_dir, string $min_version ): array {
214+
if ( null === $version ) {
215+
return array(
216+
'slug' => $slug,
217+
'status' => self::STATUS_MISSING,
218+
'source' => self::SOURCE_NONE,
219+
'installed_version' => null,
220+
'required_version' => $min_version,
221+
);
222+
}
223+
224+
$source = self::resolve_source( $plugin_dir );
225+
226+
if ( self::SOURCE_BUNDLED === $source ) {
227+
return array(
228+
'slug' => $slug,
229+
'status' => self::STATUS_OK,
230+
'source' => $source,
231+
'installed_version' => $version,
232+
'required_version' => $min_version,
233+
);
234+
}
235+
236+
/*
237+
* A backend whose version constant is defined but whose loaded path
238+
* is neither the bundled tree nor anywhere under WP_PLUGIN_DIR is an
239+
* unrecognized loader (mu-plugin, platform shim, symlink outside the
240+
* plugins dir). FOSSE can't vouch for the surface such a copy exposes,
241+
* so it is reported incompatible rather than silently OK.
242+
*/
243+
if ( self::SOURCE_UNKNOWN === $source ) {
244+
return array(
245+
'slug' => $slug,
246+
'status' => self::STATUS_INCOMPATIBLE,
247+
'source' => $source,
248+
'installed_version' => $version,
249+
'required_version' => $min_version,
250+
);
251+
}
252+
253+
$status = version_compare( $version, $min_version, '>=' )
254+
? self::STATUS_OK
255+
: self::STATUS_TOO_OLD;
256+
257+
return array(
258+
'slug' => $slug,
259+
'status' => $status,
260+
'source' => $source,
261+
'installed_version' => $version,
262+
'required_version' => $min_version,
263+
);
264+
}
265+
266+
/**
267+
* Decide whether the loaded plugin came from FOSSE's bundled tree or
268+
* from a standalone install at the canonical WP plugins path.
269+
*
270+
* @param string|null $plugin_dir Loaded plugin's `*_PLUGIN_DIR` constant, or null.
271+
*/
272+
private static function resolve_source( ?string $plugin_dir ): string {
273+
/*
274+
* No dir constant to classify against — assume a standalone install
275+
* and enforce the version floor (conservative; also the shape the
276+
* unit tests exercise with synthetic paths).
277+
*/
278+
if ( null === $plugin_dir ) {
279+
return self::SOURCE_STANDALONE;
280+
}
281+
282+
$loaded = self::canonical( $plugin_dir );
283+
284+
/*
285+
* Unresolvable path (e.g. realpath() fails): fall back to standalone
286+
* so the version floor still applies rather than flipping to a hard
287+
* incompatible on a transient FS hiccup.
288+
*/
289+
if ( null === $loaded ) {
290+
return self::SOURCE_STANDALONE;
291+
}
292+
293+
$bundled = self::canonical( __DIR__ . '/../bundled' );
294+
if ( null !== $bundled && self::path_within( $loaded, $bundled ) ) {
295+
return self::SOURCE_BUNDLED;
296+
}
297+
298+
// Under WP_PLUGIN_DIR (canonical or non-canonical folder) — a real standalone, floor-enforced.
299+
$plugins = \defined( 'WP_PLUGIN_DIR' ) ? self::canonical( WP_PLUGIN_DIR ) : null;
300+
if ( null !== $plugins && self::path_within( $loaded, $plugins ) ) {
301+
return self::SOURCE_STANDALONE;
302+
}
303+
304+
// Resolvable but outside both the bundle and the plugins dir: an unrecognized loader.
305+
return self::SOURCE_UNKNOWN;
306+
}
307+
308+
/**
309+
* Whether `$child` is `$parent` itself or a descendant of it, comparing
310+
* separator-normalized paths so the prefix test holds on Windows
311+
* (`realpath()` returns backslashes there).
312+
*
313+
* @param string $child Canonical child path.
314+
* @param string $parent Canonical parent path.
315+
* @return bool
316+
*/
317+
private static function path_within( string $child, string $parent ): bool {
318+
$child = \wp_normalize_path( $child );
319+
$parent = \wp_normalize_path( $parent );
320+
321+
return $child === $parent || str_starts_with( $child, $parent . '/' );
322+
}
323+
324+
/**
325+
* Resolve a path to its canonical, trailing-slash-free form when the
326+
* filesystem can resolve it; return null otherwise so the caller can
327+
* fall back safely.
328+
*
329+
* @param string $path Path to canonicalize.
330+
*/
331+
private static function canonical( string $path ): ?string {
332+
$real = realpath( $path );
333+
return false === $real ? null : rtrim( $real, '/\\' );
334+
}
335+
}

0 commit comments

Comments
 (0)