From e47712f3679509550dcd4321f9836c5effb3b5ca Mon Sep 17 00:00:00 2001
From: Hasan Awad <90554456+hasanwhitesource@users.noreply.github.com>
Date: Thu, 7 Apr 2022 14:32:54 +0300
Subject: [PATCH] feat(gitlabci): used yaml parsing (#14879)

Co-authored-by: Rhys Arkins <rhys@arkins.net>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
---
 .../gitlabci/__fixtures__/gitlab-ci.6.yaml    |   8 +
 .../__snapshots__/extract.spec.ts.snap        |  54 +++++++
 lib/modules/manager/gitlabci/extract.spec.ts  |  79 +++++++++-
 lib/modules/manager/gitlabci/extract.ts       | 149 +++++++++---------
 lib/modules/manager/gitlabci/types.ts         |  15 ++
 5 files changed, 226 insertions(+), 79 deletions(-)

diff --git a/lib/modules/manager/gitlabci/__fixtures__/gitlab-ci.6.yaml b/lib/modules/manager/gitlabci/__fixtures__/gitlab-ci.6.yaml
index 2fe3c85a81..6639df8271 100644
--- a/lib/modules/manager/gitlabci/__fixtures__/gitlab-ci.6.yaml
+++ b/lib/modules/manager/gitlabci/__fixtures__/gitlab-ci.6.yaml
@@ -17,3 +17,11 @@ job1:
               - -something2
               - -something3
               - -something4
+job2:
+    services:
+      - "mariadb:10.4.11"
+      - postgres:11.7
+      - redis:latest
+      - name: "registry.example.com/myimage:latest"
+      - myimage@sha256:0ecb2ad60
+      - tomcat:7-jre8
diff --git a/lib/modules/manager/gitlabci/__snapshots__/extract.spec.ts.snap b/lib/modules/manager/gitlabci/__snapshots__/extract.spec.ts.snap
index be3028decd..d522679e36 100644
--- a/lib/modules/manager/gitlabci/__snapshots__/extract.spec.ts.snap
+++ b/lib/modules/manager/gitlabci/__snapshots__/extract.spec.ts.snap
@@ -224,6 +224,60 @@ Array [
         "depType": "service-image",
         "replaceString": "mooseagency/postgresql:12.3-1@sha256:a5a65569456f221ee1f8a0b3b4e2d440eb5830772d9440c9b30b1dbfd454c778",
       },
+      Object {
+        "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}",
+        "currentDigest": undefined,
+        "currentValue": "10.4.11",
+        "datasource": "docker",
+        "depName": "mariadb",
+        "depType": "service-image",
+        "replaceString": "mariadb:10.4.11",
+      },
+      Object {
+        "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}",
+        "currentDigest": undefined,
+        "currentValue": "11.7",
+        "datasource": "docker",
+        "depName": "postgres",
+        "depType": "service-image",
+        "replaceString": "postgres:11.7",
+      },
+      Object {
+        "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}",
+        "currentDigest": undefined,
+        "currentValue": "latest",
+        "datasource": "docker",
+        "depName": "redis",
+        "depType": "service-image",
+        "replaceString": "redis:latest",
+      },
+      Object {
+        "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}",
+        "currentDigest": undefined,
+        "currentValue": "latest",
+        "datasource": "docker",
+        "depName": "registry.example.com/myimage",
+        "depType": "service-image",
+        "replaceString": "registry.example.com/myimage:latest",
+      },
+      Object {
+        "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}",
+        "currentDigest": "sha256:0ecb2ad60",
+        "currentValue": undefined,
+        "datasource": "docker",
+        "depName": "myimage",
+        "depType": "service-image",
+        "replaceString": "myimage@sha256:0ecb2ad60",
+      },
+      Object {
+        "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}",
+        "currentDigest": undefined,
+        "currentValue": "7-jre8",
+        "datasource": "docker",
+        "depName": "tomcat",
+        "depType": "service-image",
+        "replaceString": "tomcat:7-jre8",
+      },
     ],
     "packageFile": "lib/modules/manager/gitlabci/__fixtures__/gitlab-ci.6.yaml",
   },
diff --git a/lib/modules/manager/gitlabci/extract.spec.ts b/lib/modules/manager/gitlabci/extract.spec.ts
index 8fe02f84c1..2eedb77994 100644
--- a/lib/modules/manager/gitlabci/extract.spec.ts
+++ b/lib/modules/manager/gitlabci/extract.spec.ts
@@ -2,7 +2,12 @@ import { logger } from '../../../../test/util';
 import { GlobalConfig } from '../../../config/global';
 import type { RepoGlobalConfig } from '../../../config/types';
 import type { ExtractConfig, PackageDependency } from '../types';
-import { extractAllPackageFiles } from './extract';
+import {
+  extractAllPackageFiles,
+  extractFromImage,
+  extractFromJob,
+  extractFromServices,
+} from './extract';
 
 const config: ExtractConfig = {};
 
