Skip to content

Commit 92bc6b1

Browse files
authored
Merge pull request #383 from ConductionNL/wip/eol-feed-integration
feat: eol-feed-integration — data-driven end-of-life dates from the endoflife.date register
2 parents 7b57988 + 71f076c commit 92bc6b1

28 files changed

Lines changed: 2946 additions & 23 deletions

File tree

appinfo/info.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ Vrij en open source onder de EUPL-licentie.
9090
<job>OCA\SoftwareCatalog\BackgroundJob\OrganizationContactSyncJob</job>
9191
<job>OCA\SoftwareCatalog\BackgroundJob\ContractStatusJob</job>
9292
<job>OCA\SoftwareCatalog\BackgroundJob\FederationSyncJob</job>
93+
<job>OCA\SoftwareCatalog\BackgroundJob\EolSyncJob</job>
9394
</background-jobs>
9495

9596
<navigations>

appinfo/routes.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,15 @@
249249
['name' => 'settings#getCronjobUsers', 'url' => '/api/settings/cronjobs/users', 'verb' => 'GET'],
250250
['name' => 'settings#getCronjobOrganisations', 'url' => '/api/settings/cronjobs/organisations', 'verb' => 'GET'],
251251

252+
// ========================================================================
253+
// EOL FEED SYNC API ENDPOINTS (eol-feed-integration)
254+
// ========================================================================
255+
256+
['name' => 'settings#getEolSyncConfig', 'url' => '/api/eol-sync/config', 'verb' => 'GET'],
257+
['name' => 'settings#updateEolSyncConfig', 'url' => '/api/eol-sync/config', 'verb' => 'POST'],
258+
['name' => 'settings#triggerEolSync', 'url' => '/api/eol-sync/trigger', 'verb' => 'POST'],
259+
['name' => 'settings#getEolSyncStatus', 'url' => '/api/eol-sync/status', 'verb' => 'GET'],
260+
252261
// Gebruik by group
253262
['name' => 'gebruik#getGebruiken', 'url' => '/api/gebruik', 'verb' => 'GET'],
254263
['name' => 'gebruik#getGebruikenForDeelnemer', 'url' => '/api/gebruik/deelnemer', 'verb' => 'GET'],
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
3+
- SPDX-License-Identifier: EUPL-1.2
4+
-->
5+
6+
# End-of-life feed integration
7+
8+
Makes `moduleVersie.datumEindeOndersteuning` (end-of-support date)
9+
data-driven by matching catalog products to
10+
[endoflife.date](https://endoflife.date) product cycles, instead of relying
11+
on manual entry alone. The existing EOL indicators, EOL-approaching filter,
12+
roadmap, and `eol-approaching` notification rule declared in
13+
`application-lifecycle-tracking` are unchanged — this feature only improves
14+
what populates the field they already read.
15+
16+
Specification:
17+
[`openspec/specs/eol-feed-integration/spec.md`](../../openspec/specs/eol-feed-integration/spec.md).
18+
19+
## Architecture: softwarecatalog never calls endoflife.date
20+
21+
All fetching of endoflife.date data happens in the sibling **openconnector**
22+
`endoflife-date-source` change — a Source + Synchronization + Mapping that
23+
polls `https://endoflife.date/api` and upserts `eolProduct`/`eolCycle`
24+
OpenRegister objects. Softwarecatalog only *reads* those already-ingested
25+
objects via `ObjectService`; there is no HTTP client, outbound URL
26+
configuration field, or network call to endoflife.date (or any other EOL
27+
feed) anywhere in this app's code. This mirrors the pattern established by
28+
`module-vulnerability-tracking` for CVE enrichment: transport lives in
29+
openconnector, matching and consumption live in the leaf app.
30+
31+
```
32+
openconnector (sibling repo, optional)
33+
endoflife-date-source: fetches endoflife.date → eolProduct/eolCycle objects
34+
│ read-only, via ObjectService — NO HTTP here
35+
36+
softwarecatalog (this feature)
37+
module.eolProductSlug ──┐ (mapping config, per product)
38+
39+
EolSyncJob (scheduled) ─► EolSyncService ─► EolMatcherService
40+
"Sync now" (manual) ─┘ │
41+
42+
moduleVersie.datumEindeOndersteuning / eolBron / eolBijgewerktOp
43+
```
44+
45+
## Mapping a product
46+
47+
Each `module` gains an optional **`eolProductSlug`** field — the
48+
endoflife.date product identifier it corresponds to (e.g. `postgresql`,
49+
`nextcloud`). It is edited through the same generic OpenRegister object form
50+
every other module field uses; no dedicated frontend code is needed for the
51+
field itself. Modules without `eolProductSlug` set are never read or written
52+
by the matcher — the mapping is strictly opt-in, per product.
53+
54+
## Conservative matching — unambiguous only
55+
56+
`EolMatcherService` compares a `moduleVersie.versie` string (e.g. `21.3.1`)
57+
against the `cycle` values of the mapped module's `eolCycle` rows, using
58+
dot-segment version-prefix matching, most-specific level first:
59+
60+
- `21.3.1` against cycles `21.3` and `21` → matches `21.3` (deeper prefix
61+
wins).
62+
- `2` against cycles `2.0` and `2.1`**ambiguous tie**, skipped — the
63+
matcher never guesses.
64+
- No cycle shares any leading segment → **no match**, skipped.
65+
66+
A stamp is only ever written on an **exactly-one-candidate** result at the
67+
most-specific matching depth. Ties and no-matches leave the `moduleVersie`
68+
completely untouched — it remains exactly as available for manual
69+
`datumEindeOndersteuning` entry as it was before this feature existed.
70+
71+
## Stamping preserves every other field
72+
73+
When a match is found, the matcher reads the *complete* current
74+
`moduleVersie` object, sets three fields on the in-memory copy —
75+
`datumEindeOndersteuning` (from the matched cycle's `eol` date), `eolBron`
76+
(provenance source, `endoflife.date`), and `eolBijgewerktOp` (the sync run's
77+
timestamp) — and saves the full object back. OpenRegister's `saveObject()`
78+
is PUT-semantic (omitted properties are nulled, not left alone), so every
79+
other field (`versie`, `status`, `gebruiken`, `beschrijvingKort`, ...)
80+
carries forward unchanged. A hand-entered `datumEindeOndersteuning` never
81+
gains `eolBron`/`eolBijgewerktOp` — those two fields are only ever written
82+
by the matcher, so their presence reliably distinguishes a feed-sourced date
83+
from a manually entered one.
84+
85+
## Schedule and manual trigger
86+
87+
`EolSyncJob` (a Nextcloud `TimedJob`, system/non-RBAC context) re-runs the
88+
matcher on a configurable interval (default 24h, floored at 5 minutes). An
89+
admin can also trigger the identical logic immediately via **Sync now** in
90+
Settings → Software Catalog → *End-of-life feed sync* — both paths call the
91+
same `EolSyncService::run()`, so they can never drift apart.
92+
93+
## Graceful degradation
94+
95+
If the configured register/schema cannot be resolved — openconnector's
96+
`endoflife-date-source` change is not installed, the register/schema names
97+
are wrong, or the feature is simply disabled — `EolSyncService` returns a
98+
status of `available: false` with a `reason` code, and neither trigger path
99+
raises an error. Manual `datumEindeOndersteuning` entry, the EOL-approaching
100+
filter, the roadmap, and the notification rule all continue to work exactly
101+
as they do today; none of them require this feature to be configured.
102+
103+
Reason codes surfaced in the settings status panel:
104+
105+
| Reason | Meaning |
106+
|--------------------------------------|-----------------------------------------------------------------|
107+
| `disabled` | The feature toggle is off. |
108+
| `openregister-not-installed` | OpenRegister itself is not installed. |
109+
| `object-service-unavailable` | OpenRegister's `ObjectService` could not be resolved. |
110+
| `module-schema-not-configured` | Softwarecatalog's own `module`/`moduleVersie` schema isn't set up yet. |
111+
| `eol-register-or-schema-not-found` | The configured EOL register/schema names don't resolve — is `endoflife-date-source` installed? |
112+
| `not-yet-run` | No sync has ever run. |
113+
114+
## Settings
115+
116+
**Settings → Software Catalog → End-of-life feed sync**:
117+
118+
- **Enable EOL feed sync** — off by default; the matcher never reads or
119+
writes anything while disabled.
120+
- **Register slug** / **eolProduct schema slug** / **eolCycle schema slug**
121+
— pre-filled with the names the openconnector `endoflife-date-source`
122+
change provisions (`openconnector` / `eolProduct` / `eolCycle`). Editable
123+
without a code change, since openconnector and softwarecatalog are
124+
separate release trains and the provisioned names could differ.
125+
- **Sync interval (minutes)** — how often the scheduled job re-runs
126+
(minimum enforced: 5 minutes).
127+
- **Sync now** — runs the same match/stamp logic immediately.
128+
- A status banner reports the last run's matched/skipped counts and
129+
timestamp, or the unavailability reason when the feed can't be reached.
130+
131+
## API
132+
133+
```
134+
GET /apps/softwarecatalog/api/eol-sync/config — current configuration
135+
POST /apps/softwarecatalog/api/eol-sync/config — update configuration
136+
POST /apps/softwarecatalog/api/eol-sync/trigger — run a sync now, returns status
137+
GET /apps/softwarecatalog/api/eol-sync/status — last-recorded status
138+
```
139+
140+
All four endpoints require Nextcloud admin-group authorization (the default
141+
posture of `SettingsController` methods — no `#[NoAdminRequired]`), the same
142+
pattern as every other settings-admin-controller endpoint.

l10n/en.json

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,61 @@
421421
"View name": "View name",
422422
"e.g. Zaakregistratie modules": "e.g. Zaakregistratie modules",
423423
"Save view": "Save view",
424-
"Approval": "Approval"
424+
"Approval": "Approval",
425+
"{source} will be marked as merged (not deleted) and will disappear from the organisations list.": "{source} will be marked as merged (not deleted) and will disappear from the organisations list.",
426+
"Approval": "Approval",
427+
"Compliance records": "Compliance records",
428+
"Confirm organisation merge": "Confirm organisation merge",
429+
"Contact persons": "Contact persons",
430+
"Contracts": "Contracts",
431+
"Could not load EOL sync configuration": "Could not load EOL sync configuration",
432+
"Could not load EOL sync status": "Could not load EOL sync status",
433+
"Could not load target organisations.": "Could not load target organisations.",
434+
"Could not merge the organisations.": "Could not merge the organisations.",
435+
"Could not preview the merge.": "Could not preview the merge.",
436+
"Could not save EOL sync settings": "Could not save EOL sync settings",
437+
"Enable EOL feed sync": "Enable EOL feed sync",
438+
"End-of-life feed sync": "End-of-life feed sync",
439+
"EOL feed sync is disabled": "EOL feed sync is disabled",
440+
"EOL sync completed: {matched} matched, {skipped} skipped.": "EOL sync completed: {matched} matched, {skipped} skipped.",
441+
"EOL sync did not run: {reason}": "EOL sync did not run: {reason}",
442+
"EOL sync failed": "EOL sync failed",
443+
"EOL sync settings saved": "EOL sync settings saved",
444+
"eolCycle schema slug": "eolCycle schema slug",
445+
"eolProduct schema slug": "eolProduct schema slug",
446+
"Feed unavailable: {reason}. Manual end-of-support entry, the EOL-approaching filter, the roadmap, and the notification rule keep working regardless.": "Feed unavailable: {reason}. Manual end-of-support entry, the EOL-approaching filter, the roadmap, and the notification rule keep working regardless.",
447+
"Fold this organisation into another one (gemeentelijke herindeling or leveranciersovername). Every contract, usage record, contact person, offering and compliance record is re-pointed to the target; this organisation is then marked as merged, never deleted.": "Fold this organisation into another one (gemeentelijke herindeling or leveranciersovername). Every contract, usage record, contact person, offering and compliance record is re-pointed to the target; this organisation is then marked as merged, never deleted.",
448+
"Go to the organisation it was merged into": "Go to the organisation it was merged into",
449+
"Group members": "Group members",
450+
"Last run: {matched} matched, {skipped} skipped, at {time}.": "Last run: {matched} matched, {skipped} skipped, at {time}.",
451+
"Loading EOL sync configuration…": "Loading EOL sync configuration…",
452+
"Loading merge status": "Loading merge status",
453+
"Match catalog products to endoflife.date product cycles ingested via OpenConnector, to keep end-of-support dates data-driven. Softwarecatalog never calls endoflife.date directly.": "Match catalog products to endoflife.date product cycles ingested via OpenConnector, to keep end-of-support dates data-driven. Softwarecatalog never calls endoflife.date directly.",
454+
"Merge organisation": "Merge organisation",
455+
"Merge organisations": "Merge organisations",
456+
"never": "never",
457+
"not yet run": "not yet run",
458+
"Offerings": "Offerings",
459+
"OpenRegister is not currently reachable": "OpenRegister is not currently reachable",
460+
"OpenRegister is not installed": "OpenRegister is not installed",
461+
"Organisation successfully merged.": "Organisation successfully merged.",
462+
"Pre-filled with the names the openconnector endoflife-date-source change provisions. Change them if your instance uses different names — no code change required.": "Pre-filled with the names the openconnector endoflife-date-source change provisions. Change them if your instance uses different names — no code change required.",
463+
"Preview merge": "Preview merge",
464+
"Records that will be re-pointed to {target}:": "Records that will be re-pointed to {target}:",
465+
"Register slug": "Register slug",
466+
"Save EOL sync settings": "Save EOL sync settings",
467+
"Schedule": "Schedule",
468+
"Select the organisation to merge into": "Select the organisation to merge into",
469+
"Source register and schemas": "Source register and schemas",
470+
"Sync interval (minutes)": "Sync interval (minutes)",
471+
"Sync now": "Sync now",
472+
"Target organisation": "Target organisation",
473+
"the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?": "the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?",
474+
"the module/moduleVersie schema is not configured yet": "the module/moduleVersie schema is not configured yet",
475+
"The scheduled background job re-runs the matcher at this interval; the minimum enforced interval is 5 minutes.": "The scheduled background job re-runs the matcher at this interval; the minimum enforced interval is 5 minutes.",
476+
"This organisation has been merged and is no longer active.": "This organisation has been merged and is no longer active.",
477+
"This will permanently fold {source} into {target}.": "This will permanently fold {source} into {target}.",
478+
"Usage records": "Usage records",
479+
"When disabled, the matcher never reads or writes anything — the same as the feed being unavailable.": "When disabled, the matcher never reads or writes anything — the same as the feed being unavailable."
425480
}
426481
}

