RANCHER-2997. Security-Patch Versioning Approach — Impact Analysis & Adoption Scope

RANCHER-2997. Security-Patch Versioning Approach — Impact Analysis & Adoption Scope

Summary

  • Root cause of every issue: a critical/security patch (CSP, e.g. 1.0.1-1) and a SNAPSHOT build are both SemVer pre-releases, are indistinguishable to every component, and SNAPSHOT outranks the CSP. The proposal treats a pre-release as a released patch; all SemVer tooling assumes a pre-release is unreleased.

  • The gaps are systemic and surface differently per service:

    • Conflict A — default exclusion: a CSP is a pre-release, so a stable-only path skips it (plugin default; preRelease=false in Okapi/FAR).

    • Conflict B — snapshot outranks CSP: every "latest including pre-release" path returns the CI snapshot over the security patch — plugin max(), Okapi/FAR latest=N&preRelease=only, and FAR dependency resolution (which forces pre-releases on, so ^1.0.2-1 resolves to a snapshot).

    • Conflict E — hard upgrade blocker:mgr-tenant-entitlements rejects any target not strictly greater than the entitled version, so upgrading an entitled app-1.0.2-SNAPSHOT.5 to app-1.0.2-1 fails as a downgrade, with no override.


The Proposed Scheme

Format <major>.<minor>.<patch>-[critical | SNAPSHOT.build], applied to modules and applications.

Event on 1.0.0

Result

Pre-release identifier(s)

Event on 1.0.0

Result

Pre-release identifier(s)

First critical/security fix

1.0.1-1

[1] (numeric)

Second critical fix

1.0.1-2

[2] (numeric)

CI build toward next release

1.0.1-SNAPSHOT.12

[SNAPSHOT, 12]

Bug fix (release)

1.0.1

A CSP is deliberately a pre-release of the next patch, so precedence makes 1.0.1 eventually supersede 1.0.1-1. Ordering on one patch line:

1.0.1-1 < 1.0.1-2 < … < 1.0.1-SNAPSHOT.1 < 1.0.1 (numeric) (alphanumeric) (release)

The decisive SemVer rule throughout: numeric pre-release identifiers always sort below alphanumeric ones, so SNAPSHOT beats any critical number on the same x.y.z.


Findings by Component

folio-application-generator (Maven plugin) — semver4j 5.8.0

Area

Behavior

Verdict

Area

Behavior

Verdict

Parse / normalize

SemverUtils.normalizeVersion early-returns on any -, so 1.0.1-1 / 1.0.1-SNAPSHOT.12 pass through untouched

OK — no collision with UI-snapshot normalization

Compare / order

compareVersions, max-selection via Semver.compareTo

OK — spec-correct

Constraint resolution

getMatchingVersionFromRegistry: includePreRelease defaults FALSE → range ^1.0.0 excludes 1.0.1-1

Conflict A

Pre-release filter

PreReleaseFilter is ONLY/TRUE/FALSE only; with pre-releases on, max() picks 1.0.1-SNAPSHOT.x over 1.0.1-2

Conflict B

Build-number stamping

resolveVersion/updateBuildNumber rewrite the last numeric identifier → 1.0.1-1 + buildNumber becomes 1.0.1-<buildNumber>, corrupting the critical level

Conflict C (plugin-only)

Exact pin / ambiguity

isStableVersion treats 1.0.1-1 as non-stable; normalized UI snapshot 11.0.0-1099… is itself a numeric pre-release, indistinguishable from a critical identifier

Conflict D (minor)

Okapi (folio-registry) — own hand-rolled SemVer