@@ -57,7 +62,7 @@ describe('modules/manager/gitlabci/extract', () => {
       ]);
       expect(res).toMatchSnapshot();
       expect(res).toHaveLength(1);
-      expect(res[0].deps).toHaveLength(4);
+      expect(res[0].deps).toHaveLength(10);
     });
 
     it('extracts multiple image lines', async () => {
@@ -155,5 +160,75 @@ describe('modules/manager/gitlabci/extract', () => {
         },
       ]);
     });
+    it('extracts from image', () => {
+      let expectedRes = {
+        autoReplaceStringTemplate:
+          '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+        currentDigest: undefined,
+        currentValue: 'test',
+        datasource: 'docker',
+        depName: 'image',
+        depType: 'image',
+        replaceString: 'image:test',
+      };
+
+      expect(extractFromImage('image:test')).toEqual(expectedRes);
+
+      expectedRes = { ...expectedRes, depType: 'image-name' };
+      expect(
+        extractFromImage({
+          name: 'image:test',
+        })
+      ).toEqual(expectedRes);
+
+      expect(extractFromImage(undefined)).toBeNull();
+    });
+
+    it('extracts from services', () => {
+      const expectedRes = [
+        {
+          autoReplaceStringTemplate:
+            '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+          currentDigest: undefined,
+          currentValue: 'test',
+          datasource: 'docker',
+          depName: 'image',
+          depType: 'service-image',
+          replaceString: 'image:test',
+        },
+        {
+          autoReplaceStringTemplate:
+            '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+          currentDigest: undefined,
+          currentValue: 'test2',
+          datasource: 'docker',
+          depName: 'image2',
+          depType: 'service-image',
+          replaceString: 'image2:test2',
+        },
+      ];
+      const services = ['image:test', 'image2:test2'];
+      expect(extractFromServices(undefined)).toBeEmptyArray();
+      expect(extractFromServices(services)).toEqual(expectedRes);
+      expect(
+        extractFromServices([{ name: 'image:test' }, { name: 'image2:test2' }])
+      ).toEqual(expectedRes);
+    });
+    it('extracts from job object', () => {
+      const expectedRes = [
+        {
+          autoReplaceStringTemplate:
+            '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+          currentDigest: undefined,
+          currentValue: 'test',
+          datasource: 'docker',
+          depName: 'image',
+          depType: 'image',
+          replaceString: 'image:test',
+        },
+      ];
+      expect(extractFromJob(undefined)).toBeEmptyArray();
+      expect(extractFromJob({ image: 'image:test' })).toEqual(expectedRes);
+    });
   });
 });
diff --git a/lib/modules/manager/gitlabci/extract.ts b/lib/modules/manager/gitlabci/extract.ts
index 0319139103..f7b9b79127 100644
--- a/lib/modules/manager/gitlabci/extract.ts
+++ b/lib/modules/manager/gitlabci/extract.ts
@@ -2,95 +2,90 @@ import is from '@sindresorhus/is';
 import { load } from 'js-yaml';
 import { logger } from '../../../logger';
 import { readLocalFile } from '../../../util/fs';
-import { newlineRegex, regEx } from '../../../util/regex';
+import { regEx } from '../../../util/regex';
 import { getDep } from '../dockerfile/extract';
 import type { ExtractConfig, PackageDependency, PackageFile } from '../types';
-import type { GitlabPipeline } from './types';
+import type { GitlabPipeline, Image, Job, Services } from './types';
 import { replaceReferenceTags } from './utils';
 
