From a5b729220cb7474fda98279dba79b5f81c7064c6 Mon Sep 17 00:00:00 2001
From: Morre <morre@mor.re>
Date: Thu, 1 Jun 2023 18:41:39 +0200
Subject: [PATCH] feat(datasource/endoflife-date): add endoflife.date
 datasource (#21994)

Co-authored-by: Sebastian Poxhofer <secustor@users.noreply.github.com>
Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
Co-authored-by: secustor <sebastian@poxhofer.at>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
---
 lib/modules/datasource/api.ts                 |   2 +
 .../__fixtures__/apache-cassandra.json        |  40 ++++
 .../endoflife-date/__fixtures__/eks.json      |  74 +++++++
 .../__fixtures__/fairphone.json               |  42 ++++
 .../datasource/endoflife-date/common.ts       |   2 +
 .../datasource/endoflife-date/index.spec.ts   | 193 ++++++++++++++++++
 .../datasource/endoflife-date/index.ts        |  56 +++++
 .../datasource/endoflife-date/readme.md       |  46 +++++
 .../datasource/endoflife-date/schema.ts       |  42 ++++
 9 files changed, 497 insertions(+)
 create mode 100644 lib/modules/datasource/endoflife-date/__fixtures__/apache-cassandra.json
 create mode 100644 lib/modules/datasource/endoflife-date/__fixtures__/eks.json
 create mode 100644 lib/modules/datasource/endoflife-date/__fixtures__/fairphone.json
 create mode 100644 lib/modules/datasource/endoflife-date/common.ts
 create mode 100644 lib/modules/datasource/endoflife-date/index.spec.ts
 create mode 100644 lib/modules/datasource/endoflife-date/index.ts
 create mode 100644 lib/modules/datasource/endoflife-date/readme.md
 create mode 100644 lib/modules/datasource/endoflife-date/schema.ts

diff --git a/lib/modules/datasource/api.ts b/lib/modules/datasource/api.ts
index e75ad8aa10..413c8dbf17 100644
--- a/lib/modules/datasource/api.ts
+++ b/lib/modules/datasource/api.ts
@@ -16,6 +16,7 @@ import { DartVersionDatasource } from './dart-version';
 import { DenoDatasource } from './deno';
 import { DockerDatasource } from './docker';
 import { DotnetVersionDatasource } from './dotnet-version';
+import { EndoflifeDatePackagesource } from './endoflife-date';
 import { FlutterVersionDatasource } from './flutter-version';
 import { GalaxyDatasource } from './galaxy';
 import { GalaxyCollectionDatasource } from './galaxy-collection';
@@ -76,6 +77,7 @@ api.set(DartVersionDatasource.id, new DartVersionDatasource());
 api.set(DenoDatasource.id, new DenoDatasource());
 api.set(DockerDatasource.id, new DockerDatasource());
 api.set(DotnetVersionDatasource.id, new DotnetVersionDatasource());
+api.set(EndoflifeDatePackagesource.id, new EndoflifeDatePackagesource());
 api.set(FlutterVersionDatasource.id, new FlutterVersionDatasource());
 api.set(GalaxyDatasource.id, new GalaxyDatasource());
 api.set(GalaxyCollectionDatasource.id, new GalaxyCollectionDatasource());
diff --git a/lib/modules/datasource/endoflife-date/__fixtures__/apache-cassandra.json b/lib/modules/datasource/endoflife-date/__fixtures__/apache-cassandra.json
new file mode 100644
index 0000000000..919e7fef24
--- /dev/null
+++ b/lib/modules/datasource/endoflife-date/__fixtures__/apache-cassandra.json
@@ -0,0 +1,40 @@
+[
+  {
+    "cycle": "4.1",
+    "eol": "2025-05-01",
+    "support": true,
+    "releaseDate": "2022-12-13",
+    "latest": "4.1.1",
+    "latestReleaseDate": "2023-03-21",
+    "lts": false
+  },
+  {
+    "cycle": "4.0",
+    "eol": "2024-05-01",
+    "support": true,
+    "releaseDate": "2021-07-26",
+    "latest": "4.0.9",
+    "latestReleaseDate": "2023-04-14",
+    "lts": false
+  },
+  {
+    "cycle": "3.11",
+    "eol": "2023-05-01",
+    "support": true,
+    "releaseDate": "2017-06-23",
+    "latest": "3.11.15",
+    "discontinued": true,
+    "latestReleaseDate": "2023-05-05",
+    "lts": false
+  },
+  {
+    "cycle": "3.0",
+    "eol": "2023-05-01",
+    "support": true,
+    "releaseDate": "2015-11-09",
+    "latest": "3.0.29",
+    "discontinued": true,
+    "latestReleaseDate": "2023-05-15",
+    "lts": false
+  }
+]
diff --git a/lib/modules/datasource/endoflife-date/__fixtures__/eks.json b/lib/modules/datasource/endoflife-date/__fixtures__/eks.json
new file mode 100644
index 0000000000..ce0291509b
--- /dev/null
+++ b/lib/modules/datasource/endoflife-date/__fixtures__/eks.json
@@ -0,0 +1,74 @@
+[
+  {
+    "cycle": "1.26",
+    "releaseDate": "2023-04-11",
+    "eol": false,
+    "latest": "1.26-eks-1",
+    "latestReleaseDate": "2023-04-11",
+    "lts": false
+  },
+  {
+    "cycle": "1.25",
+    "releaseDate": "2023-02-21",
+    "eol": "2024-05-01",
+    "latest": "1.25-eks-3",
+    "latestReleaseDate": "2023-04-06",
+    "lts": false
+  },
+  {
+    "cycle": "1.24",
+    "eol": "2024-01-01",
+    "latest": "1.24-eks-6",
+    "latestReleaseDate": "2023-04-06",
+    "releaseDate": "2022-11-15",
+    "lts": false
+  },
+  {
+    "cycle": "1.23",
+    "eol": "2023-10-01",
+    "latest": "1.23-eks-8",
+    "releaseDate": "2022-08-11",
+    "latestReleaseDate": "2023-04-06",
+    "lts": false
+  },
+  {
+    "cycle": "1.22",
+    "eol": "2023-06-04",
+    "latest": "1.22-eks-12",
+    "releaseDate": "2022-04-04",
+    "latestReleaseDate": "2023-04-06",
+    "lts": false
+  },
+  {
+    "cycle": "1.21",
+    "eol": "2023-02-15",
+    "latest": "1.21-eks-17",
+    "releaseDate": "2021-07-19",
+    "latestReleaseDate": "2023-04-06",
+    "lts": false
+  },
+  {
+    "cycle": "1.20",
+    "eol": "2022-11-01",
+    "latest": "1.20-eks-14",
+    "releaseDate": "2021-05-18",
+    "latestReleaseDate": "2023-04-06",
+    "lts": false
+  },
+  {
+    "cycle": "1.19",
+    "eol": "2022-08-01",
+    "latest": "1.19-eks-11",
+    "releaseDate": "2021-02-16",
+    "latestReleaseDate": "2022-08-15",
+    "lts": false
+  },
+  {
+    "cycle": "1.18",
+    "eol": "2022-03-31",
+    "latest": "1.18-eks-13",
+    "releaseDate": "2020-10-13",
+    "latestReleaseDate": "2022-08-15",
+    "lts": false
+  }
+]
diff --git a/lib/modules/datasource/endoflife-date/__fixtures__/fairphone.json b/lib/modules/datasource/endoflife-date/__fixtures__/fairphone.json
new file mode 100644
index 0000000000..c4fc18537f
--- /dev/null
+++ b/lib/modules/datasource/endoflife-date/__fixtures__/fairphone.json
@@ -0,0 +1,42 @@
+[
+  {
+    "cycle": "4",
+    "discontinued": false,
+    "eol": "2026-09-30",
+    "releaseDate": "2021-09-30",
+    "link": "https://shop.fairphone.com/buy-fairphone-4",
+    "lts": false
+  },
+  {
+    "cycle": "3+",
+    "discontinued": "2022-11-01",
+    "eol": "2025-09-30",
+    "releaseDate": "2020-09-30",
+    "link": "https://shop.fairphone.com/fairphone-3-plus",
+    "lts": false
+  },
+  {
+    "cycle": "3",
+    "discontinued": "2021-09-01",
+    "eol": "2024-09-30",
+    "releaseDate": "2019-09-30",
+    "link": "https://shop.fairphone.com/fairphone-3",
+    "lts": false
+  },
+  {
+    "cycle": "2",
+    "discontinued": "2019-03-31",
+    "eol": "2023-03-07",
+    "releaseDate": "2015-12-21",
+    "link": "https://support.fairphone.com/hc/articles/213290023-FP2-Fairphone-OS-downloads",
+    "lts": false
+  },
+  {
+    "cycle": "1",
+    "discontinued": "2017-07-13",
+    "eol": "2017-07-13",
+    "releaseDate": "2013-12-01",
+    "link": "https://support.fairphone.com/hc/articles/6217522827281-Fairphone-1-Frequently-Asked-Questions-FAQ-",
+    "lts": false
+  }
+]
diff --git a/lib/modules/datasource/endoflife-date/common.ts b/lib/modules/datasource/endoflife-date/common.ts
new file mode 100644
index 0000000000..71b3a4c953
--- /dev/null
+++ b/lib/modules/datasource/endoflife-date/common.ts
@@ -0,0 +1,2 @@
+export const registryUrl = 'https://endoflife.date/api';
+export const datasource = 'endoflife-date';
diff --git a/lib/modules/datasource/endoflife-date/index.spec.ts b/lib/modules/datasource/endoflife-date/index.spec.ts
new file mode 100644
index 0000000000..6e5bc45123
--- /dev/null
+++ b/lib/modules/datasource/endoflife-date/index.spec.ts
@@ -0,0 +1,193 @@
+import { getPkgReleases } from '..';
+import { Fixtures } from '../../../../test/fixtures';
+import * as httpMock from '../../../../test/http-mock';
+import { EXTERNAL_HOST_ERROR } from '../../../constants/error-messages';
+import { registryUrl } from './common';
+import { EndoflifeDatePackagesource } from './index';
+
+const datasource = EndoflifeDatePackagesource.id;
+
+// Default package name and mock path to test with
+const packageName = 'amazon-eks';
+const eksMockPath = `/${packageName}.json`;
+
+describe('modules/datasource/endoflife-date/index', () => {
+  describe('getReleases', () => {
+    it('processes real data', async () => {
+      httpMock
+        .scope(registryUrl)
+        .get(eksMockPath)
+        .reply(200, Fixtures.getJson(`eks.json`));
+      const res = await getPkgReleases({
+        datasource,
+        packageName,
+      });
+      expect(res).toEqual({
+        registryUrl: 'https://endoflife.date/api',
+        releases: [
+          {
+            isDeprecated: true,
+            releaseTimestamp: '2020-10-13T00:00:00.000Z',
+            version: '1.18-eks-13',
+          },
+          {
+            isDeprecated: true,
+            releaseTimestamp: '2021-02-16T00:00:00.000Z',
+            version: '1.19-eks-11',
+          },
+          {
+            isDeprecated: true,
+            releaseTimestamp: '2021-05-18T00:00:00.000Z',
+            version: '1.20-eks-14',
+          },
+          {
+            isDeprecated: true,
+            releaseTimestamp: '2021-07-19T00:00:00.000Z',
+            version: '1.21-eks-17',
+          },
+          {
+            isDeprecated: false,
+            releaseTimestamp: '2022-04-04T00:00:00.000Z',
+            version: '1.22-eks-12',
+          },
+          {
+            isDeprecated: false,
+            releaseTimestamp: '2022-08-11T00:00:00.000Z',
+            version: '1.23-eks-8',
+          },
+          {
+            isDeprecated: false,
+            releaseTimestamp: '2022-11-15T00:00:00.000Z',
+            version: '1.24-eks-6',
+          },
+          {
+            isDeprecated: false,
+            releaseTimestamp: '2023-02-21T00:00:00.000Z',
+            version: '1.25-eks-3',
+          },
+          {
+            isDeprecated: false,
+            releaseTimestamp: '2023-04-11T00:00:00.000Z',
+            version: '1.26-eks-1',
+          },
+        ],
+      });
+    });
+
+    it('returns null without registryUrl', async () => {
+      const endoflifeDateDatasource = new EndoflifeDatePackagesource();
+      const res = await endoflifeDateDatasource.getReleases({
+        registryUrl: '',
+        packageName,
+      });
+      expect(res).toBeNull();
+    });
+
+    it('returns null for 404', async () => {
+      httpMock.scope(registryUrl).get(eksMockPath).reply(404);
+      expect(
+        await getPkgReleases({
+          datasource,
+          packageName,
+        })
+      ).toBeNull();
+    });
+
+    it('returns null for empty result', async () => {
+      httpMock.scope(registryUrl).get(eksMockPath).reply(200, {});
+      expect(
+        await getPkgReleases({
+          datasource,
+          packageName,
+        })
+      ).toBeNull();
+    });
+
+    it('throws for 5xx', async () => {
+      httpMock.scope(registryUrl).get(eksMockPath).reply(502);
+      await expect(
+        getPkgReleases({
+          datasource,
+          packageName,
+        })
+      ).rejects.toThrow(EXTERNAL_HOST_ERROR);
+    });
+
+    it('detects boolean discontinuation', async () => {
+      httpMock
+        .scope(registryUrl)
+        .get('/apache-cassandra.json')
+        .reply(200, Fixtures.getJson(`apache-cassandra.json`));
+      const res = await getPkgReleases({
+        datasource,
+        packageName: 'apache-cassandra',
+      });
+      expect(res).toEqual({
+        registryUrl: 'https://endoflife.date/api',
+        releases: [
+          {
+            isDeprecated: true,
+            releaseTimestamp: '2015-11-09T00:00:00.000Z',
+            version: '3.0.29',
+          },
+          {
+            isDeprecated: true,
+            releaseTimestamp: '2017-06-23T00:00:00.000Z',
+            version: '3.11.15',
+          },
+          {
+            isDeprecated: false,
+            releaseTimestamp: '2021-07-26T00:00:00.000Z',
+            version: '4.0.9',
+          },
+          {
+            isDeprecated: false,
+            releaseTimestamp: '2022-12-13T00:00:00.000Z',
+            version: '4.1.1',
+          },
+        ],
+      });
+    });
+
+    it('detects date discontinuation', async () => {
+      httpMock
+        .scope(registryUrl)
+        .get('/fairphone.json')
+        .reply(200, Fixtures.getJson(`fairphone.json`));
+      const res = await getPkgReleases({
+        datasource,
+        packageName: 'fairphone',
+      });
+      expect(res).toEqual({
+        registryUrl: 'https://endoflife.date/api',
+        releases: [
+          {
+            isDeprecated: true,
+            releaseTimestamp: '2013-12-01T00:00:00.000Z',
+            version: '1',
+          },
+          {
+            isDeprecated: true,
+            releaseTimestamp: '2015-12-21T00:00:00.000Z',
+            version: '2',
+          },
+          {
+            isDeprecated: true,
+            releaseTimestamp: '2020-09-30T00:00:00.000Z',
+            version: '3+',
+          },
+          {
+            isDeprecated: true,
+            releaseTimestamp: '2019-09-30T00:00:00.000Z',
+            version: '3',
+          },
+          {
+            isDeprecated: false,
+            releaseTimestamp: '2021-09-30T00:00:00.000Z',
+            version: '4',
+          },
+        ],
+      });
+    });
+  });
+});
diff --git a/lib/modules/datasource/endoflife-date/index.ts b/lib/modules/datasource/endoflife-date/index.ts
new file mode 100644
index 0000000000..9eba4d50eb
--- /dev/null
+++ b/lib/modules/datasource/endoflife-date/index.ts
@@ -0,0 +1,56 @@
+import is from '@sindresorhus/is';
+import { logger } from '../../../logger';
+import { cache } from '../../../util/cache/package/decorator';
+import { joinUrlParts } from '../../../util/url';
+import { Datasource } from '../datasource';
+import type { GetReleasesConfig, ReleaseResult } from '../types';
+import { datasource, registryUrl } from './common';
+import { EndoflifeHttpResponseScheme } from './schema';
+
+export class EndoflifeDatePackagesource extends Datasource {
+  static readonly id = datasource;
+
+  override readonly defaultRegistryUrls = [registryUrl];
+  override readonly caching = true;
+  override readonly defaultVersioning = 'loose';
+
+  constructor() {
+    super(EndoflifeDatePackagesource.id);
+  }
+
+  @cache({
+    namespace: `datasource-${datasource}`,
+    key: ({ registryUrl, packageName }: GetReleasesConfig) =>
+      // TODO: types (#7154)
+      `${registryUrl!}:${packageName}`,
+  })
+  async getReleases({
+    registryUrl,
+    packageName,
+  }: GetReleasesConfig): Promise<ReleaseResult | null> {
+    if (!is.nonEmptyString(registryUrl)) {
+      return null;
+    }
+
+    logger.trace(`${datasource}.getReleases(${registryUrl}, ${packageName})`);
+
+    const result: ReleaseResult = {
+      releases: [],
+    };
+
+    const url = joinUrlParts(registryUrl, `${packageName}.json`);
+
+    try {
+      const response = await this.http.getJson(
+        url,
+        EndoflifeHttpResponseScheme
+      );
+
+      result.releases.push(...response.body);
+
+      return result.releases.length ? result : null;
+    } catch (err) {
+      this.handleGenericErrors(err);
+    }
+  }
+}
diff --git a/lib/modules/datasource/endoflife-date/readme.md b/lib/modules/datasource/endoflife-date/readme.md
new file mode 100644
index 0000000000..0c79625572
--- /dev/null
+++ b/lib/modules/datasource/endoflife-date/readme.md
@@ -0,0 +1,46 @@
+[endoflife.date](https://endoflife.date) provides version and end-of-life information for different packages.
+
+To find the appropriate "package" name for the software you're trying to update, use the endoflife.date "All packages" API endpoint.
+You can find it in [the endoflife.date API documentation](https://endoflife.date/docs/api).
+
+By default, this datasource uses `loose` versioning.
+If possible, we recommend you use a stricter versioning like `semver` instead of `loose`.
+
+**Usage Example**
+
+Say you're using Amazon EKS and want Renovate to update the versions in a Terraform `.tfvars` file.
+For example, you have this `.tfvars` file:
+
+```hcl
+# renovate: datasource=endoflife-date depName=amazon-eks versioning=loose
+kubernetes_version = "1.26"
+```
+
+Given the above `.tfvars` file, you put this in your `renovate.json`:
+
+```json
+{
+  "regexManagers": [
+    {
+      "description": "Update Kubernetes version for Amazon EKS in tfvars files",
+      "fileMatch": [".+\\.tfvars$"],
+      "matchStrings": [
+        "#\\s*renovate:\\s*datasource=(?<datasource>.*?) depName=(?<depName>.*?)( versioning=(?<versioning>.*?))?\\s.*?_version\\s*=\\s*\"(?<currentValue>.*)\""
+      ],
+      "versioningTemplate": "{{#if versioning}}{{{versioning}}}{{/if}}"
+    }
+  ],
+  "packageRules": [
+    {
+      "matchDatasources": ["endoflife-date"],
+      "matchPackageNames": ["amazon-eks"],
+      "extractVersion": "^(?<version>.*)-eks.+$"
+    }
+  ]
+}
+```
+
+With this configuration, Renovate will parse all `*.tfvars` files in the repository.
+It will then update variables that end with `_version` and have the `# renovate: datasource=endoflife-date depName=dependency-name versioning=versioning` comment set in the line above when any new versions are available.
+
+For `amazon-eks`, the defined `packageRule` above will also strip the `-eks-${eks-release-version}` suffix to only set the Kubernetes minor version.
diff --git a/lib/modules/datasource/endoflife-date/schema.ts b/lib/modules/datasource/endoflife-date/schema.ts
new file mode 100644
index 0000000000..38b58c98e5
--- /dev/null
+++ b/lib/modules/datasource/endoflife-date/schema.ts
@@ -0,0 +1,42 @@
+import { DateTime } from 'luxon';
+import { z } from 'zod';
+import type { Release } from '../types';
+
+const EndoflifeDateVersionScheme = z
+  .object({
+    cycle: z.string(),
+    latest: z.optional(z.string()),
+    releaseDate: z.optional(z.string()),
+    eol: z.optional(z.union([z.string(), z.boolean()])),
+    discontinued: z.optional(z.union([z.string(), z.boolean()])),
+  })
+  .transform(({ cycle, latest, releaseDate, eol, discontinued }): Release => {
+    let isDeprecated = false;
+
+    // If "eol" date or "discontinued" date has passed or any of the values is explicitly true, set to deprecated
+    // "support" is not checked because support periods sometimes end before the EOL.
+    if (
+      eol === true ||
+      discontinued === true ||
+      (typeof eol === 'string' &&
+        DateTime.fromISO(eol, { zone: 'utc' }) <= DateTime.now().toUTC()) ||
+      (typeof discontinued === 'string' &&
+        DateTime.fromISO(discontinued, { zone: 'utc' }) <=
+          DateTime.now().toUTC())
+    ) {
+      isDeprecated = true;
+    }
+
+    let version = cycle;
+    if (latest !== undefined) {
+      version = latest;
+    }
+
+    return {
+      version,
+      releaseTimestamp: releaseDate,
+      isDeprecated,
+    };
+  });
+
+export const EndoflifeHttpResponseScheme = z.array(EndoflifeDateVersionScheme);
-- 
GitLab