From 584f0ed15fc69f0240d4ceedd0bd41d6d7416ff5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:29:10 +0000 Subject: [PATCH 01/45] chore(release): 0.2.3-unstable.20260912202721 --- appinfo/info.xml | 2 +- openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 15874ec6c..c296c0ece 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -50,7 +50,7 @@ Vrij en open source onder de EUPL-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. Voor een Service Level Agreement (SLA), neem contact op via sales@conduction.nl. ]]> - 0.2.2-unstable.20260910105110 + 0.2.3-unstable.20260912202721 EUPL-1.2 Conduction Stackiq diff --git a/openapi.json b/openapi.json index 264274cdb..9746b7596 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "stackiq", - "version": "0.2.2-unstable.20260910105110", + "version": "0.2.3-unstable.20260912202721", "description": "Stackiq", "license": { "name": "agpl" From a8a66d05551a97b1a440659f5ecf5dd57ca7123c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 14 Sep 2026 22:28:43 +0200 Subject: [PATCH 02/45] feat(integrations): show stackiq's connections through integriq's registry (#1032) * feat(connections): declare email, federation and the end-of-life feed for integriq * feat(connections): refresh and report email, federation and the end-of-life feed to integriq * feat(connections): an Integrations page over integriq's connection registry * test(connections): an e2e spec for the Integrations page, with integriq in CI * test(connections): named arguments, and stubs that wait for OCP * refactor(connections): keep the event senders private --- .github/workflows/code-quality.yml | 9 +- l10n/en.js | 15 +- l10n/en.json | 15 +- l10n/nl.js | 15 +- l10n/nl.json | 15 +- lib/AppInfo/Application.php | 11 +- lib/Controller/SettingsController.php | 13 + lib/Service/ConnectionReportService.php | 501 ++++++++++++++++++ lib/Service/EolSyncService.php | 31 +- lib/Service/Federation/FederationService.php | 35 +- lib/Settings/connections.json | 37 ++ .../adopt-connection-registry/.openspec.yaml | 2 + .../adopt-connection-registry/design.md | 96 ++++ .../adopt-connection-registry/proposal.md | 45 ++ .../specs/admin-integrations/spec.md | 95 ++++ .../adopt-connection-registry/tasks.md | 34 ++ phpstan.neon | 4 + psalm.xml | 6 + src/App.vue | 11 + src/customComponents.js | 12 + src/icons.js | 2 + src/manifest.d/connection-registry.json | 87 +++ src/services/connectionRegistry.js | 99 ++++ .../settings/sections/EmailConfiguration.vue | 1 + .../settings/sections/EolSyncSettings.vue | 1 + .../settings/sections/FederationSettings.vue | 1 + .../Event/ConnectionRefreshRequestedEvent.php | 46 ++ .../Event/ConnectionStatusReportedEvent.php | 52 ++ ...SettingsControllerConnectionReportTest.php | 165 ++++++ .../Service/ConnectionReportCallersTest.php | 303 +++++++++++ .../Service/ConnectionReportServiceTest.php | 489 +++++++++++++++++ .../Settings/ConnectionsDeclarationTest.php | 355 +++++++++++++ tests/bootstrap-unit.php | 15 + tests/bootstrap.php | 19 + tests/e2e/workflows/integrations-page.spec.ts | 154 ++++++ tests/vitest/connectionRegistry.spec.js | 165 ++++++ 36 files changed, 2945 insertions(+), 11 deletions(-) create mode 100644 lib/Service/ConnectionReportService.php create mode 100644 lib/Settings/connections.json create mode 100644 openspec/changes/adopt-connection-registry/.openspec.yaml create mode 100644 openspec/changes/adopt-connection-registry/design.md create mode 100644 openspec/changes/adopt-connection-registry/proposal.md create mode 100644 openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md create mode 100644 openspec/changes/adopt-connection-registry/tasks.md create mode 100644 src/manifest.d/connection-registry.json create mode 100644 src/services/connectionRegistry.js create mode 100644 tests/Stubs/Integriq/Event/ConnectionRefreshRequestedEvent.php create mode 100644 tests/Stubs/Integriq/Event/ConnectionStatusReportedEvent.php create mode 100644 tests/Unit/Controller/SettingsControllerConnectionReportTest.php create mode 100644 tests/Unit/Service/ConnectionReportCallersTest.php create mode 100644 tests/Unit/Service/ConnectionReportServiceTest.php create mode 100644 tests/Unit/Settings/ConnectionsDeclarationTest.php create mode 100644 tests/e2e/workflows/integrations-page.spec.ts create mode 100644 tests/vitest/connectionRegistry.spec.js diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 026f5a693..213bafb1f 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -158,7 +158,14 @@ jobs: # object API directly (tests/e2e/workflows/_fixtures.ts), so testing # against `main` measures a different backend than the one this app is # written for. Pinned to `development` to match. - additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"}]' + # + # integriq is here because the Integrations page reads integriq's + # `app_connection` rows (adopt-connection-registry). Without it the page + # shows the missing-dependency screen and + # `tests/e2e/workflows/integrations-page.spec.ts` fails on every run. + # `app` is `integriq`, verified in its appinfo/info.xml on `development` + # on 2026-09-14. + additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"},{"repo":"ConductionNL/integriq","app":"integriq","ref":"development"}]' # Newman disabled: tests/magic-mapper-import.postman_collection.json was # written against a dev env with URL rewriting + a fixed disk layout — it # hits bare paths like `/configurations` and uploads files from diff --git a/l10n/en.js b/l10n/en.js index 08b68094d..47b812dcd 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -704,7 +704,20 @@ OC.L10N.register( "xmlns": "xmlns", "xsi": "xsi", "Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.": "Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.", - "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?" + "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?", + "Integrations": "Integrations", + "Status message": "Status message", + "Last checked": "Last checked", + "All connections": "All connections", + "Add integration": "Add integration", + "Open settings": "Open settings", + "Configured": "Configured", + "Limited": "Limited", + "Not configured": "Not configured", + "Simulated": "Simulated", + "Not available": "Not available", + "Error": "Error", + "Settings": "Settings" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 81aaa9c63..8c8626820 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -703,6 +703,19 @@ "xmlns": "xmlns", "xsi": "xsi", "Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.": "Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.", - "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?" + "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?", + "Integrations": "Integrations", + "Status message": "Status message", + "Last checked": "Last checked", + "All connections": "All connections", + "Add integration": "Add integration", + "Open settings": "Open settings", + "Configured": "Configured", + "Limited": "Limited", + "Not configured": "Not configured", + "Simulated": "Simulated", + "Not available": "Not available", + "Error": "Error", + "Settings": "Settings" } } diff --git a/l10n/nl.js b/l10n/nl.js index 6c52495c3..cd3abea30 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -777,7 +777,20 @@ OC.L10N.register( "xmlns": "xmlns", "xsi": "xsi", "Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.": "Vooraf ingevuld met de namen die de Integriq-wijziging endoflife-date-source aanmaakt. Wijzig ze als uw omgeving andere namen gebruikt, geen codewijziging nodig.", - "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "het geconfigureerde register of schema kon niet worden gevonden. Is de Integriq-wijziging endoflife-date-source geïnstalleerd?" + "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "het geconfigureerde register of schema kon niet worden gevonden. Is de Integriq-wijziging endoflife-date-source geïnstalleerd?", + "Integrations": "Koppelingen", + "Status message": "Statusbericht", + "Last checked": "Laatst gecontroleerd", + "All connections": "Alle verbindingen", + "Add integration": "Integratie toevoegen", + "Open settings": "Instellingen openen", + "Configured": "Ingericht", + "Limited": "Beperkt", + "Not configured": "Niet geconfigureerd", + "Simulated": "Gesimuleerd", + "Not available": "Niet beschikbaar", + "Error": "Fout", + "Settings": "Instellingen" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 0848195e0..74b03d236 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -776,6 +776,19 @@ "xmlns": "xmlns", "xsi": "xsi", "Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.": "Vooraf ingevuld met de namen die de Integriq-wijziging endoflife-date-source aanmaakt. Wijzig ze als uw omgeving andere namen gebruikt, geen codewijziging nodig.", - "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "het geconfigureerde register of schema kon niet worden gevonden. Is de Integriq-wijziging endoflife-date-source geïnstalleerd?" + "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "het geconfigureerde register of schema kon niet worden gevonden. Is de Integriq-wijziging endoflife-date-source geïnstalleerd?", + "Integrations": "Koppelingen", + "Status message": "Statusbericht", + "Last checked": "Laatst gecontroleerd", + "All connections": "Alle verbindingen", + "Add integration": "Integratie toevoegen", + "Open settings": "Instellingen openen", + "Configured": "Ingericht", + "Limited": "Beperkt", + "Not configured": "Niet geconfigureerd", + "Simulated": "Gesimuleerd", + "Not available": "Niet beschikbaar", + "Error": "Fout", + "Settings": "Instellingen" } } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 672b1cdec..175c58296 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -39,6 +39,7 @@ use OCA\Stackiq\Service\ArchiMateExportService; use OCA\Stackiq\Service\ArchiMateImportService; use OCA\Stackiq\Service\ArchiMateService; +use OCA\Stackiq\Service\ConnectionReportService; use OCA\Stackiq\Service\ContactpersoonService; use OCA\Stackiq\Service\ContractApprovalService; use OCA\Stackiq\Service\ContractStatusService; @@ -674,7 +675,11 @@ function ($container) { config: $container->get(FederationConfig::class), merger: $container->get(FederationMerger::class), settingsService: $container->get(SettingsService::class), - logger: $container->get(LoggerInterface::class) + logger: $container->get(LoggerInterface::class), + // The integriq connection report (adopt-connection-registry). Passed by + // name: this factory is hand-built, so the constructor default of null + // would otherwise switch every federation report off without a sound. + connectionReports: $container->get(ConnectionReportService::class) ); } ); @@ -706,7 +711,9 @@ function ($container) { settingsService: $container->get(SettingsService::class), matcher: $container->get(EolMatcherService::class), timeFactory: $container->get('OCP\AppFramework\Utility\ITimeFactory'), - logger: $container->get(LoggerInterface::class) + logger: $container->get(LoggerInterface::class), + // Same reason as the FederationService factory above. + connectionReports: $container->get(ConnectionReportService::class) ); } ); diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 2064ade21..7225595ed 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -27,6 +27,7 @@ use OCA\OpenRegister\Contract\ObjectServiceInterface; use OCA\OpenRegister\Service\ConfigurationService; use OCA\Stackiq\Service\ArchiMateService; +use OCA\Stackiq\Service\ConnectionReportService; use OCA\Stackiq\Service\EolSyncService; use OCA\Stackiq\Service\OrganizationSyncService; use OCA\Stackiq\Service\ProgressTracker; @@ -84,8 +85,11 @@ class SettingsController extends Controller { * @param ProgressTracker $progressTracker The progress tracking service. * @param EolSyncService $eolSyncService The EOL feed sync orchestration service. * @param LoggerInterface $logger The logger instance. + * @param ConnectionReportService|null $connectionReports Asks integriq to look again after an email settings save. * * @SuppressWarnings(PHPMD.ExcessiveParameterList) + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function __construct( $appName, @@ -101,6 +105,7 @@ public function __construct( private readonly ProgressTracker $progressTracker, private readonly EolSyncService $eolSyncService, private readonly LoggerInterface $logger, + private readonly ?ConnectionReportService $connectionReports = null, ) { parent::__construct(appName: $appName, request: $request); @@ -431,9 +436,14 @@ private function updateUserGroupSettings(array $data, array &$result): ?JSONResp * @param array $data The raw request params. * @param array $result The result accumulator (passed by reference). * + * After the write it asks integriq to resolve the email connection again + * (adopt-connection-registry). That never throws, does nothing without + * integriq, and never changes the response. + * * @return void * * @spec openspec/changes/method-decomposition/tasks.md#task-3 + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ private function applyEmailSettingsUpdate(array $data, array &$result): void { if (isset($data['emailSettings']) === false) { @@ -441,6 +451,7 @@ private function applyEmailSettingsUpdate(array $data, array &$result): void { } $result['emailSettings'] = $this->settingsService->updateEmailSettings($data['emailSettings']); + $this->connectionReports?->emailSettingsSaved(); }//end applyEmailSettingsUpdate() @@ -2025,6 +2036,7 @@ public function getEmailSettings(): JSONResponse { * * @return JSONResponse Update result * @spec openspec/specs/settings-admin-controller/spec.md + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function updateEmailSettings(): JSONResponse { $currentUser = $this->userSession->getUser(); @@ -2041,6 +2053,7 @@ public function updateEmailSettings(): JSONResponse { $emailSettings = $data['emailSettings'] ?? $data; $updatedSettings = $this->settingsService->updateEmailSettings($emailSettings); + $this->connectionReports?->emailSettingsSaved(); return new JSONResponse( [ diff --git a/lib/Service/ConnectionReportService.php b/lib/Service/ConnectionReportService.php new file mode 100644 index 000000000..646c48e8e --- /dev/null +++ b/lib/Service/ConnectionReportService.php @@ -0,0 +1,501 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service; + +use OCA\Stackiq\AppInfo\Application; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Sends connection refresh requests and reports to integriq. + * + * A save refreshes before it reports. Under hydra#674 a refresh retires the + * observations older than itself, so a report sent before the refresh would + * be retired by it. A pull or a sync run reports without a refresh: it + * changes no settings. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ +class ConnectionReportService { + + /** + * Integriq's report event (ADR-041). Named by string so stackiq stays + * installable without integriq: the class is only there when integriq is. + * + * @var string + */ + public const STATUS_EVENT = 'OCA\Integriq\Event\ConnectionStatusReportedEvent'; + + /** + * Integriq's refresh event. Same reason for the string as above. + * + * @var string + */ + public const REFRESH_EVENT = 'OCA\Integriq\Event\ConnectionRefreshRequestedEvent'; + + /** + * The email connection key in lib/Settings/connections.json. + * + * @var string + */ + public const KEY_EMAIL = 'email'; + + /** + * The catalog federation connection key in lib/Settings/connections.json. + * + * @var string + */ + public const KEY_FEDERATION = 'federation'; + + /** + * The end-of-life feed connection key in lib/Settings/connections.json. + * + * @var string + */ + public const KEY_EOL = 'eol-feed'; + + /** + * The longest failure reason a message carries. + * + * @var int + */ + public const REASON_LIMIT = 160; + + /** + * What each EOL sync degrade reason means for the row, as status and message. + * + * The reasons are the ones EolSyncService::degrade() records. + * + * @var array + */ + public const EOL_REASONS = [ + 'disabled' => [ + 'unconfigured', + 'End-of-life sync is switched off. Switch it on in the End-of-life feed sync section.', + ], + 'openregister-not-installed' => [ + 'unavailable', + 'The end-of-life sync needs OpenRegister, and it is not installed.', + ], + 'object-service-unavailable' => [ + 'error', + 'OpenRegister did not answer the last end-of-life sync.', + ], + 'module-schema-not-configured' => [ + 'unconfigured', + 'Stackiq has no module or module version schema configured, so the sync has nothing to stamp.', + ], + 'eol-register-or-schema-not-found' => [ + 'unconfigured', + 'The end-of-life register or schemas are missing. Install the endoflife.date source in integriq, ' + . 'or fix the names in the End-of-life feed sync section.', + ], + ]; + + /** + * Constructor. + * + * @param IEventDispatcher $eventDispatcher Sends the integriq events (ADR-041). + * @param SymfonyEmailService $emailService Tells whether the saved email settings are complete. + * @param LoggerInterface $logger Records what could not be sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function __construct( + private readonly IEventDispatcher $eventDispatcher, + private readonly SymfonyEmailService $emailService, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * After an email settings save: refresh, then report what the saved settings say. + * + * The `null` transport is left to integriq's rule 3, which outranks any + * report. Never throws, and does nothing without integriq. + * + * @return bool True when the report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function emailSettingsSaved(): bool { + if ($this->refresh(key: self::KEY_EMAIL) === false) { + return false; + } + + try { + [$status, $message] = $this->describeEmail( + configStatus: $this->emailService->isEmailSystemConfigured(), + transportLabels: $this->emailService->getAvailableTransports() + ); + } catch (Throwable $e) { + $this->logger->warning( + 'Stackiq: could not read the email settings for a connection report', + ['key' => self::KEY_EMAIL, 'exception' => $e->getMessage()] + ); + return false; + } + + return $this->report(key: self::KEY_EMAIL, status: $status, message: $message); + }//end emailSettingsSaved() + + /** + * What the email configuration status says about the connection. + * + * @param array $configStatus The result of SymfonyEmailService::isEmailSystemConfigured(). + * @param array $transportLabels Transport type to its label. + * + * @return array{0: string, 1: string} The status and the message. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function describeEmail(array $configStatus, array $transportLabels): array { + if (array_key_exists('transportType', $configStatus) === false) { + // SymfonyEmailService answers without a transport only when email is off. + return ['unconfigured', 'Email is switched off, so stackiq sends no mail.']; + } + + $transport = (string) $configStatus['transportType']; + $label = ($transportLabels[$transport] ?? $transport); + + if (($configStatus['hasCredentials'] ?? false) !== true) { + return ['unconfigured', 'Email is on, and the ' . $label . ' transport misses a setting it needs.']; + } + + if (($configStatus['hasTemplates'] ?? false) !== true) { + return ['unconfigured', 'Email is on, and a required mail template is empty.']; + } + + return ['configured', 'Email is on and the ' . $label . ' transport settings are filled. No test mail was sent.']; + }//end describeEmail() + + /** + * After a peer was added or removed: refresh, then report a state that blocks federation. + * + * A ready federation gets no report: only a pull can tell whether the peers + * answer, so the row reads the declared "Not checked yet" until then. + * + * @param array $status The result of FederationService::getStatus(). + * + * @return bool True when a report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function federationPeersChanged(array $status): bool { + if ($this->refresh(key: self::KEY_FEDERATION) === false) { + return false; + } + + $blocked = $this->federationBlocker( + available: ($status['available'] ?? false) === true, + enabled: ($status['enabled'] ?? false) === true, + peerCount: count((array) ($status['peers'] ?? [])) + ); + if ($blocked === null) { + return false; + } + + return $this->report(key: self::KEY_FEDERATION, status: $blocked[0], message: $blocked[1]); + }//end federationPeersChanged() + + /** + * After a federation pull: report what the peers answered. + * + * @param array $pull The result of FederationService::pullAllPeers(). + * + * @return bool True when a report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function federationPulled(array $pull): bool { + [$status, $message] = $this->describePull(pull: $pull); + + return $this->report(key: self::KEY_FEDERATION, status: $status, message: $message); + }//end federationPulled() + + /** + * What a federation pull says about the connection. + * + * @param array $pull The result of FederationService::pullAllPeers(). + * + * @return array{0: string, 1: string} The status and the message. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function describePull(array $pull): array { + $reason = (string) ($pull['reason'] ?? ''); + if (($pull['ok'] ?? false) !== true) { + $blocked = $this->federationBlocker( + available: $reason !== 'OpenCatalogi unavailable', + enabled: $reason !== 'federation disabled', + peerCount: 1 + ); + + return ($blocked ?? ['error', 'The last federation pull failed: ' . $this->shorten(text: $reason)]); + } + + $peers = array_values((array) ($pull['peers'] ?? [])); + if ($peers === []) { + return ['unconfigured', 'Federation is on, and no peer catalog is added yet.']; + } + + $failed = array_values( + array_filter($peers, static fn (mixed $peer): bool => is_array($peer) === true && ($peer['ok'] ?? false) !== true) + ); + if ($failed === [] && count($peers) === 1) { + return ['configured', 'The peer catalog answered the last pull.']; + } + + if ($failed === []) { + return ['configured', 'All ' . count($peers) . ' peer catalogs answered the last pull.']; + } + + $first = $this->peerHost(url: (string) ($failed[0]['peer'] ?? '')) + . ' did not: ' . $this->shorten(text: (string) ($failed[0]['reason'] ?? '')); + if (count($failed) === count($peers)) { + return ['error', 'No peer catalog answered the last pull. ' . $first]; + } + + $answered = (count($peers) - count($failed)); + + return ['limited', $answered . ' of ' . count($peers) . ' peer catalogs answered the last pull. ' . $first]; + }//end describePull() + + /** + * After an EOL sync settings save: refresh, then report a switched-off sync. + * + * @param array $config The configuration as saved. + * + * @return bool True when a report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function eolSyncConfigSaved(array $config): bool { + if ($this->refresh(key: self::KEY_EOL) === false) { + return false; + } + + if (($config['enabled'] ?? false) === true) { + return false; + } + + [$status, $message] = self::EOL_REASONS['disabled']; + + return $this->report(key: self::KEY_EOL, status: $status, message: $message); + }//end eolSyncConfigSaved() + + /** + * After an EOL sync run: report the outcome the run recorded. + * + * @param array $runStatus The status EolSyncService::run() recorded. + * + * @return bool True when a report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function eolSyncRan(array $runStatus): bool { + [$status, $message] = $this->describeEolRun(runStatus: $runStatus); + + return $this->report(key: self::KEY_EOL, status: $status, message: $message); + }//end eolSyncRan() + + /** + * What an EOL sync run says about the connection. + * + * @param array $runStatus The status EolSyncService::run() recorded. + * + * @return array{0: string, 1: string} The status and the message. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function describeEolRun(array $runStatus): array { + if (($runStatus['available'] ?? false) === true) { + return [ + 'configured', + 'The last sync stamped ' . (int) ($runStatus['matched'] ?? 0) . ' module versions and skipped ' + . (int) ($runStatus['skipped'] ?? 0) . '.', + ]; + } + + $reason = (string) ($runStatus['reason'] ?? ''); + + return (self::EOL_REASONS[$reason] ?? ['error', 'The last end-of-life sync stopped: ' . $this->shorten(text: $reason)]); + }//end describeEolRun() + + /** + * Ask integriq to resolve one connection again. + * + * @param string $key The connection key. + * + * @return bool True when the event was dispatched. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + private function refresh(string $key): bool { + $eventClass = $this->resolveEventClass(eventClass: self::REFRESH_EVENT); + if ($eventClass === null) { + return false; + } + + return $this->send( + key: $key, + build: static fn (): object => new $eventClass(app: Application::APP_ID, key: $key) + ); + }//end refresh() + + /** + * Report one status for one connection. + * + * @param string $key The connection key. + * @param string $status One of the six registry statuses. + * @param string $message What stackiq observed. + * + * @return bool True when the event was dispatched. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + private function report(string $key, string $status, string $message): bool { + $eventClass = $this->resolveEventClass(eventClass: self::STATUS_EVENT); + if ($eventClass === null) { + return false; + } + + return $this->send( + key: $key, + build: static fn (): object => new $eventClass(app: Application::APP_ID, key: $key, status: $status, message: $message) + ); + }//end report() + + /** + * The event class to instantiate, or null when integriq does not ship it. + * + * @param string $eventClass The fully qualified class name, without a leading backslash. + * + * @return string|null The class name to instantiate, or null when absent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + protected function resolveEventClass(string $eventClass): ?string { + $qualified = '\\' . $eventClass; + if (class_exists($qualified) === false) { + return null; + } + + return $qualified; + }//end resolveEventClass() + + /** + * The state that keeps federation from pulling at all, or null when none does. + * + * @param bool $available Whether OpenCatalogi is installed. + * @param bool $enabled Whether federation_enabled is on. + * @param int $peerCount How many peers are configured. + * + * @return array{0: string, 1: string}|null The status and the message, or null. + */ + private function federationBlocker(bool $available, bool $enabled, int $peerCount): ?array { + if ($available === false) { + return ['unavailable', 'Federation needs the OpenCatalogi app, and it is not installed.']; + } + + if ($enabled === false) { + return [ + 'unconfigured', + 'Federation is switched off. Run occ config:app:set stackiq federation_enabled --value=true --type=boolean.', + ]; + } + + if ($peerCount === 0) { + return ['unconfigured', 'Federation is on, and no peer catalog is added yet.']; + } + + return null; + }//end federationBlocker() + + /** + * The host of a peer URL, so a message never carries its path, query or credentials. + * + * @param string $url The peer URL. + * + * @return string The host, or "A peer" when the URL has none. + */ + private function peerHost(string $url): string { + $host = parse_url($url, PHP_URL_HOST); + if (is_string($host) === false || $host === '') { + return 'A peer'; + } + + return $host; + }//end peerHost() + + /** + * A reason cut to REASON_LIMIT characters. + * + * @param string $text The reason. + * + * @return string The reason, cut and trimmed. + */ + private function shorten(string $text): string { + $text = trim($text); + if (mb_strlen($text) <= self::REASON_LIMIT) { + return $text; + } + + return rtrim(mb_substr($text, 0, self::REASON_LIMIT)) . '...'; + }//end shorten() + + /** + * Build and dispatch one event, swallowing anything a listener throws. + * + * @param string $key The connection the event is about, for the log. + * @param callable(): object $build Builds the event. + * + * @return bool True when the event was dispatched without an exception. + */ + private function send(string $key, callable $build): bool { + try { + $event = $build(); + if (($event instanceof Event) === false) { + return false; + } + + $this->eventDispatcher->dispatchTyped($event); + return true; + } catch (Throwable $e) { + $this->logger->warning( + 'Stackiq: could not send a connection event to integriq', + ['key' => $key, 'exception' => $e->getMessage()] + ); + return false; + } + }//end send() +}//end class diff --git a/lib/Service/EolSyncService.php b/lib/Service/EolSyncService.php index 7a138f9bb..8864d4fb2 100644 --- a/lib/Service/EolSyncService.php +++ b/lib/Service/EolSyncService.php @@ -57,12 +57,16 @@ class EolSyncService { * @param EolMatcherService $matcher The pure matching/stamping logic. * @param ITimeFactory $timeFactory The time factory (sync-run timestamp). * @param LoggerInterface $logger The logger. + * @param ConnectionReportService|null $connectionReports Tells integriq what a save or a run met. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function __construct( private readonly SettingsService $settingsService, private readonly EolMatcherService $matcher, private readonly ITimeFactory $timeFactory, private readonly LoggerInterface $logger, + private readonly ?ConnectionReportService $connectionReports = null, ) { }//end __construct() @@ -82,12 +86,19 @@ public function getConfig(): array { * * @param array $data The submitted configuration fields. * + * The save asks integriq to resolve the end-of-life feed connection again + * (adopt-connection-registry). + * * @return array The persisted configuration result. * * @spec openspec/specs/eol-feed-integration/spec.md#requirement-products-are-mapped-to-endoflife-date-via-per-module-config + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function updateConfig(array $data): array { - return $this->settingsService->updateEolSyncConfig($data); + $result = $this->settingsService->updateEolSyncConfig($data); + $this->connectionReports?->eolSyncConfigSaved(config: (array) ($result['config'] ?? [])); + + return $result; }//end updateConfig() /** @@ -226,7 +237,7 @@ public function run(): array { 'skipped' => $totalSkipped, 'lastRunAt' => $fetchedAt, ]; - $this->settingsService->setEolSyncStatus($status); + $this->recordStatus(status: $status); return $status; }//end run() @@ -476,8 +487,22 @@ private function degrade(string $reason): array { 'skipped' => 0, 'lastRunAt' => $this->timeFactory->getDateTime()->format(\DateTimeInterface::ATOM), ]; - $this->settingsService->setEolSyncStatus($status); + $this->recordStatus(status: $status); return $status; }//end degrade() + + /** + * Record a run's status, and tell integriq what the run met. + * + * @param array{available: bool, reason: string|null, matched: int, skipped: int, lastRunAt: string|null} $status The run status. + * + * @return void + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + private function recordStatus(array $status): void { + $this->settingsService->setEolSyncStatus($status); + $this->connectionReports?->eolSyncRan(runStatus: $status); + }//end recordStatus() }//end class diff --git a/lib/Service/Federation/FederationService.php b/lib/Service/Federation/FederationService.php index 2bd60c109..3bb5174b7 100644 --- a/lib/Service/Federation/FederationService.php +++ b/lib/Service/Federation/FederationService.php @@ -27,6 +27,7 @@ namespace OCA\Stackiq\Service\Federation; +use OCA\Stackiq\Service\ConnectionReportService; use OCA\Stackiq\Service\SettingsService; use OCP\App\IAppManager; use Psr\Container\ContainerInterface; @@ -63,6 +64,9 @@ class FederationService { * @param FederationMerger $merger The merge/staleness reconciler. * @param SettingsService|null $settingsService Resolves the mirror register/schema (lazy/optional). * @param LoggerInterface $logger Logger. + * @param ConnectionReportService|null $connectionReports Tells integriq what a peer change or a pull met. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function __construct( private readonly ContainerInterface $container, @@ -71,6 +75,7 @@ public function __construct( private readonly FederationMerger $merger, private readonly ?SettingsService $settingsService, private readonly LoggerInterface $logger, + private readonly ?ConnectionReportService $connectionReports = null, ) { }//end __construct() @@ -141,9 +146,13 @@ public function getStatus(): array { * * @param string $peerUrl The peer base URL. * + * A new peer asks integriq to resolve the federation connection again + * (adopt-connection-registry). + * * @return array{ok:bool, reason:string} Result for the settings UI. * * @spec openspec/specs/federated-catalog-sync/spec.md + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function addPeer(string $peerUrl): array { $peerUrl = trim($peerUrl); @@ -162,6 +171,7 @@ public function addPeer(string $peerUrl): array { $peers[] = $peerUrl; $this->config->setPeers(array_values($peers)); + $this->connectionReports?->federationPeersChanged(status: $this->getStatus()); return ['ok' => true, 'reason' => 'peer added']; }//end addPeer() @@ -170,9 +180,13 @@ public function addPeer(string $peerUrl): array { * * @param string $peerUrl The peer base URL. * + * A removed peer asks integriq to resolve the federation connection again + * (adopt-connection-registry). + * * @return array{ok:bool, reason:string} Result for the settings UI. * * @spec openspec/specs/federated-catalog-sync/spec.md + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function removePeer(string $peerUrl): array { $peerUrl = trim($peerUrl); @@ -184,6 +198,7 @@ public function removePeer(string $peerUrl): array { $this->config->setPeers($filtered); $this->config->setPeerFailures($peerUrl, 0); + $this->connectionReports?->federationPeersChanged(status: $this->getStatus()); return ['ok' => true, 'reason' => 'peer removed']; }//end removePeer() @@ -278,11 +293,29 @@ public function discoverPeers(): array { * independently so one unreachable peer cannot block the rest. Returns a * per-peer result summary for logging / the admin UI. * + * Tells integriq what the pull met, from the Pull now button and from + * FederationSyncJob alike (adopt-connection-registry). + * * @return array{ok:bool, reason:string, peers:array>} * * @spec openspec/specs/federated-catalog-sync/spec.md + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function pullAllPeers(): array { + $result = $this->pullEveryPeer(); + $this->connectionReports?->federationPulled(pull: $result); + + return $result; + }//end pullAllPeers() + + /** + * Pull every subscribed peer, one at a time. + * + * @return array{ok:bool, reason:string, peers:array>} + * + * @spec openspec/specs/federated-catalog-sync/spec.md + */ + private function pullEveryPeer(): array { if ($this->config->isEnabled() === false) { return ['ok' => false, 'reason' => 'federation disabled', 'peers' => []]; } @@ -297,7 +330,7 @@ public function pullAllPeers(): array { } return ['ok' => true, 'reason' => 'ok', 'peers' => $results]; - }//end pullAllPeers() + }//end pullEveryPeer() /** * Pull one peer's published catalog and reconcile it into local mirrors. diff --git a/lib/Settings/connections.json b/lib/Settings/connections.json new file mode 100644 index 000000000..e2fcb8591 --- /dev/null +++ b/lib/Settings/connections.json @@ -0,0 +1,37 @@ +{ + "app": "stackiq", + "connections": [ + { + "key": "email", + "title": "Email", + "description": "Sends registration, activation and account mails to organisations and their users.", + "order": 10, + "settingsUrl": "/settings/admin/stackiq#section-email", + "adapter": { + "configKey": "email_transport_type", + "simulatedValues": ["null"], + "simulatedMessage": "The null transport is selected, so no mail leaves stackiq. Pick a real transport in the Email configuration section." + }, + "unconfiguredMessage": "Not checked yet. Save the Email configuration section, and stackiq checks the settings." + }, + { + "key": "federation", + "title": "Catalog federation", + "description": "Announces this catalog to directory.opencatalogi.nl and pulls published entries from peer catalogs, through OpenCatalogi.", + "order": 20, + "settingsUrl": "/settings/admin/stackiq#section-federation", + "reportedOnly": true, + "unconfiguredMessage": "Not checked yet. Choose Pull now in the Catalog federation section to check the peers." + }, + { + "key": "eol-feed", + "title": "End-of-life feed", + "description": "Reads product cycles from endoflife.date through integriq, and stamps end-of-support dates on module versions.", + "order": 30, + "settingsUrl": "/settings/admin/stackiq#section-eol-sync", + "reportedOnly": true, + "sourceTemplate": "endoflife-date", + "unconfiguredMessage": "Not checked yet. Choose Sync now in the End-of-life feed sync section." + } + ] +} diff --git a/openspec/changes/adopt-connection-registry/.openspec.yaml b/openspec/changes/adopt-connection-registry/.openspec.yaml new file mode 100644 index 000000000..a40cb63c1 --- /dev/null +++ b/openspec/changes/adopt-connection-registry/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-14 diff --git a/openspec/changes/adopt-connection-registry/design.md b/openspec/changes/adopt-connection-registry/design.md new file mode 100644 index 000000000..24231e76d --- /dev/null +++ b/openspec/changes/adopt-connection-registry/design.md @@ -0,0 +1,96 @@ +# Design: adopt-connection-registry + +The contract is hydra `openspec/changes/connection-registry/design.md` (hydra#667, amended in hydra#673, with hydra#674 pending). This file records how stackiq meets it and where it fits loosely. + +## D1. Which connections are declared + +Each candidate was checked against the code on `development`. + +| Key | Declared as | Why | +|---|---|---| +| `email` | `adapter.configKey: email_transport_type`, `simulatedValues: ["null"]` | `SymfonyEmailService::createTransport()` builds `null://null` for `null`. | +| `federation` | `reportedOnly: true` | `FederationService` needs OpenCatalogi installed and the boolean key `federation_enabled`. Neither is readable as a filled string. | +| `eol-feed` | `reportedOnly: true`, `sourceTemplate: endoflife-date` | `EolSyncService::run()` reads integriq's `eol_product` and `eol_cycle` through OpenRegister. Integriq seeds the `endoflife-date` source. | + +**Why an empty transport is not simulated.** Stackiq reads a key that was never set with the default `smtp`. A key set to an empty or unknown value reaches the `default` branch of `createTransport()`, which builds an SMTP transport with a warning. An empty value sends real mail, so the contract default `[""]` would be a false Simulated. The file lists `null` only. + +**Why email has no `requiredConfig`.** Which keys a transport needs depends on the transport: a host for SMTP, an API key for SendGrid, nothing for sendmail. And `email_enabled` stores `false` as a filled string. No fixed key list can say "the settings are complete", so stackiq reports it (D2). + +**Why federation and eol-feed are reported only.** `federation_enabled` is typed boolean, and integriq's reader answers `typed` for it, which is a filled value. `eol_sync_config` is a JSON blob that is filled after any save, with `enabled` true or false. Rule 5 would read both as configured. + +**Anchors.** The admin section is `stackiq` (`StackiqAdmin::getSection()`), so each link is `/settings/admin/stackiq#section-…`. The three section components put the id on their `AlwaysVisibleSection`, whose root `NcSettingsSection` inherits it. + +## D2. What stackiq reports, and when + +`lib/Service/ConnectionReportService.php` sends both events. It names the classes by string behind `class_exists` (ADR-041) and never throws. + +Every save sends the refresh first and the report second. Under hydra#674 the refresh retires older observations, so a report sent before it would be retired by it. + +**Email, on an email settings save** (`POST /api/settings/email`, or `PUT` and `POST /api/settings` with `emailSettings`). Stackiq reads `SymfonyEmailService::isEmailSystemConfigured()`. + +| Stackiq sees | Status | Message | +|---|---|---| +| Email switched off | `unconfigured` | "Email is switched off, so stackiq sends no mail." | +| The transport misses what it needs | `unconfigured` | "Email is on, and the SMTP Server transport misses a setting it needs." | +| A required template is empty | `unconfigured` | "Email is on, and a required mail template is empty." | +| Everything filled | `configured` | "Email is on and the SMTP Server transport settings are filled. No test mail was sent." | + +The `null` transport still reads Simulated: rule 3 sits above every report. + +**Federation, on a peer add or remove.** Stackiq reads `FederationService::getStatus()`. + +| Stackiq sees | Status | Message | +|---|---|---| +| OpenCatalogi not installed | `unavailable` | "Federation needs the OpenCatalogi app, and it is not installed." | +| `federation_enabled` off | `unconfigured` | names the `occ` command | +| No peers | `unconfigured` | "Federation is on, and no peer catalog is added yet." | +| Ready | nothing | the refresh alone, so the row reads the declared "Not checked yet" | + +**Federation, after a pull** (Pull now, or `FederationSyncJob`). The same three blocking states come from the pull's `reason`. Otherwise: + +| Peers that answered | Status | +|---|---| +| all | `configured` | +| some | `limited`, naming the first host that failed | +| none | `error`, naming the first host that failed | + +A message names a peer by host only, never by its full URL, and cuts a failure reason at 160 characters. + +**End-of-life feed, on an EOL sync settings save.** A refresh, and `unconfigured` when `enabled` is off. Otherwise the row reads "Not checked yet" until the next run. + +**End-of-life feed, after a run** (Sync now, or `EolSyncJob`). `EolSyncService::run()` already records a status. The report maps its `reason`: + +| Reason | Status | +|---|---| +| none, the run completed | `configured`, with the matched and skipped counts | +| `disabled` | `unconfigured` | +| `openregister-not-installed` | `unavailable` | +| `object-service-unavailable` | `error` | +| `module-schema-not-configured` | `unconfigured` | +| `eol-register-or-schema-not-found` | `unconfigured`, naming integriq's endoflife.date source | +| anything else | `error`, naming the reason | + +**Why this is cheap.** A save and a button are admin actions. `FederationSyncJob` runs once per `federation_sync_interval` (3600 s by default), and `EolSyncJob` once per `intervalSeconds`, never below 300 s. No page request sends an event (ADR-076). + +**Wiring.** `FederationService`, `FederationSyncJob`'s service and `EolSyncService` are built by hand in `Application::register()`. Those factories pass the report service by name. `SettingsController` is autowired, so it takes the service as an optional last argument. + +## D3. The page + +- `src/manifest.d/connection-registry.json`: an `index` page `Integrations` at `/settings/integrations`, `requiresApp` integriq, `permission: admin`, `showAdd: false`, and the columns connection, status, status message, last checked and settings. +- Its menu entry `IntegrationsMenu` sits in the settings gear with `query: {app: stackiq}`, `permission: admin` and `visibleIf.appInstalled: integriq`. +- `src/services/connectionRegistry.js` holds the two formatters and `openIntegriqConnections`. +- `App.vue` passes the formatters through CnAppRoot's `formatters` prop. It passed none before this change. `src/customComponents.js` carries the handler, because CnIndexPage resolves a header action's handler against `customComponents`. + +**Formatters.** The installed `@conduction/nextcloud-vue` 2.39.0 ships no `connectionStatus` built-in, so stackiq carries a local copy with all six labels, `limited` included. + +## D4. Contract misfits + +- **A boolean app-config key.** `federation_enabled` is typed boolean. Integriq's reader answers `typed` for a type conflict, which counts as filled, so a `requiredConfig` on it would read Configured while federation is off. The contract has no way to say "filled and true". `reportedOnly` works around it. +- **A flag inside a blob.** `eol_sync_config` holds `{"enabled": false, …}`. `adapter.jsonPath` reads inside a blob, but only rule 3 uses it, and "switched off" is not "simulated". A `requiredConfig` with a JSON path would fit this row. +- **Completeness that depends on the adapter.** Email needs different keys per transport. `requiredConfig` is one fixed list. +- **Gate 116's vendored schema is behind integriq.** `hydra-gates/scripts/schemas/connections.schema.json` on `.github` `main` has no `jsonPath`, `simulatedValues` or `reportedOnly`, so gate 116 warns on every file that uses the hydra#673 fields. The file validates against integriq's own schema on `development`. + +## Risks + +- **Same-second ordering.** Stackiq sends the refresh before the report. If integriq stamps `refreshedAt` later than the report's `at` within one request, the report is retired. Hydra#674 compares with "not older than", so an equal stamp counts. +- **A federation row can lag a failing peer.** Between pulls the row keeps the last outcome. `FederationSyncJob` bounds that to one sync interval. diff --git a/openspec/changes/adopt-connection-registry/proposal.md b/openspec/changes/adopt-connection-registry/proposal.md new file mode 100644 index 000000000..b43e66e1a --- /dev/null +++ b/openspec/changes/adopt-connection-registry/proposal.md @@ -0,0 +1,45 @@ +--- +kind: code +--- + +# Proposal: adopt-connection-registry + +## Why + +Stackiq talks to three outside systems, and an admin can only tell whether they work by reading three settings sections and a log. + +- **Email.** Mail goes out through Symfony Mailer. `email_transport_type` picks smtp, sendmail, native, null, sendgrid, mailgun, postmark, ses or mailjet. On `null`, every mail is dropped without a sound. +- **Catalog federation.** OpenCatalogi announces this catalog to directory.opencatalogi.nl and pulls entries from peer catalogs. It needs OpenCatalogi installed and `federation_enabled` on, and a peer can fail for hours before anyone notices. +- **End-of-life feed.** Integriq ingests endoflife.date, and stackiq matches the cycles to module versions. A missing register or a switched-off sync shows only inside its own section. + +Hydra change `connection-registry` (hydra#667, amended in hydra#673 and hydra#674) gives every app one page of its connections, backed by integriq. + +## What changes + +- New `lib/Settings/connections.json` with three connections: `email`, `federation` and `eol-feed`. +- `email` names `email_transport_type` as its adapter key, and only `null` reads Simulated. An empty value is not simulated: stackiq falls back to SMTP. +- `federation` and `eol-feed` are `reportedOnly`. Only stackiq can see OpenCatalogi, `federation_enabled` (a boolean key) and the sync outcome. +- `eol-feed` offers integriq's `endoflife-date` source as its template. +- The three settings sections get stable ids: `section-email`, `section-federation` and `section-eol-sync`. +- An email settings save, a peer add or remove, and an EOL sync settings save send `ConnectionRefreshRequestedEvent` for that connection, then report what stackiq can see. +- A federation pull and an EOL sync run report their outcome. Both run on a schedule or on the admin's button, never on a page request. +- An Integrations page under the settings gear, over integriq's `app_connection` schema, preset to `app=stackiq`, admin only, and only shown when integriq is installed. +- Add integration opens `/apps/integriq/connections?app=stackiq&link=1`. +- Local `connectionStatus` and `connectionSettingsLabel` formatters with all six statuses, and the strings in English and Dutch. + +## Depends on + +- hydra `openspec/changes/connection-registry`, design D2, D4, D6, D8, D9 and D12, and hydra#674 (a refresh retires older observations). +- integriq on `development`: the `app_connection` schema, the declaration sync, both events, the Connections overview and the `endoflife-date` source. + +Without integriq the menu entry is hidden, a deep link shows the missing-dependency screen, and nothing is sent. + +## Out of scope + +- The stackiq register schema `connection` (softwarecatalogus). It describes a catalogue item and is unrelated to integriq's `app_connection`. +- The email test buttons. The store posts `testEmail` and `settings`, and the controller reads `email` and `emailSettings`, so neither test reaches a real send today. A report from them would describe unsaved settings. +- The directory announce. Its result is logged, and the row speaks for the pull. + +## Rollback + +Revert the change. Stackiq writes no rows of its own. Integriq removes the rows without a linked source on its next sync. diff --git a/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md b/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md new file mode 100644 index 000000000..7aae69757 --- /dev/null +++ b/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md @@ -0,0 +1,95 @@ +# admin-integrations Specification Delta + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- [adopt-connection-registry](../../) + +## Purpose + +Admins see stackiq's outside connections on one page, with a status stackiq can back. + +## ADDED Requirements + +### Requirement: REQ-STACKIQ-CONN-001 Stackiq declares its outside connections in one static file + +Stackiq SHALL declare `email`, `federation` and `eol-feed` in `lib/Settings/connections.json` in the shape of hydra connection-registry design D2 (hydra REQ-CONN-001). The `email` entry SHALL name `email_transport_type` as its adapter key with `simulatedValues` holding `null` and not the empty string, because an empty transport sends mail through SMTP. The `federation` and `eol-feed` entries SHALL be `reportedOnly`. The `eol-feed` entry SHALL offer integriq's `endoflife-date` source template. Every `settingsUrl` SHALL point at a section id that exists in the admin settings page. + +#### Scenario: The declaration names this app and passes integriq's schema +@e2e exclude A static file with no browser surface; tests/Unit/Settings/ConnectionsDeclarationTest.php checks the shape, the app id, unique keys and the anchors. + +- **GIVEN** `lib/Settings/connections.json` +- **WHEN** it is validated against integriq's `connections.schema.json` +- **THEN** it SHALL validate +- **AND** its `app` SHALL equal the id in `appinfo/info.xml` +- **AND** every key SHALL be unique +- **AND** every `#section-…` anchor SHALL be an id in a settings section component + +#### Scenario: The null transport reads simulated, and an empty one does not +@e2e tests/e2e/workflows/integrations-page.spec.ts + +- **GIVEN** integriq has synced stackiq's declaration +- **WHEN** `email_transport_type` holds `null` +- **THEN** the Email row SHALL read Simulated with the declared message +- **AND** when `email_transport_type` is empty or `smtp`, rule 3 SHALL NOT apply + +### Requirement: REQ-STACKIQ-CONN-002 A save asks integriq to look again, and a run reports what it met + +When a save writes the settings of a declared connection, stackiq SHALL send `ConnectionRefreshRequestedEvent` with app `stackiq` and that key, and SHALL send it before any report for that key (hydra REQ-CONN-004, hydra#674). An email settings save SHALL then report what `SymfonyEmailService::isEmailSystemConfigured()` sees. A peer add or remove SHALL report OpenCatalogi missing as `unavailable`, and federation off or without peers as `unconfigured`. A federation pull SHALL report every peer answering as `configured`, some as `limited` and none as `error`. An EOL sync run SHALL report its recorded outcome. A message SHALL name a peer by host only. Both events SHALL be named by string and sent only when the class exists. Neither SHALL change the response of the request, job or run that sent it. No page request SHALL send an event. + +#### Scenario: Saving email settings refreshes, then reports +@e2e exclude The event is not observable from a browser; tests/Unit/Service/ConnectionReportServiceTest.php and tests/Unit/Controller/SettingsControllerConnectionReportTest.php assert the order and the unchanged response. + +- **GIVEN** integriq is installed +- **WHEN** an admin saves the email settings with email switched off +- **THEN** stackiq SHALL send a refresh for `email` +- **AND** then a report `unconfigured` saying email is switched off + +#### Scenario: A pull where some peers fail reads limited +@e2e exclude A pull needs OpenCatalogi and reachable peers, which the CI instance does not have; tests/Unit/Service/ConnectionReportServiceTest.php drives the outcomes. + +- **GIVEN** federation is on with two peers +- **WHEN** a pull reaches one peer and not the other +- **THEN** stackiq SHALL report `federation` as `limited` +- **AND** the message SHALL name the failing peer's host and not its path + +#### Scenario: An EOL run without integriq's register reads not configured +@e2e exclude The run's outcome depends on integriq's register on the instance; tests/Unit/Service/ConnectionReportServiceTest.php asserts the report per reason, and tests/Unit/Service/ConnectionReportCallersTest.php that a run hands it over. + +- **GIVEN** EOL sync is switched on +- **WHEN** a run cannot find the `eol_product` or `eol_cycle` schema +- **THEN** stackiq SHALL report `eol-feed` as `unconfigured` with a message naming integriq's endoflife.date source + +#### Scenario: Without integriq nothing is sent +@e2e exclude The CI instance installs integriq; tests/Unit/Service/ConnectionReportServiceTest.php asserts nothing is sent or logged when the class is absent. + +- **GIVEN** integriq is not installed +- **WHEN** an admin saves email settings, or a pull or a sync runs +- **THEN** no event SHALL be sent and nothing SHALL be logged +- **AND** the save, pull or run SHALL answer as it did before this change + +### Requirement: REQ-STACKIQ-CONN-003 An admin reads the connections on an Integrations page + +Stackiq SHALL render an `index` page at `/settings/integrations` over `integriq/app_connection`, reached from the settings gear and preset to `app` equal to `stackiq` through its menu entry's `query` (hydra REQ-CONN-006). The page and its menu entry SHALL be admin only. The page SHALL require Integriq, and the menu entry SHALL only render when integriq is installed. The status column SHALL name all six statuses, `limited` included. The page SHALL NOT offer a generic Add button. Its Add integration action SHALL open `/apps/integriq/connections?app=stackiq&link=1`. + +#### Scenario: The page lists only the rows of stackiq +@e2e tests/e2e/workflows/integrations-page.spec.ts + +- **GIVEN** stackiq and integriq are installed and integriq has synced the declaration +- **WHEN** an admin opens the Integrations page +- **THEN** the page SHALL list the three declared connections +- **AND** every listed row SHALL have `app` equal to `stackiq` + +#### Scenario: Add integration goes to integriq +@e2e tests/e2e/workflows/integrations-page.spec.ts + +- **GIVEN** the Integrations page +- **WHEN** the admin chooses Add integration +- **THEN** the browser SHALL open integriq's Connections overview with `app=stackiq` and `link=1` + +#### Scenario: A connection that works in part reads Limited +@e2e exclude Only a federation pull with a failing peer produces limited; tests/vitest/connectionRegistry.spec.js asserts the label in English and Dutch. + +- **GIVEN** a row whose status is `limited` +- **WHEN** the page renders it +- **THEN** the cell SHALL read Limited, or Beperkt on a Dutch instance diff --git a/openspec/changes/adopt-connection-registry/tasks.md b/openspec/changes/adopt-connection-registry/tasks.md new file mode 100644 index 000000000..c524a3beb --- /dev/null +++ b/openspec/changes/adopt-connection-registry/tasks.md @@ -0,0 +1,34 @@ +# adopt-connection-registry tasks + +## 1. Declare + +- [x] 1.1 Write `lib/Settings/connections.json` with `email`, `federation` and `eol-feed`. +- [x] 1.2 Give the Email, Catalog federation and End-of-life feed sync sections the ids the file links to. +- [x] 1.3 Guard the file in `tests/Unit/Settings/ConnectionsDeclarationTest.php`. + +## 2. Page + +- [x] 2.1 Add `src/manifest.d/connection-registry.json` with the page and its settings-gear menu entry. +- [x] 2.2 Add `src/services/connectionRegistry.js` with the two formatters and the Add integration handler. +- [x] 2.3 Wire the formatters in `src/App.vue` and the handler in `src/customComponents.js`; register `PowerPlugOutline` in `src/icons.js`. +- [x] 2.4 Add the strings to `l10n/en` and `l10n/nl`. +- [x] 2.5 Cover it in `tests/vitest/connectionRegistry.spec.js`. + +## 3. Reports and refresh + +- [x] 3.1 Add `lib/Service/ConnectionReportService.php`. +- [x] 3.2 Refresh and report from the two email settings save paths in `SettingsController`. +- [x] 3.3 Refresh and report from `FederationService` peer changes and pulls. +- [x] 3.4 Refresh and report from `EolSyncService` config saves and runs. +- [x] 3.5 Pass the service in the `Application` factories. +- [x] 3.6 Add the integriq event stubs for PHPUnit, psalm and phpstan. +- [x] 3.7 Cover it in `ConnectionReportServiceTest`, `ConnectionReportCallersTest` and `SettingsControllerConnectionReportTest`. + +## 4. End to end + +- [x] 4.1 Write `tests/e2e/workflows/integrations-page.spec.ts`. +- [x] 4.2 Install integriq in the CI `additional-apps`. + +## 5. After integriq ships + +- [ ] 5.1 Run the e2e spec against an instance with both apps, then archive this change. diff --git a/phpstan.neon b/phpstan.neon index 804b28f31..f339f36a9 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -26,6 +26,10 @@ parameters: # spellings are in the field, and without this one the analyser proves # the newer half of the inbound guard dead. - tests/analysis-stubs/decidiq-events.stub.php + # Integriq's connection-registry events (adopt-connection-registry). + # ConnectionReportService names them by string behind class_exists. + - tests/Stubs/Integriq/Event/ConnectionStatusReportedEvent.php + - tests/Stubs/Integriq/Event/ConnectionRefreshRequestedEvent.php ignoreErrors: # OrganizationSyncService's `if ($contactObject !== null)` at the top of diff --git a/psalm.xml b/psalm.xml index bd3c1db1f..176feef4d 100644 --- a/psalm.xml +++ b/psalm.xml @@ -45,6 +45,12 @@ stub already existed for PHPUnit mock generation and mirrors the real signature in openregister/lib/Service/RegisterResolverService.php. --> + + + diff --git a/src/App.vue b/src/App.vue index 1987ef141..c05275f09 100644 --- a/src/App.vue +++ b/src/App.vue @@ -22,6 +22,7 @@ :customComponents="customComponents" :registry="registry" :pageTypes="pageTypes" + :formatters="formatters" appId="stackiq" :translate="translateForApp" :permissions="permissions" @@ -75,6 +76,7 @@ import OrganisationSwitcher from './components/organisations/OrganisationSwitche import Dialogs from './dialogs/Dialogs.vue' import Modals from './modals/Modals.vue' import { setActiveOrganisationUuid } from './composables/orClient.js' +import { createConnectionFormatters } from './services/connectionRegistry.js' import { settingsStore } from './store/store.js' export default { @@ -148,6 +150,15 @@ export default { data() { return { + /** + * Named cell formatters merged over CnAppRoot's built-ins. + * `connectionStatus` and `connectionSettingsLabel` render the + * Integrations page (adopt-connection-registry); nextcloud-vue + * 2.39.0 ships neither as a built-in. Before this change the app + * passed no formatters at all. + */ + formatters: createConnectionFormatters((source) => ncT('stackiq', source)), + objectSidebarState: reactive({ active: false, open: true, diff --git a/src/customComponents.js b/src/customComponents.js index dac0c48d3..e48c278b7 100644 --- a/src/customComponents.js +++ b/src/customComponents.js @@ -17,6 +17,7 @@ // - openspec/changes/stackiq-manifest-v1/design.md // - @conduction/nextcloud-vue → docs/migrating-to-manifest.md +import { generateUrl } from '@nextcloud/router' import OrganisatieCard from './components/cards/OrganisatieCard.vue' import ContractApprovalPanel from './components/contracts/ContractApprovalPanel.vue' import OrganisationMergePanel from './components/organisations/OrganisationMergePanel.vue' @@ -31,8 +32,19 @@ import LifecycleRoadmapView from './views/LifecycleRoadmapView.vue' import PortfolioReportView from './views/organisaties/PortfolioReport.vue' import StackiqSettingsPage from './views/settings/StackiqSettings.vue' import SuitesIndexView from './views/suites/SuitesIndexView.vue' +import { createConnectionHandlers } from './services/connectionRegistry.js' export default { + // Header-action handler: the Integrations page's Add integration + // (adopt-connection-registry). A FUNCTION, because it leaves the app for + // integriq's Connections overview and a header action's `navigate` only + // pushes a route inside this app. CnIndexPage resolves a handler name + // against this map. + ...createConnectionHandlers({ + generateUrl, + assign: (url) => window.location.assign(url), + }), + // OrganisatieCard — the bespoke card (inline contactpersoon toggle) used as // the `cardComponent` of the now-decomposed Organisaties type='index' page // (Phase 8). CnIndexPage's cardComponent config closed the prior lib gap. diff --git a/src/icons.js b/src/icons.js index f13e72b28..be09aea7a 100644 --- a/src/icons.js +++ b/src/icons.js @@ -53,6 +53,7 @@ import OfficeBuildingOutline from 'vue-material-design-icons/OfficeBuildingOutli import Package from 'vue-material-design-icons/Package.vue' import PackageVariant from 'vue-material-design-icons/PackageVariant.vue' import PackageVariantClosed from 'vue-material-design-icons/PackageVariantClosed.vue' +import PowerPlugOutline from 'vue-material-design-icons/PowerPlugOutline.vue' import PuzzleOutline from 'vue-material-design-icons/PuzzleOutline.vue' import ShieldAlert from 'vue-material-design-icons/ShieldAlert.vue' import ShieldAlertOutline from 'vue-material-design-icons/ShieldAlertOutline.vue' @@ -111,6 +112,7 @@ export default { Package, PackageVariant, PackageVariantClosed, + PowerPlugOutline, PuzzleOutline, ShieldAlert, ShieldAlertOutline, diff --git a/src/manifest.d/connection-registry.json b/src/manifest.d/connection-registry.json new file mode 100644 index 000000000..1b2f5e283 --- /dev/null +++ b/src/manifest.d/connection-registry.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json", + "_note": "adopt-connection-registry (hydra connection-registry D8, hydra#667, hydra#673 and hydra#674). The rows are integriq's `app_connection` objects, synced from lib/Settings/connections.json; integriq works out each status. The app=stackiq preset is the menu entry's `query` (ADR-097 decision 5), which the index page merges into the fetch as a bare filter key. `showAdd` is false because a row nothing declared has nothing to check (D9). Add integration leaves for integriq's overview through the openIntegriqConnections handler in src/customComponents.js, because a header action's `navigate` only pushes a route inside this app. This page lists integriq's `app_connection`, never stackiq's own softwarecatalogus `connection` schema.", + "menu": [ + { + "id": "IntegrationsMenu", + "label": "Integrations", + "icon": "PowerPlugOutline", + "route": "Integrations", + "query": { + "app": "stackiq" + }, + "section": "settings", + "order": 98, + "permission": "admin", + "visibleIf": { + "appInstalled": "integriq" + } + } + ], + "pages": [ + { + "id": "Integrations", + "route": "/settings/integrations", + "type": "index", + "title": "Integrations", + "permission": "admin", + "requiresApp": { + "id": "integriq", + "name": "Integriq" + }, + "config": { + "register": "integriq", + "schema": "app_connection", + "showViewAction": false, + "showAdd": false, + "headerActions": [ + { + "id": "add-integration", + "label": "Add integration", + "icon": "PowerPlugOutline", + "handler": "openIntegriqConnections" + } + ], + "defaultSort": { + "field": "order", + "direction": "asc" + }, + "columns": [ + { + "key": "title", + "label": "Connection" + }, + { + "key": "status", + "label": "Status", + "formatter": "connectionStatus" + }, + { + "key": "statusMessage", + "label": "Status message", + "sortable": false + }, + { + "key": "checkedAt", + "label": "Last checked" + }, + { + "key": "settingsUrl", + "label": "Settings", + "sortable": false, + "formatter": "connectionSettingsLabel", + "widget": "link", + "widgetProps": { + "href": "{settingsUrl}" + } + } + ], + "folderSidebar": { + "source": "field", + "field": "status", + "allLabel": "All connections" + } + } + } + ] +} diff --git a/src/services/connectionRegistry.js b/src/services/connectionRegistry.js new file mode 100644 index 000000000..6a881ba7e --- /dev/null +++ b/src/services/connectionRegistry.js @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. + +/** + * The Integrations page's two formatters and its Add integration handler. + * + * The rows on that page are integriq's `app_connection` objects (hydra change + * connection-registry, design D8). The installed @conduction/nextcloud-vue + * 2.39.0 ships neither formatter, so stackiq carries this copy until a + * release with the built-ins is pinned. The names are the contract's, so the + * copies across the fleet stay interchangeable. + * + * Pure: the translator, the URL builder and the navigation are passed in, so + * the module runs under vitest's node environment with nothing mocked. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-003-an-admin-reads-the-connections-on-an-integrations-page + */ + +/** + * Where Add integration lands: integriq's Connections overview, preset to this + * app and opening the link-a-source dialog (hydra connection-registry D9). + */ +export const INTEGRIQ_CONNECTIONS_PATH = '/apps/integriq/connections?app=stackiq&link=1' + +/** + * The English label for each of the six registry statuses (design D3). + * + * `limited` came with hydra#673: the connection works in part. + */ +export const CONNECTION_STATUS_LABELS = Object.freeze({ + configured: 'Configured', + limited: 'Limited', + unconfigured: 'Not configured', + simulated: 'Simulated', + unavailable: 'Not available', + error: 'Error', +}) + +/** + * Build the two connection formatters around a translator. + * + * @param {function(string): string} translate Translates an English source string for this app. + * @return {{connectionStatus: function(unknown): string, connectionSettingsLabel: function(unknown): string}} The formatters, keyed by their manifest names. + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-003-an-admin-reads-the-connections-on-an-integrations-page + */ +export function createConnectionFormatters(translate) { + return { + /** + * The label for a status. An unknown value renders itself, because a + * status the app cannot name is still a status the admin should see. + * + * @param {unknown} value The row's `status`. + * @return {string} The label, the raw value, or '' when missing. + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-003-an-admin-reads-the-connections-on-an-integrations-page + */ + connectionStatus(value) { + const source = typeof value === 'string' && Object.hasOwn(CONNECTION_STATUS_LABELS, value) + ? CONNECTION_STATUS_LABELS[value] + : null + return source ? translate(source) : String(value ?? '') + }, + + /** + * The Open settings link text, or '' when the row has nowhere to send a + * reader. An empty text makes the link cell fall through to plain text. + * + * @param {unknown} value The row's `settingsUrl`. + * @return {string} The link text, or ''. + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-003-an-admin-reads-the-connections-on-an-integrations-page + */ + connectionSettingsLabel(value) { + return typeof value === 'string' && value.length > 0 ? translate('Open settings') : '' + }, + } +} + +/** + * Build the Add integration header-action handler. + * + * A FUNCTION handler because a header action's `navigate` keyword only pushes + * a route inside this app's router, which cannot leave the app. + * + * @param {{generateUrl: function(string): string, assign: function(string): void}} deps Builds the instance URL and navigates to it. + * @return {{openIntegriqConnections: function(): void}} The handler, keyed by its manifest name. + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-003-an-admin-reads-the-connections-on-an-integrations-page + */ +export function createConnectionHandlers({ generateUrl, assign }) { + return { + /** + * Open integriq's Connections overview on the link-a-source dialog. + * + * @return {void} + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-003-an-admin-reads-the-connections-on-an-integrations-page + */ + openIntegriqConnections() { + assign(generateUrl(INTEGRIQ_CONNECTIONS_PATH)) + }, + } +} diff --git a/src/views/settings/sections/EmailConfiguration.vue b/src/views/settings/sections/EmailConfiguration.vue index 61e255b9e..4ac71bb1a 100644 --- a/src/views/settings/sections/EmailConfiguration.vue +++ b/src/views/settings/sections/EmailConfiguration.vue @@ -18,6 +18,7 @@