-const commentsRe = regEx(/^\s*#/);
-const aliasesRe = regEx(`^\\s*-?\\s*alias:`);
-const whitespaceRe = regEx(`^(?<whitespace>\\s*)`);
-const imageRe = regEx(
-  `^(?<whitespace>\\s*)image:(?:\\s+['"]?(?<image>[^\\s'"]+)['"]?)?\\s*$`
-);
-const nameRe = regEx(`^\\s*name:\\s+['"]?(?<depName>[^\\s'"]+)['"]?\\s*$`);
-const serviceRe = regEx(
-  `^\\s*-?\\s*(?:name:\\s+)?['"]?(?<depName>[^\\s'"]+[^:]$)['"]?\\s*$`
-);
-function skipCommentAndAliasLines(
-  lines: string[],
-  lineNumber: number
-): { lineNumber: number; line: string } {
-  let ln = lineNumber;
-  while (
-    ln < lines.length - 1 &&
-    (commentsRe.test(lines[ln]) || aliasesRe.test(lines[ln]))
-  ) {
-    ln += 1;
+export function extractFromImage(
+  image: Image | undefined
+): PackageDependency | null {
+  if (is.undefined(image)) {
+    return null;
+  }
+  let dep: PackageDependency = null;
+  if (is.string(image)) {
+    dep = getDep(image);
+    dep.depType = 'image';
+  } else if (is.string(image?.name)) {
+    dep = getDep(image.name);
+    dep.depType = 'image-name';
   }
-  return { line: lines[ln], lineNumber: ln };
+  return dep;
 }
 
-export function extractPackageFile(content: string): PackageFile | null {
+export function extractFromServices(
+  services: Services | undefined
+): PackageDependency[] {
+  if (is.undefined(services)) {
+    return [];
+  }
+  const deps: PackageDependency[] = [];
+  for (const service of services) {
+    if (is.string(service)) {
+      const dep = getDep(service);
+      dep.depType = 'service-image';
+      deps.push(dep);
+    } else if (is.string(service?.name)) {
+      const dep = getDep(service.name);
+      dep.depType = 'service-image';
+      deps.push(dep);
+    }
+  }
+  return deps;
+}
+
+export function extractFromJob(job: Job | undefined): PackageDependency[] {
+  if (is.undefined(job)) {
+    return [];
+  }
   const deps: PackageDependency[] = [];
+  if (is.object(job)) {
+    const { image, services } = { ...job };
+    if (is.object(image) || is.string(image)) {
+      deps.push(extractFromImage(image));
+    }
+    if (is.array(services)) {
+      deps.push(...extractFromServices(services));
+    }
+  }
+  return deps;
+}
+
+export function extractPackageFile(content: string): PackageFile | null {
+  let deps: PackageDependency[] = [];
   try {
-    const lines = content.split(newlineRegex);
-    for (let lineNumber = 0; lineNumber < lines.length; lineNumber += 1) {
-      const line = lines[lineNumber];
-      const imageMatch = imageRe.exec(line);
-      if (imageMatch) {
-        switch (imageMatch.groups.image) {
-          case undefined:
-          case '': {
-            let blockLine;
-            do {
-              lineNumber += 1;
-              blockLine = lines[lineNumber];
-              const imageNameMatch = nameRe.exec(blockLine);
-              if (imageNameMatch) {
-                logger.trace(`Matched image name on line ${lineNumber}`);
-                const dep = getDep(imageNameMatch.groups.depName);
-                dep.depType = 'image-name';
-                deps.push(dep);
-                break;
-              }
-            } while (
-              whitespaceRe.exec(blockLine)?.groups.whitespace.length >
-              imageMatch.groups.whitespace.length
-            );
+    const doc = load(replaceReferenceTags(content), {
+      json: true,
+    }) as Record<string, Image | Services | Job>;
+    if (is.object(doc)) {
+      for (const [property, value] of Object.entries(doc)) {
+        switch (property) {
+          case 'image':
+            deps.push(extractFromImage(value as Image));
+            break;
+
+          case 'services':
+            deps.push(...extractFromServices(value as Services));
+            break;
+
+          default:
+            deps.push(...extractFromJob(value as Job));
             break;
-          }
-          default: {
-            logger.trace(`Matched image on line ${lineNumber}`);
-            const dep = getDep(imageMatch.groups.image);
-            dep.depType = 'image';
-            deps.push(dep);
-          }
         }
       }
-      const services = regEx(/^\s*services:\s*$/).test(line);
-      if (services) {
-        logger.trace(`Matched services on line ${lineNumber}`);
-        let foundImage: boolean;
-        do {
-          foundImage = false;
-          const serviceImageLine = skipCommentAndAliasLines(
-            lines,
-            lineNumber + 1
-          );
-          logger.trace(`serviceImageLine: "${serviceImageLine.line}"`);
-          const serviceImageMatch = serviceRe.exec(serviceImageLine.line);
-          if (serviceImageMatch) {
-            logger.trace('serviceImageMatch');
-            foundImage = true;
-            lineNumber = serviceImageLine.lineNumber;
-            const dep = getDep(serviceImageMatch.groups.depName);
-            dep.depType = 'service-image';
-            deps.push(dep);
-          }
-        } while (foundImage);
-      }
+      deps = deps.filter(is.truthy);
     }
   } catch (err) /* istanbul ignore next */ {
     logger.warn({ err }, 'Error extracting GitLab CI dependencies');
diff --git a/lib/modules/manager/gitlabci/types.ts b/lib/modules/manager/gitlabci/types.ts
index ace7be0584..18aabed7d9 100644
--- a/lib/modules/manager/gitlabci/types.ts
+++ b/lib/modules/manager/gitlabci/types.ts
@@ -5,3 +5,18 @@ export interface GitlabInclude {
 export interface GitlabPipeline {
   include?: GitlabInclude[] | string;
 }
+
+export interface ImageObject {
+  name: string;
+  entrypoint?: string[];
+}
+export interface ServicesObject extends ImageObject {
+  command?: string[];
+  alias?: string;
+}
+export interface Job {
+  image?: Image;
+  services?: Services;
+}
+export type Image = ImageObject | string;
+export type Services = (string | ServicesObject)[];
-- 
GitLab