From a3df5febe42d31c6fdd0393a8e78af5acfb70d76 Mon Sep 17 00:00:00 2001 From: Coysh Date: Mon, 17 Aug 2026 12:42:38 +0100 Subject: [PATCH] feat: add system.v3, describing the Craft install and not just its disk The runtime report has always described the disk a site sits on - volume sizes, free space, PHP limits - and said nothing about the Craft install doing the sitting. system.v3 adds four sections a site can answer about itself and nothing outside it can observe: deprecation and session counts, whether a security key is set and whether the control panel is still at the default trigger; the database size; whether Craft's writable directories are writable; and which required PHP extensions are absent, plus which imaging library is available. The database size is the one worth calling out. Backups are the reason this package exists, and until now the report described every asset volume and the disk underneath them while saying nothing about the size of the thing being backed up - which is the figure that decides whether a backup finishes. v1 and v2 are untouched, so every existing combination of connector and platform keeps working and neither side has to be upgraded first. Each addition has a more useful shape that is deliberately refused, and the tests are what keep refusing it. A deprecation warning is easier to act on with its message, but the message names the site's own templates; a database size is easier to act on with a table breakdown, but table names describe somebody's business; a moved control panel is easier to find if you know where it moved to, which is exactly what moving it was meant to prevent. So this sends the count, the total and the boolean. missing_extensions is a closed enum of Craft's published requirements rather than free strings, so it cannot become the loaded-extension inventory that php.extensions is reduced to a count to avoid, and paths is a fixed set of Craft's own directory names because a free-form label is a filesystem path with extra steps. Response compression is not here. It is decided at the edge, so a site behind a CDN would report its origin's answer rather than the one a visitor gets. --- CHANGELOG.md | 46 ++++++ composer.json | 2 +- fixtures/system.v3/forbidden-content.json | 36 +++++ fixtures/system.v3/valid.json | 53 ++++++ schemas/system.v3.json | 188 ++++++++++++++++++++++ src/Protocol.php | 2 +- tests/SystemSchemaTest.php | 109 +++++++++++++ 7 files changed, 434 insertions(+), 2 deletions(-) create mode 100644 fixtures/system.v3/forbidden-content.json create mode 100644 fixtures/system.v3/valid.json create mode 100644 schemas/system.v3.json diff --git a/CHANGELOG.md b/CHANGELOG.md index c650399..f741a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,52 @@ This package is the wire contract between the Manager platform and the connector A change here is a change to what a site may send, so every entry says what was added and - more usefully - what was deliberately left out of it. +## 1.9.0 - 2026-08-17 + +A runtime report that can describe the Craft install, not just the disk it sits on. + +`system.v1` and `system.v2` are untouched. A connector sending either keeps working against any +platform that accepted it before, and a platform that has not been upgraded keeps receiving exactly +what it received - the connector picks the newest version both sides know, so neither has to move +first. + +### Added + +- **`system.v3`.** Everything `system.v2` carries, plus four sections a site can answer about itself + and nothing outside it can observe: + - `craft` - the number of distinct deprecation warnings Craft has recorded, the row count of its + sessions table, whether a security key is configured, and whether the control panel is still at + the default trigger. + - `database.size_bytes` - the total including indexes. This is the number that decides whether a + backup finishes, and until now the report described every asset volume and the disk underneath + them while saying nothing about the thing being backed up. + - `paths` - whether Craft's `storage`, `cpresources` and project-config directories are writable. + - `php.missing_extensions` and `php.image_driver` - which of Craft's required extensions are + absent, and which imaging library is available. + +### What was left out of it, and why + +Every one of these has a more useful shape that this schema refuses. + +- **A deprecation warning without its message is harder to act on.** The message names a template, a + file and a line, and those are the site's own code. The count says whether there is a problem; + finding it is done on the site, where the code already is. +- **A database size without a table breakdown is harder to act on.** A per-table row count describes + the shape of somebody's content, and a table *name* often describes their business. The total + answers the question a backup asks. +- **`cp_trigger_default` is a boolean, and there is no field for the trigger itself.** A site that + moved its control panel moved it somewhere it would rather not have written down; reporting the + new address in somebody else's dashboard would undo the change being reported. +- **`security_key_set` is presence, never the key.** +- **`missing_extensions` is a closed enum of Craft's published requirements**, so it cannot become + the inventory of loaded extensions that `php.extensions` deliberately reduces to a count. `paths` + is a fixed set of Craft's own directory names for the same reason a volume sends a handle rather + than a path: a free-form label is a filesystem path with extra steps. +- **`image_driver` is which library, never which version.** The version belongs to the host. +- **Response compression is not here at all.** It is decided at the edge, so a site behind a CDN + would report its origin's answer rather than the one a visitor receives. It is observed from + outside instead. + ## 1.8.1 - 2026-08-09 No wire change. No schema, canonical string, signature or fixture moved, so this is interchangeable diff --git a/composer.json b/composer.json index a6b78d6..a4b6097 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "coysh-digital/manager-protocol", "description": "Wire contract shared by the Manager platform and the Manager Connector for Craft CMS.", - "version": "1.8.1", + "version": "1.9.0", "type": "library", "license": "MIT", "keywords": [ diff --git a/fixtures/system.v3/forbidden-content.json b/fixtures/system.v3/forbidden-content.json new file mode 100644 index 0000000..ff53fe5 --- /dev/null +++ b/fixtures/system.v3/forbidden-content.json @@ -0,0 +1,36 @@ +{ + "schema_version": "system.v3", + "collected_at": 1785400000, + "php": { + "version": "8.3.14", + "extensions": 47, + "loaded": ["core", "curl", "ioncube_loader", "newrelic"], + "missing_extensions": ["intl", "ioncube_loader"], + "image_driver": "imagick 3.7.0", + "ini_path": "/etc/php/8.3/fpm/php.ini" + }, + "craft": { + "deprecation_count": 12, + "security_key_set": true, + "security_key": "vFq3mZ8pR2wK7nT4xL9dB6sG1hJ5cY0a", + "cp_trigger_default": false, + "cp_trigger": "acme-secret-panel", + "messages": [ + "templates/_layouts/base.twig line 42: craft.request is deprecated", + "modules/acme/src/Payroll.php line 118: getUser() is deprecated" + ] + }, + "database": { + "size_bytes": 21390950, + "name": "acme_production", + "tables": [ + { "name": "acme_payroll", "rows": 4102 }, + { "name": "users", "rows": 918 } + ] + }, + "paths": { + "storage": true, + "path": "/var/www/vhosts/example.org/storage", + "web_root": "/var/www/vhosts/example.org/web" + } +} diff --git a/fixtures/system.v3/valid.json b/fixtures/system.v3/valid.json new file mode 100644 index 0000000..1b00a7b --- /dev/null +++ b/fixtures/system.v3/valid.json @@ -0,0 +1,53 @@ +{ + "schema_version": "system.v3", + "collected_at": 1785400000, + "storage": { + "volumes": [ + { "handle": "images", "bytes": 4294967296, "files": 18402, "measured": true, "location": "local" }, + { "handle": "documents", "bytes": 214748364, "files": 903, "measured": true, "location": "local" }, + { "handle": "archive", "bytes": 0, "measured": false, "location": "remote", "unmeasured_reason": "remote" }, + { "handle": "video", "bytes": 8589934592, "files": 214, "measured": false, "location": "local", "unmeasured_reason": "timeout" }, + { "handle": "legacy", "bytes": 0, "measured": false, "location": "local", "unmeasured_reason": "unreadable" } + ], + "storage_bytes": 1073741824, + "disk_free_bytes": 21474836480, + "disk_total_bytes": 107374182400 + }, + "php": { + "version": "8.3.14", + "sapi": "fpm-fcgi", + "memory_limit_bytes": 268435456, + "max_execution_time": 120, + "upload_max_filesize_bytes": 67108864, + "post_max_size_bytes": 67108864, + "max_input_vars": 1000, + "opcache_enabled": true, + "opcache_memory_used_bytes": 94371840, + "opcache_memory_free_bytes": 39845888, + "extensions": 47, + "missing_extensions": ["intl"], + "image_driver": "imagick" + }, + "craft": { + "deprecation_count": 12, + "session_rows": 29, + "security_key_set": true, + "cp_trigger_default": false + }, + "database": { + "size_bytes": 21390950 + }, + "paths": { + "storage": true, + "cpresources": true, + "config_project": false + }, + "response": { + "samples": 200, + "window_hours": 6, + "mean_ms": 84.3, + "p50_ms": 71.0, + "p95_ms": 212.5, + "max_ms": 1840.0 + } +} diff --git a/schemas/system.v3.json b/schemas/system.v3.json new file mode 100644 index 0000000..b494d84 --- /dev/null +++ b/schemas/system.v3.json @@ -0,0 +1,188 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://protocol.coysh.digital/system.v3.json", + "title": "system.v3", + "description": "system.v2 plus what the site can say about its own Craft install that nothing outside it can observe: how much deprecated code is still running, how large the database is, whether the directories Craft writes to are writable, whether a security key is set, whether the control panel is still at the default address, and which of Craft's required PHP extensions are absent.\n\nEvery addition is a count, a boolean or a member of a closed set, and each one was chosen over a more useful shape for the same reason v2's `location` is two values rather than an adapter name.\n\n`security_key_set` is presence, never the key. `cp_trigger_default` says whether the trigger equals Craft's published default and never what it actually is - a site that moved its control panel moved it to somewhere it would rather not have written down in somebody else's dashboard, and reporting the new address would undo the thing it did. `paths` names Craft's own directories from a fixed list, so a site cannot report a filesystem path by choosing a label. `missing_extensions` is drawn from an enum of Craft's published requirements, so it can only ever say which of a public, fixed list is absent - the count in `extensions` stays exactly as it was, because the full list of what is loaded fingerprints the host and this does not.\n\nWhat is deliberately not here: response compression. It is a property of what the server sends a visitor, it is decided at the edge, and a site behind a CDN would report its origin's answer rather than the one anybody receives. It is observed from outside instead.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "collected_at"], + "properties": { + "schema_version": { + "type": "string", + "enum": ["system.v3"] + }, + "collected_at": { + "description": "Unix timestamp at which the connector gathered this data.", + "type": "integer" + }, + + "storage": { + "description": "Sizes of the volumes Craft manages. Labels are a closed set, so a site cannot name a directory here and thereby report a path.", + "type": "object", + "additionalProperties": false, + "properties": { + "volumes": { + "type": "array", + "maxItems": 50, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["handle", "bytes"], + "properties": { + "handle": { + "description": "The asset volume's handle. Not its path.", + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "bytes": { "type": "integer", "minimum": 0 }, + "files": { "type": "integer", "minimum": 0 }, + "measured": { + "description": "False when the volume could not be walked. A size of zero and a size nobody could measure are different facts, and `unmeasured_reason` says which of the several causes applied.", + "type": "boolean" + }, + "location": { + "description": "Where this volume's files are. `local` means the server's own filesystem, so the bytes count towards its disk; `remote` means an object store or anything else reached over the network, so they do not and the disk figures elsewhere in this report say nothing about them. Two values on purpose - see system.v2's description for why it is not the adapter's name.", + "type": "string", + "enum": ["local", "remote"] + }, + "unmeasured_reason": { + "description": "Why `measured` is false. `remote` - the files are not on this server and walking them would mean an API call per directory, billed to the site, to satisfy a dashboard. `timeout` - the walk started and ran out of the connector's storageWalkSeconds budget, so `bytes` and `files` are a floor rather than a total. `unreadable` - the path resolved to nothing this process could open, which is a configuration fault worth someone looking at. Absent when `measured` is true.", + "type": "string", + "enum": ["remote", "timeout", "unreadable"] + } + } + } + }, + + "storage_bytes": { + "description": "Craft's own storage directory: logs, caches, compiled templates, backups it has taken itself.", + "type": "integer", + "minimum": 0 + }, + "disk_free_bytes": { "type": "integer", "minimum": 0 }, + "disk_total_bytes": { "type": "integer", "minimum": 0 } + } + }, + + "php": { + "description": "Runtime limits, as numbers. Never phpinfo(), never a path, never an ini file, and never the value of any setting that could name one.", + "type": "object", + "additionalProperties": false, + "properties": { + "version": { "type": "string", "maxLength": 32 }, + "sapi": { "type": "string", "maxLength": 32 }, + "memory_limit_bytes": { + "description": "-1 where the limit is unlimited, which is a real setting and not an error.", + "type": "integer", + "minimum": -1 + }, + "max_execution_time": { "type": "integer", "minimum": 0 }, + "upload_max_filesize_bytes": { "type": "integer", "minimum": 0 }, + "post_max_size_bytes": { "type": "integer", "minimum": 0 }, + "max_input_vars": { "type": "integer", "minimum": 0 }, + "opcache_enabled": { "type": "boolean" }, + "opcache_memory_used_bytes": { "type": "integer", "minimum": 0 }, + "opcache_memory_free_bytes": { "type": "integer", "minimum": 0 }, + "extensions": { + "description": "Count only. The list itself would fingerprint the host in a way a byte count does not, and `missing_extensions` does not change that - it answers a closed question about a public list rather than describing what is installed.", + "type": "integer", + "minimum": 0 + }, + "missing_extensions": { + "description": "Which of Craft's required extensions are not loaded. A closed enum rather than free strings, so this cannot become the inventory of loaded extensions that `extensions` deliberately refuses to be. An empty array and an absent field mean different things: empty is a site that checked and found none missing, absent is a connector that did not look.\n\nThe imaging extensions are not in the list. Craft needs one of two rather than both, so \"missing\" is the wrong question about them and `image_driver` answers the right one.", + "type": "array", + "maxItems": 14, + "items": { + "type": "string", + "enum": [ + "ctype", + "curl", + "dom", + "fileinfo", + "iconv", + "intl", + "json", + "mbstring", + "openssl", + "pcre", + "pdo", + "reflection", + "spl", + "zip" + ] + } + }, + "image_driver": { + "description": "Which imaging library Craft has available. `none` is a real answer and a serious one - a site in that state cannot generate a transform. Three values, and never a version string: the version belongs to the host, and what a reader can act on is which of the two libraries is in play.", + "type": "string", + "enum": ["gd", "imagick", "none"] + } + } + }, + + "craft": { + "description": "Facts about the Craft install that only the site can see. Counts and booleans; no setting's value appears here.", + "type": "object", + "additionalProperties": false, + "properties": { + "deprecation_count": { + "description": "How many distinct deprecation warnings Craft has recorded. The count, never the messages - a deprecation warning names a template, a file and a line, and those are the site's code.", + "type": "integer", + "minimum": 0 + }, + "session_rows": { + "description": "Rows in Craft's sessions table. A number nobody looks at until it is in the millions, which is what an install that has never run garbage collection looks like. No session, no token and no user id travels with it.", + "type": "integer", + "minimum": 0 + }, + "security_key_set": { + "description": "Whether a security key is configured. Presence, never the key. False means every token, session and encrypted value on the site is built on nothing, which is worth a platform saying out loud.", + "type": "boolean" + }, + "cp_trigger_default": { + "description": "Whether the control panel is still reachable at Craft's default trigger. True means it is. False means somebody moved it, and this schema does not say where - see this schema's description for why reporting the new address would undo the change it is reporting.", + "type": "boolean" + } + } + }, + + "database": { + "description": "How large the database is. A size, never a table listing and never a row count per table: the first is what decides whether a backup finishes, and the second starts describing the shape of somebody's content.", + "type": "object", + "additionalProperties": false, + "properties": { + "size_bytes": { + "description": "Total size including indexes, as the engine reports it. Approximate on MySQL and MariaDB, which is fine for the question it answers - a backup that has grown from 200 MB to 9 GB is a different backup, and nobody needs the exact byte to see that.", + "type": "integer", + "minimum": 0 + } + } + }, + + "paths": { + "description": "Whether the directories Craft has to write to are writable. A fixed set of Craft's own directory names, so a site cannot report a filesystem path by choosing a label - which is the same reason a volume sends a handle rather than a path. Absent for a connector that did not look; a directory absent from a present object is one it could not determine.", + "type": "object", + "additionalProperties": false, + "properties": { + "storage": { "type": "boolean" }, + "cpresources": { "type": "boolean" }, + "config_project": { "type": "boolean" } + } + }, + + "response": { + "description": "How long the site took to build its own responses, sampled on the site from traffic that was happening anyway. This is server processing time - the connector cannot see network, TLS or queueing - so it is not time to first byte and the interface does not call it that.", + "type": "object", + "additionalProperties": false, + "properties": { + "samples": { "type": "integer", "minimum": 0 }, + "window_hours": { "type": "integer", "minimum": 1, "maximum": 168 }, + "mean_ms": { "type": "number", "minimum": 0 }, + "p50_ms": { "type": "number", "minimum": 0 }, + "p95_ms": { "type": "number", "minimum": 0 }, + "max_ms": { "type": "number", "minimum": 0 } + } + } + } +} diff --git a/src/Protocol.php b/src/Protocol.php index 4042cb9..9dc971a 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -22,7 +22,7 @@ final class Protocol /** * @var string Package version. Independent of the platform and connector versions. */ - public const VERSION = '1.8.1'; + public const VERSION = '1.9.0'; /** * @var string Prefix on the canonical string a connector signs. diff --git a/tests/SystemSchemaTest.php b/tests/SystemSchemaTest.php index 68c386b..8e0fcdf 100644 --- a/tests/SystemSchemaTest.php +++ b/tests/SystemSchemaTest.php @@ -202,3 +202,112 @@ expect(SchemaValidator::forSchema('system.v1')->validate($v2))->not->toBe([]); }); + +/* + | system.v3. + | + | v1 and v2 are untouched, so every combination of connector and platform that worked before still + | works and nothing has to be upgraded in step. + | + | What v3 adds is the first set of facts in this schema that a site could answer *usefully* by + | sending something identifying. A deprecation warning is only actionable if you know which template + | raised it; a database size is only actionable if you know which table grew; a moved control panel + | is only findable if you know where it moved to. In every one of those cases the useful answer is + | the site's code, the shape of its content, or a secret it moved on purpose - so v3 sends the + | count, the total and the boolean, and these tests are the reason it keeps doing so. + */ +it('accepts a v3 report describing the Craft install', function (): void { + expect(SchemaValidator::forSchema('system.v3')->validate(fixture('system.v3/valid.json'))) + ->toBe([]); +}); + +it('refuses the identifying half of every fact v3 added', function (): void { + $joined = implode(' ', SchemaValidator::forSchema('system.v3')->validate(fixture('system.v3/forbidden-content.json'))); + + // The security key beside the boolean that says one is set, the trigger beside the boolean that + // says it moved, the deprecation messages beside their count, the table list beside the total + // size, a path beside the writability it describes, and the loaded-extension list beside the + // count that exists precisely so the list does not have to travel. + expect($joined)->toContain('.security_key is') + ->and($joined)->toContain('.cp_trigger is') + ->and($joined)->toContain('.messages is') + ->and($joined)->toContain('.tables is') + ->and($joined)->toContain('.name is') + ->and($joined)->toContain('.path is') + ->and($joined)->toContain('.web_root is') + ->and($joined)->toContain('.loaded is') + ->and($joined)->toContain('.ini_path is'); +}); + +it('never echoes a rejected v3 value either', function (): void { + $joined = implode(' ', SchemaValidator::forSchema('system.v3')->validate(fixture('system.v3/forbidden-content.json'))); + + expect($joined)->not->toContain('vFq3mZ8pR2wK7nT4xL9dB6sG1hJ5cY0a') + ->and($joined)->not->toContain('acme-secret-panel') + ->and($joined)->not->toContain('acme_production') + ->and($joined)->not->toContain('Payroll.php') + ->and($joined)->not->toContain('/var/www'); +}); + +it('keeps missing_extensions to Craft\'s published requirements', function (): void { + /* + * The field that would most easily become the thing `extensions` refuses to be. Free strings + * here and a connector could report every loaded extension by calling them all "missing" — so + * the values are an enum, and anything outside it is refused rather than trimmed. + */ + $payload = fixture('system.v3/valid.json'); + $payload['php']['missing_extensions'] = ['ioncube_loader']; + + expect(SchemaValidator::forSchema('system.v3')->validate($payload))->not->toBe([]); +}); + +it('keeps the image driver to which library, not which version', function (): void { + // A version string belongs to the host. What a reader acts on is whether transforms can happen + // at all, and that has three answers. + $payload = fixture('system.v3/valid.json'); + $payload['php']['image_driver'] = 'imagick 3.7.0'; + + expect(SchemaValidator::forSchema('system.v3')->validate($payload))->not->toBe([]); +}); + +it('keeps writable directories to the ones Craft owns', function (): void { + // A free-form label here is a filesystem path with extra steps: "/var/www/html/storage": true + // reports a path while looking like it reports a permission. + $payload = fixture('system.v3/valid.json'); + $payload['paths']['/var/www/vhosts/example.org/storage'] = true; + + expect(SchemaValidator::forSchema('system.v3')->validate($payload))->not->toBe([]); +}); + +it('tells an empty missing_extensions apart from an absent one', function (): void { + // A site that checked and found nothing missing, and a connector too old to have looked, are + // different facts. Both are valid; only one of them means the extensions are fine. + $checked = fixture('system.v3/valid.json'); + $checked['php']['missing_extensions'] = []; + + expect(SchemaValidator::forSchema('system.v3')->validate($checked))->toBe([]); + + $neverLooked = fixture('system.v3/valid.json'); + unset($neverLooked['php']['missing_extensions']); + + expect(SchemaValidator::forSchema('system.v3')->validate($neverLooked))->toBe([]); +}); + +it('is valid with nothing but the core fields, as v1 and v2 are', function (): void { + // Every section stays optional. A connector that can build a v3 report but whose Craft could not + // answer any of the new questions sends a shorter report, not a deficient one. + expect(SchemaValidator::forSchema('system.v3')->validate([ + 'schema_version' => 'system.v3', + 'collected_at' => 1785400000, + ]))->toBe([]); +}); + +it('will not accept a v2 report under the v3 name, or the reverse', function (): void { + $v2 = fixture('system.v2/valid.json'); + + expect(SchemaValidator::forSchema('system.v3')->validate($v2))->not->toBe([]); + + $v3 = fixture('system.v3/valid.json'); + + expect(SchemaValidator::forSchema('system.v2')->validate($v3))->not->toBe([]); +});