l10n/en_US.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,38 @@ OC.L10N.register(
258258
"Offerings" : "Offerings",
259259
"Compliance records" : "Compliance records",
260260
"Group members" : "Group members",
261-
"Merge organisations" : "Merge organisations"
261+
"Merge organisations" : "Merge organisations",
262+
"End-of-life feed sync" : "End-of-life feed sync",
263+
"Match catalog products to endoflife.date product cycles ingested via OpenConnector, to keep end-of-support dates data-driven. Softwarecatalog never calls endoflife.date directly." : "Match catalog products to endoflife.date product cycles ingested via OpenConnector, to keep end-of-support dates data-driven. Softwarecatalog never calls endoflife.date directly.",
264+
"Loading EOL sync configuration…" : "Loading EOL sync configuration…",
265+
"Save EOL sync settings" : "Save EOL sync settings",
266+
"Sync now" : "Sync now",
267+
"Last run: {matched} matched, {skipped} skipped, at {time}." : "Last run: {matched} matched, {skipped} skipped, at {time}.",
268+
"Feed unavailable: {reason}. Manual end-of-support entry, the EOL-approaching filter, the roadmap, and the notification rule keep working regardless." : "Feed unavailable: {reason}. Manual end-of-support entry, the EOL-approaching filter, the roadmap, and the notification rule keep working regardless.",
269+
"Enable EOL feed sync" : "Enable EOL feed sync",
270+
"When disabled, the matcher never reads or writes anything — the same as the feed being unavailable." : "When disabled, the matcher never reads or writes anything — the same as the feed being unavailable.",
271+
"Source register and schemas" : "Source register and schemas",
272+
"Pre-filled with the names the openconnector endoflife-date-source change provisions. Change them if your instance uses different names — no code change required." : "Pre-filled with the names the openconnector endoflife-date-source change provisions. Change them if your instance uses different names — no code change required.",
273+
"Register slug" : "Register slug",
274+
"eolProduct schema slug" : "eolProduct schema slug",
275+
"eolCycle schema slug" : "eolCycle schema slug",
276+
"Schedule" : "Schedule",
277+
"Sync interval (minutes)" : "Sync interval (minutes)",
278+
"The scheduled background job re-runs the matcher at this interval; the minimum enforced interval is 5 minutes." : "The scheduled background job re-runs the matcher at this interval; the minimum enforced interval is 5 minutes.",
279+
"Could not load EOL sync configuration" : "Could not load EOL sync configuration",
280+
"Could not load EOL sync status" : "Could not load EOL sync status",
281+
"Could not save EOL sync settings" : "Could not save EOL sync settings",
282+
"EOL sync settings saved" : "EOL sync settings saved",
283+
"EOL sync completed: {matched} matched, {skipped} skipped." : "EOL sync completed: {matched} matched, {skipped} skipped.",
284+
"EOL sync did not run: {reason}" : "EOL sync did not run: {reason}",
285+
"EOL sync failed" : "EOL sync failed",
286+
"EOL feed sync is disabled" : "EOL feed sync is disabled",
287+
"not yet run" : "not yet run",
288+
"OpenRegister is not installed" : "OpenRegister is not installed",
289+
"OpenRegister is not currently reachable" : "OpenRegister is not currently reachable",
290+
"the module/moduleVersie schema is not configured yet" : "the module/moduleVersie schema is not configured yet",
291+
"the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?" : "the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?",
292+
"never" : "never"
262293
},
263294
"nplurals=2; plural=(n != 1);"
264295
);

0 commit comments

Comments
 (0)