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