|
| 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. |
0 commit comments