diff --git a/lib/modules/manager/argocd/extract.ts b/lib/modules/manager/argocd/extract.ts index 26fdc84cec4eb049f04927e858c5a697535d3339..49d3daa6c9134fa48ec2f3b24df70127a4b4f6f8 100644 --- a/lib/modules/manager/argocd/extract.ts +++ b/lib/modules/manager/argocd/extract.ts @@ -11,11 +11,11 @@ import type { PackageDependency, PackageFileContent, } from '../types'; -import type { +import { ApplicationDefinition, - ApplicationSource, - ApplicationSpec, -} from './types'; + type ApplicationSource, + type ApplicationSpec, +} from './schema'; import { fileTestRegex } from './util'; export function extractPackageFile( @@ -33,27 +33,21 @@ export function extractPackageFile( let definitions: ApplicationDefinition[]; try { - // TODO: use schema (#9610) - definitions = parseYaml(content); + definitions = parseYaml(content, null, { + customSchema: ApplicationDefinition, + failureBehaviour: 'filter', + }); } catch (err) { logger.debug({ err, packageFile }, 'Failed to parse ArgoCD definition.'); return null; } - const deps = definitions.filter(is.plainObject).flatMap(processAppSpec); + const deps = definitions.flatMap(processAppSpec); return deps.length ? { deps } : null; } function processSource(source: ApplicationSource): PackageDependency | null { - if ( - !source || - !is.nonEmptyString(source.repoURL) || - !is.nonEmptyString(source.targetRevision) - ) { - return null; - } - // a chart variable is defined this is helm declaration if (source.chart) { // assume OCI helm chart if repoURL doesn't contain explicit protocol @@ -89,14 +83,10 @@ function processSource(source: ApplicationSource): PackageDependency | null { function processAppSpec( definition: ApplicationDefinition, ): PackageDependency[] { - const spec: ApplicationSpec | null | undefined = + const spec: ApplicationSpec = definition.kind === 'Application' - ? definition?.spec - : definition?.spec?.template?.spec; - - if (is.nullOrUndefined(spec)) { - return []; - } + ? definition.spec + : definition.spec.template.spec; const deps: (PackageDependency | null)[] = []; diff --git a/lib/modules/manager/argocd/schema.ts b/lib/modules/manager/argocd/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5de403e97b1d6fc5bc7c09526acd4558bc8d2c3 --- /dev/null +++ b/lib/modules/manager/argocd/schema.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +export const KubernetesResource = z.object({ + apiVersion: z.string(), +}); + +export const ApplicationSource = z.object({ + chart: z.string().optional(), + repoURL: z.string(), + targetRevision: z.string(), +}); +export type ApplicationSource = z.infer<typeof ApplicationSource>; + +export const ApplicationSpec = z.object({ + source: ApplicationSource.optional(), + sources: z.array(ApplicationSource).optional(), +}); +export type ApplicationSpec = z.infer<typeof ApplicationSpec>; + +export const Application = KubernetesResource.extend({ + kind: z.literal('Application'), + spec: ApplicationSpec, +}); + +export const ApplicationSet = KubernetesResource.extend({ + kind: z.literal('ApplicationSet'), + spec: z.object({ + template: z.object({ + spec: ApplicationSpec, + }), + }), +}); + +export const ApplicationDefinition = Application.or(ApplicationSet); +export type ApplicationDefinition = z.infer<typeof ApplicationDefinition>; diff --git a/lib/modules/manager/argocd/types.ts b/lib/modules/manager/argocd/types.ts deleted file mode 100644 index f8b473e6d195afaf88e7676f3e1af16e2473edad..0000000000000000000000000000000000000000 --- a/lib/modules/manager/argocd/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -export interface KubernetesResource { - apiVersion: string; -} - -export interface ApplicationSource { - chart?: string; - repoURL: string; - targetRevision: string; -} - -export interface ApplicationSpec { - source?: ApplicationSource; - sources?: ApplicationSource[]; -} - -export interface Application extends KubernetesResource { - kind: 'Application'; - spec: ApplicationSpec; -} - -export interface ApplicationSet extends KubernetesResource { - kind: 'ApplicationSet'; - spec: { - template: { - spec: ApplicationSpec; - }; - }; -} - -export type ApplicationDefinition = Application | ApplicationSet; diff --git a/lib/modules/manager/crossplane/extract.ts b/lib/modules/manager/crossplane/extract.ts index 993dbdadf961d01ee2020d04674e40ba5d7ec5fe..c247351ee5d7c486dc37d8c11c9c223a544e6428 100644 --- a/lib/modules/manager/crossplane/extract.ts +++ b/lib/modules/manager/crossplane/extract.ts @@ -6,7 +6,7 @@ import type { PackageDependency, PackageFileContent, } from '../types'; -import { XPKGSchema } from './schema'; +import { type XPKG, XPKGSchema } from './schema'; export function extractPackageFile( content: string, @@ -19,9 +19,12 @@ export function extractPackageFile( return null; } - let list = []; + let list: XPKG[] = []; try { - list = parseYaml(content); + list = parseYaml(content, null, { + customSchema: XPKGSchema, + failureBehaviour: 'filter', + }); } catch (err) { logger.debug( { err, packageFile }, @@ -31,16 +34,7 @@ export function extractPackageFile( } const deps: PackageDependency[] = []; - for (const item of list) { - const parsed = XPKGSchema.safeParse(item); - if (!parsed.success) { - logger.trace( - { item, errors: parsed.error }, - 'Invalid Crossplane package', - ); - continue; - } - const xpkg = parsed.data; + for (const xpkg of list) { const dep = getDep(xpkg.spec.package, true, extractConfig?.registryAliases); dep.depType = xpkg.kind.toLowerCase(); deps.push(dep); diff --git a/lib/modules/manager/docker-compose/extract.ts b/lib/modules/manager/docker-compose/extract.ts index 6b254ae20f6c224f05933550d7b9720cceb138a5..255d47ae7d35a914b4224c94690de3ae4757c1ee 100644 --- a/lib/modules/manager/docker-compose/extract.ts +++ b/lib/modules/manager/docker-compose/extract.ts @@ -4,7 +4,7 @@ import { newlineRegex, regEx } from '../../../util/regex'; import { parseSingleYaml } from '../../../util/yaml'; import { getDep } from '../dockerfile/extract'; import type { ExtractConfig, PackageFileContent } from '../types'; -import type { DockerComposeConfig } from './types'; +import { DockerComposeFile } from './schema'; class LineMapper { private imageLines: { line: string; lineNumber: number; used: boolean }[]; @@ -34,24 +34,12 @@ export function extractPackageFile( extractConfig: ExtractConfig, ): PackageFileContent | null { logger.debug(`docker-compose.extractPackageFile(${packageFile})`); - let config: DockerComposeConfig; + let config: DockerComposeFile; try { - // TODO: use schema (#9610) - config = parseSingleYaml(content, { json: true }); - if (!config) { - logger.debug( - { packageFile }, - 'Null config when parsing Docker Compose content', - ); - return null; - } - if (typeof config !== 'object') { - logger.debug( - { packageFile, type: typeof config }, - 'Unexpected type for Docker Compose content', - ); - return null; - } + config = parseSingleYaml(content, { + json: true, + customSchema: DockerComposeFile, + }); } catch (err) { logger.debug( { err, packageFile }, diff --git a/lib/modules/manager/docker-compose/schema.ts b/lib/modules/manager/docker-compose/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f521e93389e0ad5270888900f03676a3cc68350 --- /dev/null +++ b/lib/modules/manager/docker-compose/schema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +const DockerComposeService = z.object({ + image: z.string().optional(), + build: z + .object({ + context: z.string().optional(), + dockerfile: z.string().optional(), + }) + .optional(), +}); + +const DockerComposeFileV1 = z.record(DockerComposeService); +const DockerComposeFileModern = z.object({ + // compose does not use this strictly, so we shouldn't be either + // https://docs.docker.com/compose/compose-file/04-version-and-name/#version-top-level-element + version: z.string().optional(), + services: z.record(DockerComposeService), +}); + +export const DockerComposeFile = + DockerComposeFileModern.or(DockerComposeFileV1); +export type DockerComposeFile = z.infer<typeof DockerComposeFile>; diff --git a/lib/modules/manager/docker-compose/types.ts b/lib/modules/manager/docker-compose/types.ts deleted file mode 100644 index 25495d806264d48412a30884fff8c2a04a48a83e..0000000000000000000000000000000000000000 --- a/lib/modules/manager/docker-compose/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type DockerComposeConfig = { - version?: string; - services?: Record<string, DockerComposeService>; -} & Record<string, DockerComposeService>; - -export interface DockerComposeService { - image?: string; - build?: { - context?: string; - dockerfile?: string; - }; -} diff --git a/lib/modules/manager/fleet/extract.ts b/lib/modules/manager/fleet/extract.ts index 6ea8a243476158957286d2d0852775e3e3a92bc0..32c4c705c336bbaf9387997eb0c29c91856a6591 100644 --- a/lib/modules/manager/fleet/extract.ts +++ b/lib/modules/manager/fleet/extract.ts @@ -6,7 +6,7 @@ import { GitTagsDatasource } from '../../datasource/git-tags'; import { HelmDatasource } from '../../datasource/helm'; import { checkIfStringIsPath } from '../terraform/util'; import type { PackageDependency, PackageFileContent } from '../types'; -import type { FleetFile, FleetHelmBlock, GitRepo } from './types'; +import { FleetFile, type FleetHelmBlock, GitRepo } from './schema'; function extractGitRepo(doc: GitRepo): PackageDependency { const dep: PackageDependency = { @@ -119,23 +119,21 @@ export function extractPackageFile( try { if (regEx('fleet.ya?ml').test(packageFile)) { - // TODO: use schema (#9610) - const docs = parseYaml<FleetFile>(content, null, { + const docs = parseYaml(content, null, { json: true, + customSchema: FleetFile, + failureBehaviour: 'filter', }); - const fleetDeps = docs - .filter((doc) => is.truthy(doc?.helm)) - .flatMap((doc) => extractFleetFile(doc)); + const fleetDeps = docs.flatMap(extractFleetFile); deps.push(...fleetDeps); } else { - // TODO: use schema (#9610) - const docs = parseYaml<GitRepo>(content, null, { + const docs = parseYaml(content, null, { json: true, + customSchema: GitRepo, + failureBehaviour: 'filter', }); - const gitRepoDeps = docs - .filter((doc) => doc.kind === 'GitRepo') // ensure only GitRepo manifests are processed - .flatMap((doc) => extractGitRepo(doc)); + const gitRepoDeps = docs.flatMap(extractGitRepo); deps.push(...gitRepoDeps); } } catch (err) { diff --git a/lib/modules/manager/fleet/schema.ts b/lib/modules/manager/fleet/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..08d3d4342a450be31fcad04df4343309bf2012c1 --- /dev/null +++ b/lib/modules/manager/fleet/schema.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; + +const FleetHelmBlock = z.object({ + chart: z.string().optional(), + repo: z.string().optional(), + version: z.string().optional(), +}); +export type FleetHelmBlock = z.infer<typeof FleetHelmBlock>; + +const FleetFileHelm = FleetHelmBlock.extend({ + releaseName: z.string(), +}); + +/** + Represent a GitRepo Kubernetes manifest of Fleet. + @link https://fleet.rancher.io/gitrepo-add/#create-gitrepo-instance + */ +export const GitRepo = z.object({ + metadata: z.object({ + name: z.string(), + }), + kind: z.string(), + spec: z.object({ + repo: z.string().optional(), + revision: z.string().optional(), + }), +}); +export type GitRepo = z.infer<typeof GitRepo>; + +/** + Represent a Bundle configuration of Fleet, which is located in `fleet.yaml` files. + @link https://fleet.rancher.io/gitrepo-structure/#fleetyaml + */ +export const FleetFile = z.object({ + helm: FleetFileHelm, + targetCustomizations: z + .array( + z.object({ + name: z.string(), + helm: FleetHelmBlock.partial().optional(), + }), + ) + .optional(), +}); +export type FleetFile = z.infer<typeof FleetFile>; diff --git a/lib/modules/manager/fleet/types.ts b/lib/modules/manager/fleet/types.ts deleted file mode 100644 index c3e5b58743a8ca6a7d2be6bae43ecfea1adc0d65..0000000000000000000000000000000000000000 --- a/lib/modules/manager/fleet/types.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - Represent a GitRepo Kubernetes manifest of Fleet. - @link https://fleet.rancher.io/gitrepo-add/#create-gitrepo-instance - */ -export interface GitRepo { - metadata: { - name: string; - }; - kind: string; - spec: { - repo: string; - revision?: string; - }; -} - -/** - Represent a Bundle configuration of Fleet, which is located in `fleet.yaml` files. - @link https://fleet.rancher.io/gitrepo-structure/#fleetyaml - */ -export interface FleetFile { - helm: FleetFileHelm; - targetCustomizations?: { - name: string; - helm: FleetHelmBlock; - }[]; -} - -export interface FleetHelmBlock { - chart: string; - repo?: string; - version?: string; -} - -export interface FleetFileHelm extends FleetHelmBlock { - releaseName: string; -} diff --git a/lib/modules/manager/flux/extract.spec.ts b/lib/modules/manager/flux/extract.spec.ts index 958ab562466ad7afb1a0c1c5c31473f1a6a4c718..6c4b5c4f1192ddc040c1a3874673a040a6a01b59 100644 --- a/lib/modules/manager/flux/extract.spec.ts +++ b/lib/modules/manager/flux/extract.spec.ts @@ -194,16 +194,7 @@ describe('modules/manager/flux/extract', () => { `, 'test.yaml', ); - expect(result).toEqual({ - deps: [ - { - currentValue: '2.0.2', - datasource: HelmDatasource.id, - depName: 'sealed-secrets', - skipReason: 'unknown-registry', - }, - ], - }); + expect(result).toBeNull(); }); it('does not match HelmRelease resources without a sourceRef', () => { @@ -215,6 +206,7 @@ describe('modules/manager/flux/extract', () => { kind: HelmRelease metadata: name: sealed-secrets + namespace: test spec: chart: spec: @@ -253,16 +245,7 @@ describe('modules/manager/flux/extract', () => { `, 'test.yaml', ); - expect(result).toEqual({ - deps: [ - { - currentValue: '2.0.2', - datasource: HelmDatasource.id, - depName: 'sealed-secrets', - skipReason: 'unknown-registry', - }, - ], - }); + expect(result).toBeNull(); }); it('ignores HelmRepository resources without a namespace', () => { diff --git a/lib/modules/manager/flux/extract.ts b/lib/modules/manager/flux/extract.ts index 2b03f05eac54c8da5b06a28918ac038c900f872e..7e4fd03ec58d0051ee4ce051d855730f92d19824 100644 --- a/lib/modules/manager/flux/extract.ts +++ b/lib/modules/manager/flux/extract.ts @@ -19,11 +19,10 @@ import type { PackageFileContent, } from '../types'; import { isSystemManifest, systemManifestHeaderRegex } from './common'; +import { FluxResource, type HelmRepository } from './schema'; import type { FluxManagerData, FluxManifest, - FluxResource, - HelmRepository, ResourceFluxManifest, SystemFluxManifest, } from './types'; @@ -45,61 +44,21 @@ function readManifest( }; } - const manifest: FluxManifest = { - kind: 'resource', - file: packageFile, - resources: [], - }; - let resources: FluxResource[]; try { - // TODO: use schema (#9610) - resources = parseYaml(content, null, { json: true }); + const manifest: FluxManifest = { + kind: 'resource', + file: packageFile, + resources: parseYaml(content, null, { + json: true, + customSchema: FluxResource, + failureBehaviour: 'filter', + }), + }; + return manifest; } catch (err) { logger.debug({ err, packageFile }, 'Failed to parse Flux manifest'); return null; } - - // It's possible there are other non-Flux HelmRelease/HelmRepository CRs out there, so we filter based on apiVersion. - for (const resource of resources) { - switch (resource?.kind) { - case 'HelmRelease': - if ( - resource.apiVersion?.startsWith('helm.toolkit.fluxcd.io/') && - resource.spec?.chart?.spec?.chart - ) { - manifest.resources.push(resource); - } - break; - case 'HelmRepository': - if ( - resource.apiVersion?.startsWith('source.toolkit.fluxcd.io/') && - resource.metadata?.name && - resource.metadata.namespace && - resource.spec?.url - ) { - manifest.resources.push(resource); - } - break; - case 'GitRepository': - if ( - resource.apiVersion?.startsWith('source.toolkit.fluxcd.io/') && - resource.spec?.url - ) { - manifest.resources.push(resource); - } - break; - case 'OCIRepository': - if ( - resource.apiVersion?.startsWith('source.toolkit.fluxcd.io/') && - resource.spec?.url - ) { - manifest.resources.push(resource); - } - break; - } - } - - return manifest; } const githubUrlRegex = regEx( diff --git a/lib/modules/manager/flux/schema.ts b/lib/modules/manager/flux/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..5030ab16ef5aed49320742cac228c2857ef7cebf --- /dev/null +++ b/lib/modules/manager/flux/schema.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; + +export const KubernetesResource = z.object({ + apiVersion: z.string(), + kind: z.string(), + metadata: z.object({ + name: z.string(), + // For Flux, the namespace property is optional, but matching HelmReleases to HelmRepositories would be + // much more difficult without it (we'd have to examine the parent Kustomizations to discover the value), + // so we require it for renovation. + namespace: z.string().optional(), + }), +}); + +export const HelmRelease = KubernetesResource.extend({ + apiVersion: z.string().startsWith('helm.toolkit.fluxcd.io/'), + kind: z.literal('HelmRelease'), + spec: z.object({ + chart: z.object({ + spec: z.object({ + chart: z.string(), + version: z.string().optional(), + sourceRef: z + .object({ + kind: z.string().optional(), + name: z.string().optional(), + namespace: z.string().optional(), + }) + .optional(), + }), + }), + }), +}); + +export const HelmRepository = KubernetesResource.extend({ + apiVersion: z.string().startsWith('source.toolkit.fluxcd.io/'), + kind: z.literal('HelmRepository'), + spec: z.object({ + url: z.string(), + type: z.enum(['oci', 'default']).optional(), + }), +}); +export type HelmRepository = z.infer<typeof HelmRepository>; + +export const GitRepository = KubernetesResource.extend({ + apiVersion: z.string().startsWith('source.toolkit.fluxcd.io/'), + kind: z.literal('GitRepository'), + spec: z.object({ + url: z.string(), + ref: z + .object({ + tag: z.string().optional(), + commit: z.string().optional(), + }) + .optional(), + }), +}); + +export const OCIRepository = KubernetesResource.extend({ + apiVersion: z.string().startsWith('source.toolkit.fluxcd.io/'), + kind: z.literal('OCIRepository'), + spec: z.object({ + url: z.string(), + ref: z + .object({ + tag: z.string().optional(), + digest: z.string().optional(), + }) + .optional(), + }), +}); + +export const FluxResource = HelmRelease.or(HelmRepository) + .or(GitRepository) + .or(OCIRepository); +export type FluxResource = z.infer<typeof FluxResource>; diff --git a/lib/modules/manager/flux/types.ts b/lib/modules/manager/flux/types.ts index 9895abc28b29e6f81a1bf51695df775254afde8c..20e80e788180dd1edbd1c394c89739f1a489be26 100644 --- a/lib/modules/manager/flux/types.ts +++ b/lib/modules/manager/flux/types.ts @@ -1,73 +1,9 @@ +import type { FluxResource } from './schema'; + export type FluxManagerData = { components: string; }; -export interface KubernetesResource { - apiVersion: string; - metadata: { - name: string; - // For Flux, the namespace property is optional, but matching HelmReleases to HelmRepositories would be - // much more difficult without it (we'd have to examine the parent Kustomizations to discover the value), - // so we require it for renovation. - namespace: string; - }; -} - -export interface HelmRelease extends KubernetesResource { - kind: 'HelmRelease'; - spec: { - chart: { - spec: { - chart: string; - sourceRef: { - kind: string; - name: string; - namespace?: string; - }; - version?: string; - }; - }; - }; -} - -export type HelmRepositoryType = 'oci' | 'default'; - -export interface HelmRepository extends KubernetesResource { - kind: 'HelmRepository'; - spec: { - url: string; - type: HelmRepositoryType; - }; -} - -export interface GitRepository extends KubernetesResource { - kind: 'GitRepository'; - spec: { - ref: { - tag?: string; - commit?: string; - }; - url: string; - }; -} - -export interface OciRepository extends KubernetesResource { - kind: 'OCIRepository'; - spec: { - ref: { - digest?: string; - tag?: string; - }; - url: string; - }; -} - -export type FluxResource = - | HelmRelease - | HelmRepository - | GitRepository - | OciRepository; - export interface FluxFile { file: string; }