Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ Vrij en open source onder de EUPL-licentie.
<job>OCA\SoftwareCatalog\BackgroundJob\OrganizationContactSyncJob</job>
<job>OCA\SoftwareCatalog\BackgroundJob\ContractStatusJob</job>
<job>OCA\SoftwareCatalog\BackgroundJob\FederationSyncJob</job>
<job>OCA\SoftwareCatalog\BackgroundJob\EolSyncJob</job>
</background-jobs>

<navigations>
Expand Down
9 changes: 9 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,15 @@
['name' => 'settings#getCronjobUsers', 'url' => '/api/settings/cronjobs/users', 'verb' => 'GET'],
['name' => 'settings#getCronjobOrganisations', 'url' => '/api/settings/cronjobs/organisations', 'verb' => 'GET'],

// ========================================================================
// EOL FEED SYNC API ENDPOINTS (eol-feed-integration)
// ========================================================================

['name' => 'settings#getEolSyncConfig', 'url' => '/api/eol-sync/config', 'verb' => 'GET'],
['name' => 'settings#updateEolSyncConfig', 'url' => '/api/eol-sync/config', 'verb' => 'POST'],
['name' => 'settings#triggerEolSync', 'url' => '/api/eol-sync/trigger', 'verb' => 'POST'],
['name' => 'settings#getEolSyncStatus', 'url' => '/api/eol-sync/status', 'verb' => 'GET'],

// Gebruik by group
['name' => 'gebruik#getGebruiken', 'url' => '/api/gebruik', 'verb' => 'GET'],
['name' => 'gebruik#getGebruikenForDeelnemer', 'url' => '/api/gebruik/deelnemer', 'verb' => 'GET'],
Expand Down
142 changes: 142 additions & 0 deletions docs/features/eol-feed-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<!--
- SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
- SPDX-License-Identifier: EUPL-1.2
-->

# End-of-life feed integration

