feat: GKE Resource provider (#18759)

* feat: GKE Resource provider

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* feat: configurable parents

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* chore: changeset

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* test: happy path test

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* chore: typos

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* docs: api-reports

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* docs: readme

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

---------

Signed-off-by: Matthew Clarke <mclarke@spotify.com>
This commit is contained in:
Matthew Clarke
2023-07-27 15:09:23 -04:00
committed by GitHub
parent f602c02daa
commit 290eff6692
16 changed files with 858 additions and 4 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-gcp': minor
---
Added GCP catalog plugin with GKE provider
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,41 @@
# Catalog Backend Module for GCP
This is an extension module to the plugin-catalog-backend plugin, containing catalog processors and providers to ingest GCP resources as `Resource` kind entities.
## installation
Register the plugin in `catalog.ts``
```typescript
import { GkeEntityProvider } from '@backstage/plugin-catalog-backend-module-gcp';
...
builder.addEntityProvider(
GkeEntityProvider.fromConfig({
logger: env.logger,
scheduler: env.scheduler,
config: env.config
})
);
```
Update `app-config.yaml` as follows:
```yaml
catalog:
providers:
gcp:
gke:
parents:
# consult https://cloud.google.com/kubernetes-engine/docs/ for valid values
# list all clusters in the project
- 'projects/some-project/locations/-'
# list all clusters in the region, in the project
- 'projects/some-other-project/locations/some-region'
schedule: # optional; same options as in TaskScheduleDefinition
# supports cron, ISO duration, "human duration" as used in code
frequency: { minutes: 30 }
# supports ISO duration, "human duration" as used in code
timeout: { minutes: 3 }
```
@@ -0,0 +1,7 @@
## API Report File for "@backstage/plugin-catalog-backend-module-gcp"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
```
@@ -0,0 +1,48 @@
## API Report File for "@backstage/plugin-catalog-backend-module-gcp"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import * as container from '@google-cloud/container';
import { EntityProvider } from '@backstage/plugin-catalog-node';
import { EntityProviderConnection } from '@backstage/plugin-catalog-node';
import { Logger } from 'winston';
import { SchedulerService } from '@backstage/backend-plugin-api';
// @public
export const catalogModuleGcpGkeEntityProvider: () => BackendFeature;
// @public
export class GkeEntityProvider implements EntityProvider {
// (undocumented)
connect(connection: EntityProviderConnection): Promise<void>;
// (undocumented)
static fromConfig({
logger,
scheduler,
config,
}: {
logger: Logger;
scheduler: SchedulerService;
config: Config;
}): GkeEntityProvider;
// (undocumented)
static fromConfigWithClient({
logger,
scheduler,
config,
clusterManagerClient,
}: {
logger: Logger;
scheduler: SchedulerService;
config: Config;
clusterManagerClient: container.v1.ClusterManagerClient;
}): GkeEntityProvider;
// (undocumented)
getProviderName(): string;
// (undocumented)
refresh(): Promise<void>;
}
```
+45
View File
@@ -0,0 +1,45 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TaskScheduleDefinitionConfig } from '@backstage/backend-tasks';
export interface Config {
catalog?: {
/**
* List of provider-specific options and attributes
*/
providers?: {
/**
* GCPCatalogModuleConfig configuration
*/
gcp?: {
/**
* Config for GKE clusters
*/
gke?: {
/**
* Locations to list clusters from
*/
parents: string[];
/**
* (Optional) TaskScheduleDefinition for the refresh.
*/
schedule: TaskScheduleDefinitionConfig;
};
};
};
};
}
@@ -0,0 +1,66 @@
{
"name": "@backstage/plugin-catalog-backend-module-gcp",
"description": "A Backstage catalog backend module that helps integrate towards GCP",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public"
},
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.ts",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"alpha": [
"src/alpha.ts"
],
"package.json": [
"package.json"
]
}
},
"backstage": {
"role": "backend-plugin-module"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/catalog-backend-module-gcp"
},
"keywords": [
"backstage"
],
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"clean": "backstage-cli package clean"
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/backend-tasks": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"@backstage/plugin-kubernetes-common": "workspace:^",
"@google-cloud/container": "^4.15.0",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^"
},
"files": [
"config.d.ts",
"dist"
],
"configSchema": "config.d.ts"
}
@@ -0,0 +1,23 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* A Backstage catalog backend module that helps integrate towards GCP
*
* @packageDocumentation
*/
export {};
@@ -0,0 +1,24 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* A Backstage catalog backend module that helps integrate towards GCP
*
* @packageDocumentation
*/
export * from './providers';
export * from './module';
@@ -0,0 +1,52 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { loggerToWinstonLogger } from '@backstage/backend-common';
import {
coreServices,
createBackendModule,
} from '@backstage/backend-plugin-api';
import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha';
import { GkeEntityProvider } from '../providers/GkeEntityProvider';
/**
* Registers the GcpGkeEntityProvider with the catalog processing extension point.
*
* @public
*/
export const catalogModuleGcpGkeEntityProvider = createBackendModule({
pluginId: 'catalog',
moduleId: 'gcpGkeEntityProvider',
register(env) {
env.registerInit({
deps: {
config: coreServices.config,
catalog: catalogProcessingExtensionPoint,
logger: coreServices.logger,
scheduler: coreServices.scheduler,
},
async init({ config, catalog, logger, scheduler }) {
catalog.addEntityProvider(
GkeEntityProvider.fromConfig({
logger: loggerToWinstonLogger(logger),
scheduler,
config,
}),
);
},
});
},
});
@@ -0,0 +1,17 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { catalogModuleGcpGkeEntityProvider } from './catalogModuleGcpGkeEntityProvider';
@@ -0,0 +1,251 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { GkeEntityProvider } from './GkeEntityProvider';
import { TaskRunner } from '@backstage/backend-tasks';
import {
ANNOTATION_KUBERNETES_API_SERVER,
ANNOTATION_KUBERNETES_API_SERVER_CA,
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
} from '@backstage/plugin-kubernetes-common';
import * as container from '@google-cloud/container';
import { ConfigReader } from '@backstage/config';
describe('GkeEntityProvider', () => {
const clusterManagerClientMock = {
listClusters: jest.fn(),
};
const connectionMock = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const taskRunner = {
createScheduleFn: jest.fn(),
run: jest.fn(),
} as TaskRunner;
const schedulerMock = {
createScheduledTaskRunner: jest.fn(),
} as any;
const logger = {
info: jest.fn(),
error: jest.fn(),
};
let gkeEntityProvider: GkeEntityProvider;
beforeEach(async () => {
jest.resetAllMocks();
schedulerMock.createScheduledTaskRunner.mockReturnValue(taskRunner);
gkeEntityProvider = GkeEntityProvider.fromConfigWithClient({
logger: logger as any,
config: new ConfigReader({
catalog: {
providers: {
gcp: {
gke: {
parents: ['parent1', 'parent2'],
schedule: {
frequency: {
minutes: 3,
},
timeout: {
minutes: 3,
},
},
},
},
},
},
}),
scheduler: schedulerMock,
clusterManagerClient: clusterManagerClientMock as any,
});
await gkeEntityProvider.connect(connectionMock);
});
it('should return clusters as Resources', async () => {
clusterManagerClientMock.listClusters.mockImplementation(req => {
if (req.parent === 'parent1') {
return [
{
clusters: [
{
name: 'some-cluster',
endpoint: 'http://127.0.0.1:1234',
location: 'some-location',
selfLink: 'http://127.0.0.1/some-link',
masterAuth: {
clusterCaCertificate: 'abcdefg',
},
},
],
},
];
} else if (req.parent === 'parent2') {
return [
{
clusters: [
{
name: 'some-other-cluster',
endpoint: 'http://127.0.0.1:5678',
location: 'some-other-location',
selfLink: 'http://127.0.0.1/some-other-link',
masterAuth: {
// no CA cert is ok
},
},
],
},
];
}
throw new Error(`unexpected parent ${req.parent}`);
});
await gkeEntityProvider.refresh();
expect(connectionMock.applyMutation).toHaveBeenCalledWith({
type: 'full',
entities: [
{
locationKey: 'gcp-gke:some-location',
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Resource',
metadata: {
annotations: {
[ANNOTATION_KUBERNETES_API_SERVER]: 'http://127.0.0.1:1234',
[ANNOTATION_KUBERNETES_API_SERVER_CA]: 'abcdefg',
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google',
'backstage.io/managed-by-location': 'gcp-gke:some-location',
'backstage.io/managed-by-origin-location':
'gcp-gke:some-location',
},
name: 'some-cluster',
namespace: 'default',
},
spec: {
type: 'kubernetes-cluster',
owner: 'unknown',
},
},
},
{
locationKey: 'gcp-gke:some-other-location',
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Resource',
metadata: {
annotations: {
[ANNOTATION_KUBERNETES_API_SERVER]: 'http://127.0.0.1:5678',
[ANNOTATION_KUBERNETES_API_SERVER_CA]: '',
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google',
'backstage.io/managed-by-location':
'gcp-gke:some-other-location',
'backstage.io/managed-by-origin-location':
'gcp-gke:some-other-location',
},
name: 'some-other-cluster',
namespace: 'default',
},
spec: {
type: 'kubernetes-cluster',
owner: 'unknown',
},
},
},
],
});
});
const ignoredPartialClustersTests: [
string,
container.protos.google.container.v1.ICluster,
][] = [
[
'no-cluster-name',
{
endpoint: 'http://127.0.0.1:1234',
location: 'some-location',
selfLink: 'http://127.0.0.1/some-link',
masterAuth: {
clusterCaCertificate: 'abcdefg',
},
},
],
[
'no-self-link',
{
// no selfLink
name: 'some-name',
endpoint: 'http://127.0.0.1:1234',
location: 'some-location',
masterAuth: {
clusterCaCertificate: 'abcdefg',
},
},
],
[
'no-endpoint',
{
name: 'some-name',
location: 'some-location',
selfLink: 'http://127.0.0.1/some-link',
masterAuth: {
clusterCaCertificate: 'abcdefg',
},
},
],
[
'no-location',
{
name: 'some-name',
endpoint: 'http://127.0.0.1:1234',
selfLink: 'http://127.0.0.1/some-link',
masterAuth: {
clusterCaCertificate: 'abcdefg',
},
},
],
];
it.each(ignoredPartialClustersTests)(
'ignore cluster - %s',
async (_name, ignoredCluster) => {
clusterManagerClientMock.listClusters.mockImplementation(req => {
if (req.parent === 'parent1') {
return [ignoredCluster];
}
return [
{
clusters: [],
},
];
});
await gkeEntityProvider.refresh();
expect(connectionMock.applyMutation).toHaveBeenCalledWith({
type: 'full',
entities: [],
});
},
);
it('should log GKE API errors', async () => {
clusterManagerClientMock.listClusters.mockRejectedValue(
new Error('some-error'),
);
await gkeEntityProvider.refresh();
expect(connectionMock.applyMutation).toHaveBeenCalledTimes(0);
expect(logger.error).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,223 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
TaskRunner,
readTaskScheduleDefinitionFromConfig,
} from '@backstage/backend-tasks';
import {
DeferredEntity,
EntityProvider,
EntityProviderConnection,
} from '@backstage/plugin-catalog-node';
import { Logger } from 'winston';
import * as container from '@google-cloud/container';
import {
ANNOTATION_KUBERNETES_API_SERVER,
ANNOTATION_KUBERNETES_API_SERVER_CA,
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
} from '@backstage/plugin-kubernetes-common';
import { Config } from '@backstage/config';
import { SchedulerService } from '@backstage/backend-plugin-api';
/**
* Catalog provider to ingest GKE clusters
*
* @public
*/
export class GkeEntityProvider implements EntityProvider {
private readonly logger: Logger;
private readonly scheduleFn: () => Promise<void>;
private readonly gkeParents: string[];
private readonly clusterManagerClient: container.v1.ClusterManagerClient;
private connection?: EntityProviderConnection;
private constructor(
logger: Logger,
taskRunner: TaskRunner,
gkeParents: string[],
clusterManagerClient: container.v1.ClusterManagerClient,
) {
this.logger = logger;
this.scheduleFn = this.createScheduleFn(taskRunner);
this.gkeParents = gkeParents;
this.clusterManagerClient = clusterManagerClient;
}
public static fromConfig({
logger,
scheduler,
config,
}: {
logger: Logger;
scheduler: SchedulerService;
config: Config;
}) {
return GkeEntityProvider.fromConfigWithClient({
logger,
scheduler: scheduler,
config,
clusterManagerClient: new container.v1.ClusterManagerClient(),
});
}
public static fromConfigWithClient({
logger,
scheduler,
config,
clusterManagerClient,
}: {
logger: Logger;
scheduler: SchedulerService;
config: Config;
clusterManagerClient: container.v1.ClusterManagerClient;
}) {
const gkeProviderConfig = config.getConfig('catalog.providers.gcp.gke');
const schedule = readTaskScheduleDefinitionFromConfig(
gkeProviderConfig.getConfig('schedule'),
);
return new GkeEntityProvider(
logger,
scheduler.createScheduledTaskRunner(schedule),
gkeProviderConfig.getStringArray('parents'),
clusterManagerClient,
);
}
getProviderName(): string {
return `gcp-gke`;
}
async connect(connection: EntityProviderConnection): Promise<void> {
this.connection = connection;
await this.scheduleFn();
}
private filterOutUndefinedDeferredEntity(
e: DeferredEntity | undefined,
): e is DeferredEntity {
return e !== undefined;
}
private filterOutUndefinedCluster(
c: container.protos.google.container.v1.ICluster | null | undefined,
): c is container.protos.google.container.v1.ICluster {
return c !== undefined && c !== null;
}
private clusterToResource(
cluster: container.protos.google.container.v1.ICluster,
): DeferredEntity | undefined {
const location = `${this.getProviderName()}:${cluster.location}`;
if (!cluster.name || !cluster.selfLink || !location || !cluster.endpoint) {
this.logger.warn(
`ignoring partial cluster, one of name=${cluster.name}, endpoint=${cluster.endpoint}, selfLink=${cluster.selfLink} or location=${cluster.location} is missing`,
);
return undefined;
}
// TODO fix location type
return {
locationKey: location,
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Resource',
metadata: {
annotations: {
[ANNOTATION_KUBERNETES_API_SERVER]: cluster.endpoint,
[ANNOTATION_KUBERNETES_API_SERVER_CA]:
cluster.masterAuth?.clusterCaCertificate || '',
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google',
'backstage.io/managed-by-location': location,
'backstage.io/managed-by-origin-location': location,
},
name: cluster.name,
namespace: 'default',
},
spec: {
type: 'kubernetes-cluster',
owner: 'unknown',
},
},
};
}
private createScheduleFn(taskRunner: TaskRunner): () => Promise<void> {
return async () => {
const taskId = `${this.getProviderName()}:refresh`;
return taskRunner.run({
id: taskId,
fn: async () => {
try {
await this.refresh();
} catch (error) {
this.logger.error(error);
}
},
});
};
}
private async getClusters(): Promise<
container.protos.google.container.v1.ICluster[]
> {
const clusters = await Promise.all(
this.gkeParents.map(async parent => {
const request = {
parent: parent,
};
const [response] = await this.clusterManagerClient.listClusters(
request,
);
return response.clusters?.filter(this.filterOutUndefinedCluster) ?? [];
}),
);
return clusters.flat();
}
async refresh() {
if (!this.connection) {
throw new Error('Not initialized');
}
this.logger.info('Discovering GKE clusters');
let clusters: container.protos.google.container.v1.ICluster[];
try {
clusters = await this.getClusters();
} catch (e) {
this.logger.error('error fetching GKE clusters', e);
return;
}
const resources =
clusters
.map(c => this.clusterToResource(c))
.filter(this.filterOutUndefinedDeferredEntity) ?? [];
this.logger.info(
`Ingesting GKE clusters [${resources
.map(r => r.entity.metadata.name)
.join(', ')}]`,
);
await this.connection.applyMutation({
type: 'full',
entities: resources,
});
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { GkeEntityProvider } from './GkeEntityProvider';
@@ -0,0 +1,17 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {};
+21 -4
View File
@@ -5160,6 +5160,23 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-catalog-backend-module-gcp@workspace:plugins/catalog-backend-module-gcp":
version: 0.0.0-use.local
resolution: "@backstage/plugin-catalog-backend-module-gcp@workspace:plugins/catalog-backend-module-gcp"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-tasks": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/plugin-catalog-node": "workspace:^"
"@backstage/plugin-kubernetes-common": "workspace:^"
"@google-cloud/container": ^4.15.0
winston: ^3.2.1
languageName: unknown
linkType: soft
"@backstage/plugin-catalog-backend-module-gerrit@workspace:plugins/catalog-backend-module-gerrit":
version: 0.0.0-use.local
resolution: "@backstage/plugin-catalog-backend-module-gerrit@workspace:plugins/catalog-backend-module-gerrit"
@@ -10912,12 +10929,12 @@ __metadata:
languageName: node
linkType: hard
"@google-cloud/container@npm:^4.0.0":
version: 4.13.0
resolution: "@google-cloud/container@npm:4.13.0"
"@google-cloud/container@npm:^4.0.0, @google-cloud/container@npm:^4.15.0":
version: 4.15.0
resolution: "@google-cloud/container@npm:4.15.0"
dependencies:
google-gax: ^3.5.8
checksum: 21e57e8c69e2df8cf6899541dc405f9a6c05d999289cf211c2bb8dc6e33bf7ef36ff255cfde75c6448dd335fb5b707e77f5df49bee561decbd5ed398c58db6f6
checksum: e81f1b708ab44c55b8e998d34c442baed10a113828ef51b4a84b806a2b04a10a0dc40ee0aa202386ac6158b34b9d3f03467d7008da9e4f709fdb1f021e725b8c
languageName: node
linkType: hard