From 9ef76c5a47d14fb68f2b885b8ec6a508b5b57ae3 Mon Sep 17 00:00:00 2001
From: Robert Munteanu <robert.munteanu@gmail.com>
Date: Mon, 19 Dec 2022 21:00:13 +0100
Subject: [PATCH] feat: add osgi dependency manager (#19282)

---
 lib/modules/manager/api.ts               |   2 +
 lib/modules/manager/osgi/extract.spec.ts | 279 +++++++++++++++++++++++
 lib/modules/manager/osgi/extract.ts      | 135 +++++++++++
 lib/modules/manager/osgi/index.ts        |  12 +
 lib/modules/manager/osgi/readme.md       |  10 +
 lib/modules/manager/osgi/types.ts        |  14 ++
 6 files changed, 452 insertions(+)
 create mode 100644 lib/modules/manager/osgi/extract.spec.ts
 create mode 100644 lib/modules/manager/osgi/extract.ts
 create mode 100644 lib/modules/manager/osgi/index.ts
 create mode 100644 lib/modules/manager/osgi/readme.md
 create mode 100644 lib/modules/manager/osgi/types.ts

diff --git a/lib/modules/manager/api.ts b/lib/modules/manager/api.ts
index 810bc94024..06939c884c 100644
--- a/lib/modules/manager/api.ts
+++ b/lib/modules/manager/api.ts
@@ -55,6 +55,7 @@ import * as nodenv from './nodenv';
 import * as npm from './npm';
 import * as nuget from './nuget';
 import * as nvm from './nvm';
+import * as osgi from './osgi';
 import * as pipCompile from './pip-compile';
 import * as pip_requirements from './pip_requirements';
 import * as pip_setup from './pip_setup';
@@ -140,6 +141,7 @@ api.set('nodenv', nodenv);
 api.set('npm', npm);
 api.set('nuget', nuget);
 api.set('nvm', nvm);
+api.set('osgi', osgi);
 api.set('pip-compile', pipCompile);
 api.set('pip_requirements', pip_requirements);
 api.set('pip_setup', pip_setup);
diff --git a/lib/modules/manager/osgi/extract.spec.ts b/lib/modules/manager/osgi/extract.spec.ts
new file mode 100644
index 0000000000..c77c15151d
--- /dev/null
+++ b/lib/modules/manager/osgi/extract.spec.ts
@@ -0,0 +1,279 @@
+import { extractPackageFile } from './extract';
+
+const noArtifacts = `{
+  "configurations": {
+    "org.apache.sling.jcr.davex.impl.servlets.SlingDavExServlet":{
+      "alias":"/server"
+    }
+  }
+}`;
+const unsupportedFeatureVersion = `{
+  "feature-resource-version": "2.0",
+  "bundles":[
+      {
+          "id":"commons-codec:commons-codec:1.15",
+          "start-order":"5"
+      }
+  ]
+}`;
+const featureWithBundlesAsObjects = `{
+  "feature-resource-version": "1.0",
+  "bundles":[
+      {
+          "id":"commons-codec:commons-codec:1.15",
+          "start-order":"5"
+      },
+      {
+          "id":"commons-collections:commons-collections:3.2.2",
+          "start-order":"15"
+      }
+  ]
+}`;
+const featureWithBundlesAsStrings = `{
+  "bundles": [
+    "org.apache.felix/org.apache.felix.scr/2.1.26",
+    "org.apache.felix/org.apache.felix.log/1.2.4"
+  ]
+}`;
+const featureWithComment = `{
+  // comments are permitted
+  "bundles": [ "org.apache.aries:org.apache.aries.util:1.1.3" ]
+}`;
+const artifactsExtension = `{
+  "content-packages:ARTIFACTS|true": [
+      "com.day.cq:core.wcm.components.all:zip:2.21.0"
+  ]
+}`;
+const doubleSlashNotComment = `{
+  "bundles":[
+       {
+           "id":"com.h2database:h2-mvstore:2.1.214",
+           "start-order":"15"
+      },
+      {
+           "id":"org.mongodb:mongo-java-driver:3.12.11",
+           "start-order":"15"
+       }
+  ],
+  "configurations":{
+      "org.apache.jackrabbit.oak.plugins.document.DocumentNodeStoreService":{
+          "db":"sling",
+          "mongouri":"mongodb://$[env:MONGODB_HOST;default=localhost]:$[env:MONGODB_PORT;type=Integer;default=27017]"
+       }
+  }
+}`;
+const frameworkArtifact = `{
+  "execution-environment:JSON|false":{
+      "framework":{
+          "id":"org.apache.felix:org.apache.felix.framework:7.0.5"
+      }
+  }
+}`;
+const versionWithVariable = `{
+  "bundles":[
+      {
+          "id":"com.fasterxml.jackson.core:jackson-annotations:$\{jackson.version}",
+          "start-order":"20"
+      }
+  ]
+}`;
+const malformedDefinitions = `{
+  "bundles":[
+      {
+          "#": "missing the 'id' attribute",
+          "not-id":"commons-codec:commons-codec:1.15"
+      },
+      {
+          "#": "too few parts in the GAV definition",
+          "id":"commons-codec:1.15"
+      },
+      {
+          "#": "valid definition, should be extracted",
+          "id":"commons-codec:commons-codec:1.15"
+      }
+  ]
+}`;
+const invalidFeatureVersion = `{
+  "feature-resource-version": "unknown",
+  "bundles":[
+      {
+          "id":"commons-codec:commons-codec:1.15",
+          "start-order":"5"
+      }
+  ]
+}`;
+
+describe('modules/manager/osgi/extract', () => {
+  describe('extractPackageFile()', () => {
+    it('returns null for empty file', () => {
+      expect(extractPackageFile('', '', undefined)).toBeNull();
+    });
+
+    it('returns null for invalid file', () => {
+      expect(extractPackageFile('this-is-not-json', '', undefined)).toBeNull();
+    });
+
+    it('returns null for unsupported version of feature model definition', () => {
+      expect(
+        extractPackageFile(unsupportedFeatureVersion, '', undefined)
+      ).toBeNull();
+    });
+
+    it('returns null for an invalid version of feature model definition', () => {
+      expect(
+        extractPackageFile(invalidFeatureVersion, '', undefined)
+      ).toBeNull();
+    });
+
+    it('returns null for a null string passed in as a feature model definition', () => {
+      expect(extractPackageFile('null', '', undefined)).toBeNull();
+    });
+
+    it('returns null for a valid file with no artifact definitions', () => {
+      expect(extractPackageFile(noArtifacts, '', undefined)).toBeNull();
+    });
+
+    it('extracts the bundles from a file with object bundles definitions', () => {
+      const packageFile = extractPackageFile(
+        featureWithBundlesAsObjects,
+        '',
+        undefined
+      );
+      expect(packageFile).toEqual({
+        deps: [
+          {
+            datasource: 'maven',
+            depName: 'commons-codec:commons-codec',
+            currentValue: '1.15',
+          },
+          {
+            datasource: 'maven',
+            depName: 'commons-collections:commons-collections',
+            currentValue: '3.2.2',
+          },
+        ],
+      });
+    });
+
+    it('extracts the bundles from a file with string bundles defintions', () => {
+      const packageFile = extractPackageFile(
+        featureWithBundlesAsStrings,
+        '',
+        undefined
+      );
+      expect(packageFile).toEqual({
+        deps: [
+          {
+            datasource: 'maven',
+            depName: 'org.apache.felix:org.apache.felix.scr',
+            currentValue: '2.1.26',
+          },
+          {
+            datasource: 'maven',
+            depName: 'org.apache.felix:org.apache.felix.log',
+            currentValue: '1.2.4',
+          },
+        ],
+      });
+    });
+
+    it('extracts the bundles from a file with comments', () => {
+      const packageFile = extractPackageFile(featureWithComment, '', undefined);
+      expect(packageFile).toEqual({
+        deps: [
+          {
+            datasource: 'maven',
+            depName: 'org.apache.aries:org.apache.aries.util',
+            currentValue: '1.1.3',
+          },
+        ],
+      });
+    });
+
+    it('extracts the artifacts from an extension section', () => {
+      const packageFile = extractPackageFile(artifactsExtension, '', undefined);
+      expect(packageFile).toEqual({
+        deps: [
+          {
+            datasource: 'maven',
+            depName: 'com.day.cq:core.wcm.components.all',
+            currentValue: '2.21.0',
+          },
+        ],
+      });
+    });
+
+    it('extracts the artifacts a file with a double slash', () => {
+      const packageFile = extractPackageFile(
+        doubleSlashNotComment,
+        '',
+        undefined
+      );
+      expect(packageFile).toEqual({
+        deps: [
+          {
+            datasource: 'maven',
+            depName: 'com.h2database:h2-mvstore',
+            currentValue: '2.1.214',
+          },
+          {
+            datasource: 'maven',
+            depName: 'org.mongodb:mongo-java-driver',
+            currentValue: '3.12.11',
+          },
+        ],
+      });
+    });
+
+    it('extracts the artifacts from the framework artifact section', () => {
+      const packageFile = extractPackageFile(frameworkArtifact, '', undefined);
+      expect(packageFile).toEqual({
+        deps: [
+          {
+            datasource: 'maven',
+            depName: 'org.apache.felix:org.apache.felix.framework',
+            currentValue: '7.0.5',
+          },
+        ],
+      });
+    });
+
+    it('skips depedencies with with malformed definitions', () => {
+      const packageFile = extractPackageFile(
+        malformedDefinitions,
+        '',
+        undefined
+      );
+      expect(packageFile).toEqual({
+        deps: [
+          {
+            depName: 'commons-codec:1.15',
+            skipReason: 'invalid-value',
+          },
+          {
+            datasource: 'maven',
+            depName: 'commons-codec:commons-codec',
+            currentValue: '1.15',
+          },
+        ],
+      });
+    });
+
+    it('skips artifacts with variables in version', () => {
+      const packageFile = extractPackageFile(
+        versionWithVariable,
+        '',
+        undefined
+      );
+      expect(packageFile).toEqual({
+        deps: [
+          {
+            datasource: 'maven',
+            depName: 'com.fasterxml.jackson.core:jackson-annotations',
+            skipReason: 'contains-variable',
+          },
+        ],
+      });
+    });
+  });
+});
diff --git a/lib/modules/manager/osgi/extract.ts b/lib/modules/manager/osgi/extract.ts
new file mode 100644
index 0000000000..b38670b7ca
--- /dev/null
+++ b/lib/modules/manager/osgi/extract.ts
@@ -0,0 +1,135 @@
+import is from '@sindresorhus/is';
+import * as json5 from 'json5';
+import { coerce, satisfies } from 'semver';
+import { logger } from '../../../logger';
+import { MavenDatasource } from '../../datasource/maven';
+import type { ExtractConfig, PackageDependency, PackageFile } from '../types';
+import type { Bundle, FeatureModel } from './types';
+
+export function extractPackageFile(
+  content: string,
+  fileName: string,
+  config?: ExtractConfig
+): PackageFile | null {
+  // References:
+  // - OSGi compendium release 8 ( https://docs.osgi.org/specification/osgi.cmpn/8.0.0/service.feature.html )
+  // - The Sling implementation of the feature model ( https://sling.apache.org/documentation/development/feature-model.html )
+  logger.trace({ fileName }, 'osgi.extractPackageFile');
+
+  const deps: PackageDependency[] = [];
+  let featureModel: FeatureModel;
+  try {
+    // Compendium R8 159.3: JS comments are supported
+    featureModel = json5.parse<FeatureModel>(content);
+  } catch (err) {
+    logger.warn({ fileName, err }, 'Failed to parse osgi file');
+    return null;
+  }
+
+  if (
+    // for empty an empty result
+    is.nullOrUndefined(featureModel) ||
+    // Compendium R8 159.9: resource versioning
+    !isSupportedFeatureResourceVersion(featureModel, fileName)
+  ) {
+    return null;
+  }
+
+  // OSGi Compendium R8 159.4: bundles entry
+  const allBundles = featureModel.bundles ?? [];
+
+  // The 'execution-environment' key is supported by the Sling/OSGi feature model implementation
+  const execEnvFramework =
+    featureModel['execution-environment:JSON|false']?.['framework'];
+  if (!is.nullOrUndefined(execEnvFramework)) {
+    allBundles.push(execEnvFramework);
+  }
+
+  // parse custom sections
+  //
+  // Note: we do not support artifact list extensions as defined in
+  // section 159.7.3 yet. As of 05-12-2022, there is no implementation that
+  // supports this
+  for (const [section, value] of Object.entries(featureModel)) {
+    logger.trace({ fileName, section }, 'Parsing section');
+    const customSectionEntries = extractArtifactList(section, value);
+    allBundles.push(...customSectionEntries);
+  }
+
+  // convert bundles to dependencies
+  for (const entry of allBundles) {
+    const rawGav = typeof entry === 'string' ? entry : entry.id;
+    // skip invalid definitions, such as objects without an id set
+    if (!rawGav) {
+      continue;
+    }
+
+    // both '/' and ':' are valid separators, but the Maven datasource
+    // expects the separator to be ':'
+    const gav = rawGav.replace(/\//g, ':');
+
+    // identifiers support 3-5 parts, see OSGi R8 - 159.2.1 Identifiers
+    // groupId ':' artifactId ( ':' type ( ':' classifier )? )? ':' version
+    const parts = gav.split(':');
+    if (parts.length < 3 || parts.length > 5) {
+      deps.push({
+        depName: gav,
+        skipReason: 'invalid-value',
+      });
+      continue;
+    }
+    // parsing should use the last entry for the version
+    const currentValue = parts[parts.length - 1];
+    const result: PackageDependency = {
+      datasource: MavenDatasource.id,
+      depName: `${parts[0]}:${parts[1]}`,
+    };
+    if (currentValue.includes('${')) {
+      result.skipReason = 'contains-variable';
+    } else {
+      result.currentValue = currentValue;
+    }
+
+    deps.push(result);
+  }
+
+  return deps.length ? { deps } : null;
+}
+
+function isSupportedFeatureResourceVersion(
+  featureModel: FeatureModel,
+  fileName: string
+): boolean {
+  const resourceVersion = featureModel['feature-resource-version'];
+  if (resourceVersion) {
+    const resourceSemVer = coerce(resourceVersion);
+    if (!resourceSemVer) {
+      logger.debug(
+        `Skipping file ${fileName} due to invalid feature-resource-version '${resourceVersion}'`
+      );
+      return false;
+    }
+
+    // we only support 1.x, although no over version has been defined
+    if (!satisfies(resourceSemVer, '^1')) {
+      logger.debug(
+        `Skipping file ${fileName} due to unsupported feature-resource-version '${resourceVersion}'`
+      );
+      return false;
+    }
+  }
+
+  return true;
+}
+
+function extractArtifactList(
+  sectionName: string,
+  sectionValue: unknown
+): Bundle[] {
+  // The 'ARTIFACTS' key is supported by the Sling/OSGi feature model implementation
+  if (sectionName.includes(':ARTIFACTS|') && is.array(sectionValue)) {
+    return sectionValue as Bundle[];
+  }
+
+  return [];
+}
diff --git a/lib/modules/manager/osgi/index.ts b/lib/modules/manager/osgi/index.ts
new file mode 100644
index 0000000000..3fc0a553f3
--- /dev/null
+++ b/lib/modules/manager/osgi/index.ts
@@ -0,0 +1,12 @@
+import type { ProgrammingLanguage } from '../../../constants';
+import { MavenDatasource } from '../../datasource/maven';
+
+export { extractPackageFile } from './extract';
+
+export const language: ProgrammingLanguage = 'java';
+
+export const defaultConfig = {
+  fileMatch: ['(^|/)src/main/features/.+\\.json$'],
+};
+
+export const supportedDatasources = [MavenDatasource.id];
diff --git a/lib/modules/manager/osgi/readme.md b/lib/modules/manager/osgi/readme.md
new file mode 100644
index 0000000000..a3044df828
--- /dev/null
+++ b/lib/modules/manager/osgi/readme.md
@@ -0,0 +1,10 @@
+The `osgi` manager extracts dependencies from feature model definition files, typically located under `src/main/features`.
+It uses the `maven` datasource to find dependency updates.
+
+Artifact list extensions are not supported.
+For the definition of artifact list extensions, read [section 159.7.3 of the OSGi R8 spec](https://docs.osgi.org/specification/osgi.cmpn/8.0.0/service.feature.html#d0e156801).
+
+References:
+
+- [OSGi compendium release 8, Feature Service Specification](https://docs.osgi.org/specification/osgi.cmpn/8.0.0/service.feature.html)
+- [The Sling implementation of the feature model](https://sling.apache.org/documentation/development/feature-model.html)
diff --git a/lib/modules/manager/osgi/types.ts b/lib/modules/manager/osgi/types.ts
new file mode 100644
index 0000000000..944369edce
--- /dev/null
+++ b/lib/modules/manager/osgi/types.ts
@@ -0,0 +1,14 @@
+export interface FeatureModel {
+  'feature-resource-version'?: string;
+  bundles?: Bundle[];
+  'execution-environment:JSON|false'?: {
+    framework?: Bundle;
+  };
+  [x: string]: unknown;
+}
+
+export type Bundle = string | BundleObject;
+
+export interface BundleObject {
+  id: string;
+}
-- 
GitLab