From 208a316c39f645bf8bbb6037878c61aaee4c1239 Mon Sep 17 00:00:00 2001
From: Marcel <34819524+MarcelCoding@users.noreply.github.com>
Date: Wed, 7 Sep 2022 07:10:27 +0200
Subject: [PATCH] feat: woodpecker manager (#17297)

Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
Co-authored-by: Rhys Arkins <rhys@arkins.net>
---
 lib/config/options/index.ts                   |   1 +
 lib/modules/manager/api.ts                    |   2 +
 .../woodpecker/__fixtures__/.woodpecker.yml   |  24 +++
 .../manager/woodpecker/extract.spec.ts        | 188 ++++++++++++++++++
 lib/modules/manager/woodpecker/extract.ts     |  48 +++++
 lib/modules/manager/woodpecker/index.ts       |  13 ++
 lib/modules/manager/woodpecker/readme.md      |   7 +
 lib/modules/manager/woodpecker/types.ts       |   7 +
 8 files changed, 290 insertions(+)
 create mode 100644 lib/modules/manager/woodpecker/__fixtures__/.woodpecker.yml
 create mode 100644 lib/modules/manager/woodpecker/extract.spec.ts
 create mode 100644 lib/modules/manager/woodpecker/extract.ts
 create mode 100644 lib/modules/manager/woodpecker/index.ts
 create mode 100644 lib/modules/manager/woodpecker/readme.md
 create mode 100644 lib/modules/manager/woodpecker/types.ts

diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts
index a486d67625..2dac6716d2 100644
--- a/lib/config/options/index.ts
+++ b/lib/config/options/index.ts
@@ -849,6 +849,7 @@ const options: RenovateOptions[] = [
       'kubernetes',
       'ansible',
       'droneci',
+      'woodpecker',
     ],
   },
   {
diff --git a/lib/modules/manager/api.ts b/lib/modules/manager/api.ts
index 4a3fe2bf5c..84b74fe6a2 100644
--- a/lib/modules/manager/api.ts
+++ b/lib/modules/manager/api.ts
@@ -74,6 +74,7 @@ import * as terragruntVersion from './terragrunt-version';
 import * as travis from './travis';
 import type { ManagerApi } from './types';
 import * as velaci from './velaci';
+import * as woodpecker from './woodpecker';
 
 const api = new Map<string, ManagerApi>();
 export default api;
@@ -153,3 +154,4 @@ api.set('terragrunt', terragrunt);
 api.set('terragrunt-version', terragruntVersion);
 api.set('travis', travis);
 api.set('velaci', velaci);
+api.set('woodpecker', woodpecker);
diff --git a/lib/modules/manager/woodpecker/__fixtures__/.woodpecker.yml b/lib/modules/manager/woodpecker/__fixtures__/.woodpecker.yml
new file mode 100644
index 0000000000..aaa5c46759
--- /dev/null
+++ b/lib/modules/manager/woodpecker/__fixtures__/.woodpecker.yml
@@ -0,0 +1,24 @@
+pipeline:
+  redis:
+    image: quay.io/something/redis:alpine
+
+  worker:
+    image: "node:10.0.0"
+
+  db:
+    image: "postgres:9.4.0"
+
+  vote:
+    image: dockersamples/examplevotingapp_vote:before
+
+  result:
+    image: 'dockersamples/examplevotingapp_result:before'
+
+  votingworker:
+    image: dockersamples/examplevotingapp_worker
+
+  visualizer:
+    image: dockersamples/visualizer:stable
+
+  debugapp:
+    image: app-local-debug
diff --git a/lib/modules/manager/woodpecker/extract.spec.ts b/lib/modules/manager/woodpecker/extract.spec.ts
new file mode 100644
index 0000000000..7a6faf476b
--- /dev/null
+++ b/lib/modules/manager/woodpecker/extract.spec.ts
@@ -0,0 +1,188 @@
+import { Fixtures } from '../../../../test/fixtures';
+import { extractPackageFile } from '.';
+
+const yamlFile = Fixtures.get('.woodpecker.yml');
+
+describe('modules/manager/woodpecker/extract', () => {
+  describe('extractPackageFile()', () => {
+    it('returns null for empty', () => {
+      expect(extractPackageFile('', '', {})).toBeNull();
+    });
+
+    it('returns null for non-object YAML', () => {
+      expect(extractPackageFile('nothing here', '', {})).toBeNull();
+    });
+
+    it('returns null for malformed YAML', () => {
+      expect(extractPackageFile('nothing here\n:::::::', '', {})).toBeNull();
+    });
+
+    it('extracts multiple image lines', () => {
+      const res = extractPackageFile(yamlFile, '', {});
+      expect(res).toEqual({
+        deps: [
+          {
+            depName: 'quay.io/something/redis',
+            currentValue: 'alpine',
+            currentDigest: undefined,
+            replaceString: 'quay.io/something/redis:alpine',
+            autoReplaceStringTemplate:
+              '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+            datasource: 'docker',
+          },
+          {
+            depName: 'node',
+            currentValue: '10.0.0',
+            currentDigest: undefined,
+            replaceString: 'node:10.0.0',
+            autoReplaceStringTemplate:
+              '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+            datasource: 'docker',
+          },
+          {
+            depName: 'postgres',
+            currentValue: '9.4.0',
+            currentDigest: undefined,
+            replaceString: 'postgres:9.4.0',
+            autoReplaceStringTemplate:
+              '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+            datasource: 'docker',
+          },
+          {
+            depName: 'dockersamples/examplevotingapp_vote',
+            currentValue: 'before',
+            currentDigest: undefined,
+            replaceString: 'dockersamples/examplevotingapp_vote:before',
+            autoReplaceStringTemplate:
+              '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+            datasource: 'docker',
+          },
+          {
+            depName: 'dockersamples/examplevotingapp_result',
+            currentValue: 'before',
+            currentDigest: undefined,
+            replaceString: 'dockersamples/examplevotingapp_result:before',
+            autoReplaceStringTemplate:
+              '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+            datasource: 'docker',
+          },
+          {
+            depName: 'dockersamples/examplevotingapp_worker',
+            currentValue: undefined,
+            currentDigest: undefined,
+            replaceString: 'dockersamples/examplevotingapp_worker',
+            autoReplaceStringTemplate:
+              '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+            datasource: 'docker',
+          },
+          {
+            depName: 'dockersamples/visualizer',
+            currentValue: 'stable',
+            currentDigest: undefined,
+            replaceString: 'dockersamples/visualizer:stable',
+            autoReplaceStringTemplate:
+              '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+            datasource: 'docker',
+          },
+          {
+            depName: 'app-local-debug',
+            currentValue: undefined,
+            currentDigest: undefined,
+            replaceString: 'app-local-debug',
+            autoReplaceStringTemplate:
+              '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+            datasource: 'docker',
+          },
+        ],
+      });
+    });
+
+    it('extracts image and replaces registry', () => {
+      const res = extractPackageFile(
+        `
+    pipeline:
+      nginx:
+        image: quay.io/nginx:0.0.1
+      `,
+        '',
+        {
+          registryAliases: {
+            'quay.io': 'my-quay-mirror.registry.com',
+          },
+        }
+      );
+      expect(res).toEqual({
+        deps: [
+          {
+            autoReplaceStringTemplate:
+              'quay.io/nginx:{{#if newValue}}{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+            currentDigest: undefined,
+            currentValue: '0.0.1',
+            datasource: 'docker',
+            depName: 'my-quay-mirror.registry.com/nginx',
+            replaceString: 'quay.io/nginx:0.0.1',
+          },
+        ],
+      });
+    });
+
+    it('extracts image but no replacement', () => {
+      const res = extractPackageFile(
+        `
+        pipeline:
+          nginx:
+            image: quay.io/nginx:0.0.1
+        `,
+        '',
+        {
+          registryAliases: {
+            'index.docker.io': 'my-docker-mirror.registry.com',
+          },
+        }
+      );
+      expect(res).toEqual({
+        deps: [
+          {
+            autoReplaceStringTemplate:
+              '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+            currentDigest: undefined,
+            currentValue: '0.0.1',
+            datasource: 'docker',
+            depName: 'quay.io/nginx',
+            replaceString: 'quay.io/nginx:0.0.1',
+          },
+        ],
+      });
+    });
+
+    it('extracts image and no double replacement', () => {
+      const res = extractPackageFile(
+        `
+        pipeline:
+          nginx:
+            image: quay.io/nginx:0.0.1
+        `,
+        '',
+        {
+          registryAliases: {
+            'quay.io': 'my-quay-mirror.registry.com',
+            'my-quay-mirror.registry.com': 'quay.io',
+          },
+        }
+      );
+      expect(res).toEqual({
+        deps: [
+          {
+            autoReplaceStringTemplate:
+              'quay.io/nginx:{{#if newValue}}{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+            currentDigest: undefined,
+            currentValue: '0.0.1',
+            datasource: 'docker',
+            depName: 'my-quay-mirror.registry.com/nginx',
+            replaceString: 'quay.io/nginx:0.0.1',
+          },
+        ],
+      });
+    });
+  });
+});
diff --git a/lib/modules/manager/woodpecker/extract.ts b/lib/modules/manager/woodpecker/extract.ts
new file mode 100644
index 0000000000..f41691ff73
--- /dev/null
+++ b/lib/modules/manager/woodpecker/extract.ts
@@ -0,0 +1,48 @@
+import is from '@sindresorhus/is';
+import { load } from 'js-yaml';
+import { logger } from '../../../logger';
+import { getDep } from '../dockerfile/extract';
+import type { ExtractConfig, PackageFile } from '../types';
+import type { WoodpeckerConfig } from './types';
+
+export function extractPackageFile(
+  content: string,
+  fileName: string,
+  extractConfig: ExtractConfig
+): PackageFile | null {
+  logger.debug('woodpecker.extractPackageFile()');
+  let config: WoodpeckerConfig;
+  try {
+    // TODO: fix me (#9610)
+    config = load(content, { json: true }) as WoodpeckerConfig;
+    if (!config) {
+      logger.debug(
+        { fileName },
+        'Null config when parsing Woodpecker Configuration content'
+      );
+      return null;
+    }
+    if (typeof config !== 'object') {
+      logger.debug(
+        { fileName, type: typeof config },
+        'Unexpected type for Woodpecker Configuration content'
+      );
+      return null;
+    }
+  } catch (err) {
+    logger.debug(
+      { fileName, err },
+      'Error parsing Woodpecker Configuration config YAML'
+    );
+    return null;
+  }
+
+  // Image name/tags for services are only eligible for update if they don't
+  // use variables and if the image is not built locally
+  const deps = Object.values(config.pipeline ?? {})
+    .filter((step) => is.string(step?.image))
+    .map((step) => getDep(step.image, true, extractConfig.registryAliases));
+
+  logger.trace({ deps }, 'Woodpecker Configuration image');
+  return deps.length ? { deps } : null;
+}
diff --git a/lib/modules/manager/woodpecker/index.ts b/lib/modules/manager/woodpecker/index.ts
new file mode 100644
index 0000000000..12b959e5de
--- /dev/null
+++ b/lib/modules/manager/woodpecker/index.ts
@@ -0,0 +1,13 @@
+import { ProgrammingLanguage } from '../../../constants';
+import { DockerDatasource } from '../../datasource/docker';
+import { extractPackageFile } from './extract';
+
+export const language = ProgrammingLanguage.Docker;
+
+export { extractPackageFile };
+
+export const defaultConfig = {
+  fileMatch: ['(^|\\/)\\.woodpecker[^/]*\\.ya?ml$'],
+};
+
+export const supportedDatasources = [DockerDatasource.id];
diff --git a/lib/modules/manager/woodpecker/readme.md b/lib/modules/manager/woodpecker/readme.md
new file mode 100644
index 0000000000..b7d0aa6e82
--- /dev/null
+++ b/lib/modules/manager/woodpecker/readme.md
@@ -0,0 +1,7 @@
+Extracts all Docker images from Woodpecker Pipeline YAML files.
+
+- [Woodpecker homepage](https://woodpecker-ci.org/)
+- [Woodpecker Docs: Pipeline Syntax](https://woodpecker-ci.org/docs/usage/pipeline-syntax) ([section with dependencies](https://woodpecker-ci.org/docs/usage/pipeline-syntax#image))
+- [`woodpecker-ci` JSON schema](https://raw.githubusercontent.com/woodpecker-ci/woodpecker/master/pipeline/schema/schema.json)
+
+If you need to change the versioning format, read the [versioning](https://docs.renovatebot.com/modules/versioning/) documentation to learn more.
diff --git a/lib/modules/manager/woodpecker/types.ts b/lib/modules/manager/woodpecker/types.ts
new file mode 100644
index 0000000000..aced9644f6
--- /dev/null
+++ b/lib/modules/manager/woodpecker/types.ts
@@ -0,0 +1,7 @@
+export type WoodpeckerConfig = {
+  pipeline?: Record<string, WoodpeckerStep>;
+};
+
+export interface WoodpeckerStep {
+  image?: string;
+}
-- 
GitLab