From ea64bf5935bd5b3d0fda822f94b6af353d2ddc4e Mon Sep 17 00:00:00 2001
From: Sergei Zharinov <zharinov@users.noreply.github.com>
Date: Thu, 16 Jan 2025 07:54:10 -0300
Subject: [PATCH] feat(helm): Use schema for datasource (#33577)

Co-authored-by: Rhys Arkins <rhys@arkins.net>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
 lib/modules/datasource/helm/common.spec.ts | 26 -------
 lib/modules/datasource/helm/common.ts      | 43 ------------
 lib/modules/datasource/helm/index.ts       | 76 ++++----------------
 lib/modules/datasource/helm/schema.spec.ts | 41 +++++++++++
 lib/modules/datasource/helm/schema.ts      | 82 ++++++++++++++++++++++
 lib/modules/datasource/helm/types.ts       | 16 -----
 6 files changed, 137 insertions(+), 147 deletions(-)
 delete mode 100644 lib/modules/datasource/helm/common.spec.ts
 delete mode 100644 lib/modules/datasource/helm/common.ts
 create mode 100644 lib/modules/datasource/helm/schema.spec.ts
 create mode 100644 lib/modules/datasource/helm/schema.ts
 delete mode 100644 lib/modules/datasource/helm/types.ts

diff --git a/lib/modules/datasource/helm/common.spec.ts b/lib/modules/datasource/helm/common.spec.ts
deleted file mode 100644
index 84b88120db..0000000000
--- a/lib/modules/datasource/helm/common.spec.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { Fixtures } from '../../../../test/fixtures';
-import { parseSingleYaml } from '../../../util/yaml';
-import { findSourceUrl } from './common';
-import type { HelmRepository } from './types';
-
-// Truncated index.yaml file
-const repo = parseSingleYaml<HelmRepository>(Fixtures.get('sample.yaml'));
-
-describe('modules/datasource/helm/common', () => {
-  describe('findSourceUrl', () => {
-    it.each`
-      input                     | output
-      ${'airflow'}              | ${'https://github.com/bitnami/charts/tree/master/bitnami/airflow'}
-      ${'coredns'}              | ${'https://github.com/coredns/helm'}
-      ${'pgadmin4'}             | ${'https://github.com/rowanruseler/helm-charts'}
-      ${'private-chart-github'} | ${'https://github.example.com/some-org/charts/tree/master/private-chart'}
-      ${'private-chart-gitlab'} | ${'https://gitlab.example.com/some/group/charts/-/tree/master/private-chart'}
-      ${'dummy'}                | ${null}
-    `(
-      '$input -> $output',
-      ({ input, output }: { input: string; output: string }) => {
-        expect(findSourceUrl(repo.entries[input][0])).toEqual(output);
-      },
-    );
-  });
-});
diff --git a/lib/modules/datasource/helm/common.ts b/lib/modules/datasource/helm/common.ts
deleted file mode 100644
index 0b32a3c33f..0000000000
--- a/lib/modules/datasource/helm/common.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { detectPlatform } from '../../../util/common';
-import { parseGitUrl } from '../../../util/git/url';
-import { regEx } from '../../../util/regex';
-import type { HelmRelease } from './types';
-
-const chartRepo = regEx(/charts?|helm|helm-charts/i);
-const githubRelease = regEx(
-  /^(https:\/\/github\.com\/[^/]+\/[^/]+)\/releases\//,
-);
-
-function isPossibleChartRepo(url: string): boolean {
-  if (detectPlatform(url) === null) {
-    return false;
-  }
-
-  const parsed = parseGitUrl(url);
-  return chartRepo.test(parsed.name);
-}
-
-export function findSourceUrl(release: HelmRelease): string | null {
-  // it's a github release :)
-  const releaseMatch = githubRelease.exec(release.urls[0]);
-  if (releaseMatch) {
-    return releaseMatch[1];
-  }
-
-  if (release.home && isPossibleChartRepo(release.home)) {
-    return release.home;
-  }
-
-  if (!release.sources?.length) {
-    return null;
-  }
-
-  for (const url of release.sources) {
-    if (isPossibleChartRepo(url)) {
-      return url;
-    }
-  }
-
-  // fallback
-  return release.sources[0];
-}
diff --git a/lib/modules/datasource/helm/index.ts b/lib/modules/datasource/helm/index.ts
index 6ca3681c98..506b6a4690 100644
--- a/lib/modules/datasource/helm/index.ts
+++ b/lib/modules/datasource/helm/index.ts
@@ -1,14 +1,11 @@
-import is from '@sindresorhus/is';
 import { logger } from '../../../logger';
 import { cache } from '../../../util/cache/package/decorator';
-import type { HttpResponse } from '../../../util/http/types';
 import { ensureTrailingSlash } from '../../../util/url';
-import { parseSingleYaml } from '../../../util/yaml';
 import * as helmVersioning from '../../versioning/helm';
 import { Datasource } from '../datasource';
 import type { GetReleasesConfig, ReleaseResult } from '../types';
-import { findSourceUrl } from './common';
-import type { HelmRepository, HelmRepositoryData } from './types';
+import type { HelmRepositoryData } from './schema';
+import { HelmRepositorySchema } from './schema';
 
 export class HelmDatasource extends Datasource {
   static readonly id = 'helm';
@@ -34,63 +31,22 @@ export class HelmDatasource extends Datasource {
 
   @cache({
     namespace: `datasource-${HelmDatasource.id}`,
-    key: (helmRepository: string) => helmRepository,
+    key: (helmRepository: string) => `repository-data:${helmRepository}`,
   })
-  async getRepositoryData(
-    helmRepository: string,
-  ): Promise<HelmRepositoryData | null> {
-    let res: HttpResponse<string>;
-    try {
-      res = await this.http.get('index.yaml', {
-        baseUrl: ensureTrailingSlash(helmRepository),
-      });
-      if (!res?.body) {
-        logger.warn(
-          { helmRepository },
-          `Received invalid response from helm repository`,
-        );
-        return null;
-      }
-    } catch (err) {
+  async getRepositoryData(helmRepository: string): Promise<HelmRepositoryData> {
+    const { val, err } = await this.http
+      .getYamlSafe(
+        'index.yaml',
+        { baseUrl: ensureTrailingSlash(helmRepository) },
+        HelmRepositorySchema,
+      )
+      .unwrap();
+
+    if (err) {
       this.handleGenericErrors(err);
     }
-    try {
-      // TODO: use schema (#9610)
-      const doc = parseSingleYaml<HelmRepository>(res.body);
-      if (!is.plainObject<HelmRepository>(doc)) {
-        logger.warn(
-          { helmRepository },
-          `Failed to parse index.yaml from helm repository`,
-        );
-        return null;
-      }
-      const result: HelmRepositoryData = {};
-      for (const [name, releases] of Object.entries(doc.entries)) {
-        if (releases.length === 0) {
-          continue;
-        }
-        const latestRelease = releases[0];
-        const sourceUrl = findSourceUrl(latestRelease);
-        result[name] = {
-          homepage: latestRelease.home,
-          sourceUrl,
-          releases: releases.map((release) => ({
-            version: release.version,
-            releaseTimestamp: release.created ?? null,
-            // The Helm repository at Gitlab does not include a digest (#24280)
-            newDigest: release.digest ?? undefined,
-          })),
-        };
-      }
 
-      return result;
-    } catch (err) {
-      logger.debug(
-        { helmRepository, err },
-        `Failed to parse index.yaml from helm repository`,
-      );
-      return null;
-    }
+    return val;
   }
 
   async getReleases({
@@ -103,10 +59,6 @@ export class HelmDatasource extends Datasource {
     }
 
     const repositoryData = await this.getRepositoryData(helmRepository);
-    if (!repositoryData) {
-      logger.debug(`Missing repo data from ${helmRepository}`);
-      return null;
-    }
     const releases = repositoryData[packageName];
     if (!releases) {
       logger.debug(
diff --git a/lib/modules/datasource/helm/schema.spec.ts b/lib/modules/datasource/helm/schema.spec.ts
new file mode 100644
index 0000000000..52f10871fb
--- /dev/null
+++ b/lib/modules/datasource/helm/schema.spec.ts
@@ -0,0 +1,41 @@
+import { Fixtures } from '../../../../test/fixtures';
+import { Yaml } from '../../../util/schema-utils';
+import { HelmRepositorySchema } from './schema';
+
+describe('modules/datasource/helm/schema', () => {
+  describe('sourceUrl', () => {
+    it('works', () => {
+      const repo = Yaml.pipe(HelmRepositorySchema).parse(
+        Fixtures.get('sample.yaml'),
+      );
+      expect(repo).toMatchObject({
+        airflow: {
+          homepage:
+            'https://github.com/bitnami/charts/tree/master/bitnami/airflow',
+          sourceUrl:
+            'https://github.com/bitnami/charts/tree/master/bitnami/airflow',
+        },
+        coredns: {
+          homepage: 'https://coredns.io',
+          sourceUrl: 'https://github.com/coredns/helm',
+        },
+        pgadmin4: {
+          homepage: 'https://www.pgadmin.org/',
+          sourceUrl: 'https://github.com/rowanruseler/helm-charts',
+        },
+        'private-chart-github': {
+          homepage:
+            'https://github.example.com/some-org/charts/tree/master/private-chart',
+          sourceUrl:
+            'https://github.example.com/some-org/charts/tree/master/private-chart',
+        },
+        'private-chart-gitlab': {
+          homepage:
+            'https://gitlab.example.com/some/group/charts/-/tree/master/private-chart',
+          sourceUrl:
+            'https://gitlab.example.com/some/group/charts/-/tree/master/private-chart',
+        },
+      });
+    });
+  });
+});
diff --git a/lib/modules/datasource/helm/schema.ts b/lib/modules/datasource/helm/schema.ts
new file mode 100644
index 0000000000..d10d90120b
--- /dev/null
+++ b/lib/modules/datasource/helm/schema.ts
@@ -0,0 +1,82 @@
+import { z } from 'zod';
+import { detectPlatform } from '../../../util/common';
+import { parseGitUrl } from '../../../util/git/url';
+import { regEx } from '../../../util/regex';
+import { LooseRecord } from '../../../util/schema-utils';
+import type { Release } from '../types';
+
+const HelmReleaseSchema = z.object({
+  version: z.string(),
+  created: z.string().nullable().catch(null),
+  digest: z.string().optional().catch(undefined),
+  home: z.string().optional().catch(undefined),
+  sources: z.array(z.string()).catch([]),
+  urls: z.array(z.string()).catch([]),
+});
+type HelmRelease = z.infer<typeof HelmReleaseSchema>;
+
+const chartRepo = regEx(/charts?|helm|helm-charts/i);
+
+function isPossibleChartRepo(url: string): boolean {
+  if (detectPlatform(url) === null) {
+    return false;
+  }
+
+  const parsed = parseGitUrl(url);
+  return chartRepo.test(parsed.name);
+}
+
+const githubRelease = regEx(
+  /^(https:\/\/github\.com\/[^/]+\/[^/]+)\/releases\//,
+);
+
+function getSourceUrl(release: HelmRelease): string | undefined {
+  // it's a github release :)
+  const [githubUrl] = release.urls;
+  const releaseMatch = githubRelease.exec(githubUrl);
+  if (releaseMatch) {
+    return releaseMatch[1];
+  }
+
+  if (release.home && isPossibleChartRepo(release.home)) {
+    return release.home;
+  }
+
+  for (const url of release.sources) {
+    if (isPossibleChartRepo(url)) {
+      return url;
+    }
+  }
+
+  // fallback
+  return release.sources[0];
+}
+
+export const HelmRepositorySchema = z
+  .object({
+    entries: LooseRecord(
+      z.string(),
+      HelmReleaseSchema.array()
+        .min(1)
+        .transform((helmReleases) => {
+          const latestRelease = helmReleases[0];
+          const homepage = latestRelease.home;
+          const sourceUrl = getSourceUrl(latestRelease);
+          const releases = helmReleases.map(
+            ({
+              version,
+              created: releaseTimestamp,
+              digest: newDigest,
+            }): Release => ({
+              version,
+              releaseTimestamp,
+              newDigest,
+            }),
+          );
+          return { homepage, sourceUrl, releases };
+        }),
+    ),
+  })
+  .transform(({ entries }) => entries);
+
+export type HelmRepositoryData = z.infer<typeof HelmRepositorySchema>;
diff --git a/lib/modules/datasource/helm/types.ts b/lib/modules/datasource/helm/types.ts
deleted file mode 100644
index 3c89e124e6..0000000000
--- a/lib/modules/datasource/helm/types.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import type { ReleaseResult } from '../types';
-
-export interface HelmRelease {
-  home?: string;
-  sources?: string[];
-  version: string;
-  created: string;
-  digest: string | null;
-  urls: string[];
-}
-
-export interface HelmRepository {
-  entries: Record<string, HelmRelease[]>;
-}
-
-export type HelmRepositoryData = Record<string, ReleaseResult>;
-- 
GitLab