org.folio.okapi.common.SemVer.compareComp implements all four SemVer 2.0.0 pre-release rules (numeric-as-integer; alphanumeric-lexical; numeric always lower than alphanumeric; larger set wins on equal prefix), so ordering is correct: 1.0.1-1 < 1.0.1-2 < 1.0.1-10 < 1.0.1-SNAPSHOT.5 < 1.0.1.

  • /_/proxy/modules supports filter, latest, order/orderBy=id, preRelease, npmSnapshot.

  • Conflict A/B present:preRelease=false hides CSPs; latest=1&preRelease=only returns the snapshot over the CSP.

  • The separate npmSnapshot axis does not helphasNpmSnapshot() means a 3-part version with a 5+ digit patch (UI timestamp snapshots), not -SNAPSHOT.x. So 1.0.1-SNAPSHOT.x and 1.0.1-1 are both just hasPreRelease().

mgr-applications (FAR) — semver4j 5.8.0

The scheme numbers applications too, so app dependencies are ranges like ^1.0.2-1 / ^1.0.2-SNAPSHOT.

  • GET /applications sorts SemVer-aware in Java (not SQL) — listing order is correct.

  • Pre-release filter is the same ternary (Conflict A/B): preRelease=false hides CSPs; latest=1&preRelease=only returns the snapshot.

  • Dependency resolution forces pre-releases ON:ApplicationDescriptorsValidationService and DependenciesValidator call RangesListFactory.create(version, true). So Conflict A does not apply here — but Conflict B is worse: ^1.0.2-1 matches both 1.0.2-1 and 1.0.2-SNAPSHOT.x, and max(bySemver()) picks the snapshot (since 1.0.2-1 < 1.0.2-SNAPSHOT.x). App dependencies resolve to the wrong (snapshot) version.

  • Register/update enforces only exact-ID uniqueness — no downgrade guard here (that lives in entitlements). No DB migration needed (VARCHAR).

mgr-tenant-entitlements — semver4j (via folio-backend-common)

The entitlement upgrade flow refuses any target not strictly newer than what the tenant runs:

return requestVersion.compareTo(entitledVersion) > 0 ? Optional.empty() : Optional.of(new Parameter().key(applicationId) .value("Application version is same or lower than entitled"));
  • UpgradeRequestValidator (duplicated in ValidatorUtils for the declarative /entitlements/state path).

  • No overrideEntitlementRequest exposes only ignoreErrors/purge/async.

  • Conflict E: since 1.0.2-1 < 1.0.2-SNAPSHOT.x, upgrading an entitled app-1.0.2-SNAPSHOT.5 to the security patch app-1.0.2-1 is rejected as a downgrade — the CSP cannot be installed at all. (A tenant on a release1.0.11.0.2-1 — upgrades fine, since 1.0.2-1 > 1.0.1.)


Use Cases

1. Eureka CI builds an application version through the app generator

Flow today. Eureka CI (workflows/scripts in kitfox-github/.github and platform-lsp/.github) invokes the app generator (generateFromJson / updateFromTemplate) as a key step of building or updating an application descriptor. The generator reads a module-version constraint from the template and resolves it to the latest matching version, governed by a preRelease mode carried in the template / branch config (update-config.yml): true (release + pre-release), false (release only), only (pre-release only). These map onto branches — release branches use false, the snapshot branch uses only.

What the new scheme breaks.

  • On a release branch (preRelease="false", the standard release path) the generator will not select a CSP — a CSP is a pre-release, so it is treated as non-release and skipped; the security patch never enters a release application. (Probability: high.)

  • With preRelease="only" a CSP can be returned as "the latest" pre-release, which may be unintended. (Probability: low, but possible.)

  • Independently, Eureka CI's own guard rejects the format: collect-app-version validates against ^[0-9]+\.[0-9]+\.[0-9]+(-SNAPSHOT(\.[0-9]+)?)?$, which does not match a CSP (1.0.1-1 / 1.0.1-z.1) and fails with INVALID_VERSION_FORMAT.

Why doesn't " just set preRelease=true “ save it? Forcing true everywhere means the constraint can no longer carve a clean band: a range meant to admit only releases (or, on a snapshot app, only snapshots) now also pulls in CSPs, because they are pre-releases. You lose the ability to express "this band, without CSPs / without snapshots."