Makes `moduleVersie.datumEindeOndersteuning` (end-of-support date)
data-driven by matching catalog products to
[endoflife.date](https://endoflife.date) product cycles, instead of relying
on manual entry alone. The existing EOL indicators, EOL-approaching filter,
roadmap, and `eol-approaching` notification rule declared in
`application-lifecycle-tracking` are unchanged — this feature only improves
what populates the field they already read.

Specification:
[`openspec/specs/eol-feed-integration/spec.md`](../../openspec/specs/eol-feed-integration/spec.md).

## Architecture: softwarecatalog never calls endoflife.date

All fetching of endoflife.date data happens in the sibling **openconnector**
`endoflife-date-source` change — a Source + Synchronization + Mapping that
polls `https://endoflife.date/api` and upserts `eolProduct`/`eolCycle`
OpenRegister objects. Softwarecatalog only *reads* those already-ingested
objects via `ObjectService`; there is no HTTP client, outbound URL
configuration field, or network call to endoflife.date (or any other EOL
feed) anywhere in this app's code. This mirrors the pattern established by
`module-vulnerability-tracking` for CVE enrichment: transport lives in
openconnector, matching and consumption live in the leaf app.

```
openconnector (sibling repo, optional)
endoflife-date-source: fetches endoflife.date → eolProduct/eolCycle objects
│ read-only, via ObjectService — NO HTTP here
softwarecatalog (this feature)
module.eolProductSlug ──┐ (mapping config, per product)
EolSyncJob (scheduled) ─► EolSyncService ─► EolMatcherService
"Sync now" (manual) ─┘ │
moduleVersie.datumEindeOndersteuning / eolBron / eolBijgewerktOp
```

## Mapping a product

Each `module` gains an optional **`eolProductSlug`** field — the
endoflife.date product identifier it corresponds to (e.g. `postgresql`,
`nextcloud`). It is edited through the same generic OpenRegister object form
every other module field uses; no dedicated frontend code is needed for the
field itself. Modules without `eolProductSlug` set are never read or written
by the matcher — the mapping is strictly opt-in, per product.

## Conservative matching — unambiguous only

`EolMatcherService` compares a `moduleVersie.versie` string (e.g. `21.3.1`)
against the `cycle` values of the mapped module's `eolCycle` rows, using
dot-segment version-prefix matching, most-specific level first:

- `21.3.1` against cycles `21.3` and `21` → matches `21.3` (deeper prefix
wins).
- `2` against cycles `2.0` and `2.1` → **ambiguous tie**, skipped — the
matcher never guesses.
- No cycle shares any leading segment → **no match**, skipped.

A stamp is only ever written on an **exactly-one-candidate** result at the
most-specific matching depth. Ties and no-matches leave the `moduleVersie`
completely untouched — it remains exactly as available for manual
`datumEindeOndersteuning` entry as it was before this feature existed.

## Stamping preserves every other field

When a match is found, the matcher reads the *complete* current
`moduleVersie` object, sets three fields on the in-memory copy —
`datumEindeOndersteuning` (from the matched cycle's `eol` date), `eolBron`
(provenance source, `endoflife.date`), and `eolBijgewerktOp` (the sync run's
timestamp) — and saves the full object back. OpenRegister's `saveObject()`
is PUT-semantic (omitted properties are nulled, not left alone), so every
other field (`versie`, `status`, `gebruiken`, `beschrijvingKort`, ...)
carries forward unchanged. A hand-entered `datumEindeOndersteuning` never
gains `eolBron`/`eolBijgewerktOp` — those two fields are only ever written
by the matcher, so their presence reliably distinguishes a feed-sourced date
from a manually entered one.

## Schedule and manual trigger

`EolSyncJob` (a Nextcloud `TimedJob`, system/non-RBAC context) re-runs the
matcher on a configurable interval (default 24h, floored at 5 minutes). An
admin can also trigger the identical logic immediately via **Sync now** in
Settings → Software Catalog → *End-of-life feed sync* — both paths call the
same `EolSyncService::run()`, so they can never drift apart.

## Graceful degradation

If the configured register/schema cannot be resolved — openconnector's
`endoflife-date-source` change is not installed, the register/schema names
are wrong, or the feature is simply disabled — `EolSyncService` returns a
status of `available: false` with a `reason` code, and neither trigger path
raises an error. Manual `datumEindeOndersteuning` entry, the EOL-approaching
filter, the roadmap, and the notification rule all continue to work exactly
as they do today; none of them require this feature to be configured.

Reason codes surfaced in the settings status panel:

| Reason | Meaning |
|--------------------------------------|-----------------------------------------------------------------|
| `disabled` | The feature toggle is off. |
| `openregister-not-installed` | OpenRegister itself is not installed. |
| `object-service-unavailable` | OpenRegister's `ObjectService` could not be resolved. |
| `module-schema-not-configured` | Softwarecatalog's own `module`/`moduleVersie` schema isn't set up yet. |
| `eol-register-or-schema-not-found` | The configured EOL register/schema names don't resolve — is `endoflife-date-source` installed? |
| `not-yet-run` | No sync has ever run. |

## Settings

**Settings → Software Catalog → End-of-life feed sync**:

- **Enable EOL feed sync** — off by default; the matcher never reads or
writes anything while disabled.
- **Register slug** / **eolProduct schema slug** / **eolCycle schema slug**
— pre-filled with the names the openconnector `endoflife-date-source`
change provisions (`openconnector` / `eolProduct` / `eolCycle`). Editable
without a code change, since openconnector and softwarecatalog are
separate release trains and the provisioned names could differ.
- **Sync interval (minutes)** — how often the scheduled job re-runs
(minimum enforced: 5 minutes).
- **Sync now** — runs the same match/stamp logic immediately.
- A status banner reports the last run's matched/skipped counts and
timestamp, or the unavailability reason when the feed can't be reached.

## API

```
GET /apps/softwarecatalog/api/eol-sync/config — current configuration
POST /apps/softwarecatalog/api/eol-sync/config — update configuration
POST /apps/softwarecatalog/api/eol-sync/trigger — run a sync now, returns status
GET /apps/softwarecatalog/api/eol-sync/status — last-recorded status
```

All four endpoints require Nextcloud admin-group authorization (the default
posture of `SettingsController` methods — no `#[NoAdminRequired]`), the same
pattern as every other settings-admin-controller endpoint.
57 changes: 56 additions & 1 deletion l10n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,61 @@
"View name": "View name",
"e.g. Zaakregistratie modules": "e.g. Zaakregistratie modules",
"Save view": "Save view",
"Approval": "Approval"
"Approval": "Approval",
"{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.",
"Approval": "Approval",
"Compliance records": "Compliance records",
"Confirm organisation merge": "Confirm organisation merge",
"Contact persons": "Contact persons",
"Contracts": "Contracts",
"Could not load EOL sync configuration": "Could not load EOL sync configuration",
"Could not load EOL sync status": "Could not load EOL sync status",
"Could not load target organisations.": "Could not load target organisations.",
"Could not merge the organisations.": "Could not merge the organisations.",
"Could not preview the merge.": "Could not preview the merge.",
"Could not save EOL sync settings": "Could not save EOL sync settings",
"Enable EOL feed sync": "Enable EOL feed sync",
"End-of-life feed sync": "End-of-life feed sync",
"EOL feed sync is disabled": "EOL feed sync is disabled",
"EOL sync completed: {matched} matched, {skipped} skipped.": "EOL sync completed: {matched} matched, {skipped} skipped.",
"EOL sync did not run: {reason}": "EOL sync did not run: {reason}",
"EOL sync failed": "EOL sync failed",
"EOL sync settings saved": "EOL sync settings saved",
"eolCycle schema slug": "eolCycle schema slug",
"eolProduct schema slug": "eolProduct schema slug",
"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.",
"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.",
"Go to the organisation it was merged into": "Go to the organisation it was merged into",
"Group members": "Group members",
"Last run: {matched} matched, {skipped} skipped, at {time}.": "Last run: {matched} matched, {skipped} skipped, at {time}.",
"Loading EOL sync configuration…": "Loading EOL sync configuration…",
"Loading merge status": "Loading merge status",
"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.",
"Merge organisation": "Merge organisation",
"Merge organisations": "Merge organisations",
"never": "never",
"not yet run": "not yet run",
"Offerings": "Offerings",
"OpenRegister is not currently reachable": "OpenRegister is not currently reachable",
"OpenRegister is not installed": "OpenRegister is not installed",
"Organisation successfully merged.": "Organisation successfully merged.",
"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.",
"Preview merge": "Preview merge",
"Records that will be re-pointed to {target}:": "Records that will be re-pointed to {target}:",
"Register slug": "Register slug",
"Save EOL sync settings": "Save EOL sync settings",
"Schedule": "Schedule",
"Select the organisation to merge into": "Select the organisation to merge into",
"Source register and schemas": "Source register and schemas",
"Sync interval (minutes)": "Sync interval (minutes)",
"Sync now": "Sync now",
"Target organisation": "Target organisation",
"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?",
"the module/moduleVersie schema is not configured yet": "the module/moduleVersie schema is not configured yet",
"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.",
"This organisation has been merged and is no longer active.": "This organisation has been merged and is no longer active.",
"This will permanently fold {source} into {target}.": "This will permanently fold {source} into {target}.",
"Usage records": "Usage records",
"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."
}
}
33 changes: 32 additions & 1 deletion l10n/en_US.js
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,38 @@ OC.L10N.register(
"Offerings" : "Offerings",
"Compliance records" : "Compliance records",
"Group members" : "Group members",
"Merge organisations" : "Merge organisations"
"Merge organisations" : "Merge organisations",
"End-of-life feed sync" : "End-of-life feed sync",
"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.",
"Loading EOL sync configuration…" : "Loading EOL sync configuration…",
"Save EOL sync settings" : "Save EOL sync settings",
"Sync now" : "Sync now",
"Last run: {matched} matched, {skipped} skipped, at {time}." : "Last run: {matched} matched, {skipped} skipped, at {time}.",
"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.",
"Enable EOL feed sync" : "Enable EOL feed sync",
"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.",
"Source register and schemas" : "Source register and schemas",
"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.",
"Register slug" : "Register slug",
"eolProduct schema slug" : "eolProduct schema slug",
"eolCycle schema slug" : "eolCycle schema slug",
"Schedule" : "Schedule",
"Sync interval (minutes)" : "Sync interval (minutes)",
"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.",
"Could not load EOL sync configuration" : "Could not load EOL sync configuration",
"Could not load EOL sync status" : "Could not load EOL sync status",
"Could not save EOL sync settings" : "Could not save EOL sync settings",
"EOL sync settings saved" : "EOL sync settings saved",
"EOL sync completed: {matched} matched, {skipped} skipped." : "EOL sync completed: {matched} matched, {skipped} skipped.",
"EOL sync did not run: {reason}" : "EOL sync did not run: {reason}",
"EOL sync failed" : "EOL sync failed",
"EOL feed sync is disabled" : "EOL feed sync is disabled",
"not yet run" : "not yet run",
"OpenRegister is not installed" : "OpenRegister is not installed",
"OpenRegister is not currently reachable" : "OpenRegister is not currently reachable",
"the module/moduleVersie schema is not configured yet" : "the module/moduleVersie schema is not configured yet",
"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?",
"never" : "never"
},
"nplurals=2; plural=(n != 1);"
);
Loading