Consequence. Snapshot applications keep building correctly with high probability (today: 100%); release applications would need their CSP releases produced by hand. To avoid opening that error surface, the change must land in every component that uses SemVer over app or module versions — the app generator, Eureka CI (version-format regex, branch preRelease mapping, check-apps.sh), mgr-applications, mgr-tenant-entitlements, and Okapi.

2. Okapi module listing (/_/proxy/modules, folio-registry)

Flow today. The app generator — and the broader community, and likely Index Data pipelines — query Okapi's module registry with a preRelease filter, e.g.:

https://folio-registry.dev.folio.org/_/proxy/modules?filter=folio_serials-management&latest=100&order=desc&preRelease=true&orderBy=id

What the new scheme breaks. A CSP is a pre-release, so the preRelease filter misclassifies it — hidden by false, out-ranked by a coexisting SNAPSHOT under true/only. The parameter stops working correctly for every consumer of the endpoint. There is no workaround — it is broken by the new semantics.

Fix. Change Okapi itself (hand-rolled SemVer / ModuleVersionFilter), which also partially addresses UC1. Okapi is a deployed service: after the change, folio-registry must be re-released and redeployed — additional effort.

Pre-existing UI-snapshot complication. FOLIO UI modules carry a non-SemVer numeric form, e.g. 1.1.0.10000005564, which strict SemVer cannot parse. Both Okapi and the app generator already pre-process these into a numeric pre-release (e.g. 1.1.0-10000005564) before handling. This is a years-deep legacy — the existing artifacts cannot be renumbered. The CSP fix must keep these UI snapshots distinct from CSPs so the new numbering does not collide.

3. FAR application listing (/applications, mgr-applications)

Flow today. The same pattern one level up, for applications, served by mgr-applications (FAR), e.g.:

https://far.ci.folio.org/applications?query=name=app-requests-mediated&preRelease=false&orderBy=version&order=desc&limit=10

check-apps.sh (platform-lsp) already drives this endpoint (…&preRelease=only&latest=1) and trusts FAR to return the correct latest.

What the new scheme breaks. Identical to UC2 — the preRelease parameter misclassifies CSP application versions and stops working correctly; no workaround. Fix: change mgr-applications and redeploy far.ci. The one relief vs. UC2: there is no UI-module legacy at the application level.

4. Promote a tested CSP onto a tenant (mgr-tenant-entitlements)

Flow today. To validate a CSP before release, an operator first builds a snapshot of that CSP and installs it on a test environment — e.g., app-platform-complete-1.0.1-1.SNAPSHOT.x — then upgrades the tenant to the real CSP release app-platform-complete-1.0.1-1.

What the new scheme breaks. The snapshot-of-the-CSP carries more pre-release fields than the CSP, so 1.0.1-1.SNAPSHOT.x outranks 1.0.1-1. The entitlement upgrade therefore sees the target as lower-or-equal and rejects it:

return requestVersion.compareTo(entitledVersion) > 0 ? Optional.empty() : Optional.of(new Parameter().key(applicationId) .value("Application version is same or lower than entitled"));

This guard lives in mgr-tenant-entitlements and has no bypass (verified in source). The only way to move the test tenant from the CSP snapshot to the CSP release is to change this module's code.


What Adopting the Scheme Means

The version format is valid and orders correctly everywhere (see above). The problem is operational: with the code as it stands today, adopting the scheme does not deliver its goal — "ship a security patch on any release line." End to end:

  • A published CSP (1.0.1-1) is invisible to default resolution — the app generator resolves ^1.0.0 to the release and silently skips the patch.

  • Wherever pre-releases are turned on to surface the CSP, a SNAPSHOT outranks it and is picked instead (plugin max(), Okapi/FAR latest, FAR dependency resolution).

  • The entitlement upgrade hard-rejects SNAPSHOT.x → CSP as a downgrade.

So the scheme is format-correct but operationally blocked by the current code.


Can It Be Mitigated Without Code Changes?

Component

No-code workaround

Verdict

Component

No-code workaround

Verdict

Plugin (app generator)

Pin the exact CSP (=1.0.1-1) in every template, per module, every release; never use ranges

Possible but unscalable and error-prone — defeats the automation

Okapi / FAR (mgr-applications)

Query with explicit flags and hand-pick the CSP over the snapshot; every downstream consumer must do the same

Possible but manual at every call site

mgr-tenant-entitlements

None. The upgrade validator is a mandatory pre-flight stage; no force/skipVersionCheck flag exists; ignoreErrors only changes post-validation execution strategy; equal-or-lower is rejected strictly

Hard wall — a tenant cannot be patched without a code change

Bottom line: "do nothing and work around it by hand" is not a real option. The plugin/registry workarounds are manual toil; the entitlement path has no override at all (verified in source — see Appendix). Manual mitigation is painful where it exists and impossible where it matters most.


Change the code across every affected component

The only viable adoption is a coordinated change across every component that handles SemVer over app or module versions, so they classify a pre-release as CSP vs. SNAPSHOT and behave consistently wherever a version is selected or validated:

  • folio-application-generator — classifier in SemverUtils; selection policy in ModuleVersionService (default = releases + CSP, exclude SNAPSHOT); extend PreReleaseFilter; stamping guard in ApplicationDescriptorUpdateService.

#

Change

Location

Effort

#

Change

Location

Effort

1

Pre-release classifier (isCriticalPatch / isSnapshot)

SemverUtils

S

2

Selection policy: default = releases + CSP, exclude snapshots (per Decision 1)

ModuleVersionService

M

3

Filter model: third axis or classifier-driven

PreReleaseFilter

S–M

4

Guard stamping so only SNAPSHOT pre-releases get build numbers

ApplicationDescriptorUpdateService

S

  • Eureka CI (kitfox-github/.github, platform-lsp/.github) — widen the version-format regex in collect-app-version; revisit the branch→preRelease mapping in update-config.yml; adjust the check-apps.sh FAR query and equality check; CSP-aware bump logic in release-preparation-flow.

  • Okapi — distinguish -SNAPSHOT.x from CSP in ModuleVersionFilter/ModuleUtil.filter; optional latest preference for the shipped patch over a snapshot; redeploy folio-registry.

#

Change

Location

Effort

#

Change

Location

Effort

1

Distinguish -SNAPSHOT.x from CSP (extend ModuleVersionFilter / reuse npmSnapshot)

ModuleVersionFilter, ModuleUtil.filter

M

2

Optional latest preference for shipped patch over snapshot

DepResolution.getLatestProducts

M

Infra: redeploy folio-registry; coordinate /_/proxy/modules consumers

infra

  • mgr-applications (FAR) — CSP-vs-snapshot selection (ApplicationDescriptorsValidationService, DependenciesValidator); optional filter dimension on GET /applications; redeploy far.ci (no DB migration).

#

Change

Location

Effort

#

Change

Location

Effort

1

Classifier + CSP-vs-snapshot selection so ^1.0.2-1 resolves to the CSP

ApplicationDescriptorsValidationService, DependenciesValidator

M

2

Optional snapshot-vs-CSP filter dimension on GET /applications

ApplicationService, am.yaml

M

Infra: redeploy far.ci

infra

  • mgr-tenant-entitlements — make the upgrade guard CSP-aware (a CSP / its release must not be blocked by an installed snapshot of the same target) or add an explicit override; fix UpgradeRequestValidator + the duplicated ValidatorUtils.

#

Change

Location

Effort

#

Change

Location

Effort

1

Make the upgrade guard CSP-aware (a CSP must not be blocked by an installed snapshot of the same/lower target), or add an explicit override

UpgradeRequestValidator