From bc9d62f4f7b452141b383ad00124ed9ba2c1c3e0 Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Thu, 8 Apr 2021 18:38:39 +0200 Subject: [PATCH] Create a new method to check the configuration of a techdocs publisher to not crash the application on errors Signed-off-by: Dominik Henneke --- .changeset/fluffy-lies-complain.md | 18 ++ .../__mocks__/@google-cloud/storage.ts | 10 +- packages/techdocs-common/__mocks__/aws-sdk.ts | 14 +- .../techdocs-common/__mocks__/pkgcloud.ts | 15 +- .../src/stages/publish/awsS3.test.ts | 41 ++- .../src/stages/publish/awsS3.ts | 62 ++-- .../stages/publish/azureBlobStorage.test.ts | 264 +++++++----------- .../src/stages/publish/azureBlobStorage.ts | 63 +++-- .../src/stages/publish/googleStorage.test.ts | 31 +- .../src/stages/publish/googleStorage.ts | 54 ++-- .../src/stages/publish/local.ts | 7 + .../src/stages/publish/openStackSwift.test.ts | 45 ++- .../src/stages/publish/openStackSwift.ts | 59 ++-- .../src/stages/publish/publish.ts | 14 +- .../src/stages/publish/types.ts | 16 ++ 15 files changed, 423 insertions(+), 290 deletions(-) create mode 100644 .changeset/fluffy-lies-complain.md diff --git a/.changeset/fluffy-lies-complain.md b/.changeset/fluffy-lies-complain.md new file mode 100644 index 0000000000..addffc2baa --- /dev/null +++ b/.changeset/fluffy-lies-complain.md @@ -0,0 +1,18 @@ +--- +'@backstage/techdocs-common': patch +--- + +Move the sanity checks of the publisher configurations to a dedicated `PublisherBase#validateConfiguration()` method instead of throwing an error when doing `Publisher.fromConfig(...)`. +If you want to preserve this check in your application, use the following code: + +```ts +const publisher = await Publisher.fromConfig(config, { + logger, + discovery, +}); + +const validation = await publisher.validateConfiguration(); +if (!validation.isValid) { + throw new Error('Invalid TechDocs publisher configuration'); +} +``` diff --git a/packages/techdocs-common/__mocks__/@google-cloud/storage.ts b/packages/techdocs-common/__mocks__/@google-cloud/storage.ts index 264852d31c..f8d0368f27 100644 --- a/packages/techdocs-common/__mocks__/@google-cloud/storage.ts +++ b/packages/techdocs-common/__mocks__/@google-cloud/storage.ts @@ -81,10 +81,12 @@ class Bucket { this.bucketName = bucketName; } - getMetadata() { - return new Promise(resolve => { - resolve(''); - }); + async getMetadata() { + if (this.bucketName === 'errorBucket') { + throw Error('Bucket does not exist'); + } + + return ''; } upload(source: string, { destination }) { diff --git a/packages/techdocs-common/__mocks__/aws-sdk.ts b/packages/techdocs-common/__mocks__/aws-sdk.ts index 363662c63c..eb15a218ab 100644 --- a/packages/techdocs-common/__mocks__/aws-sdk.ts +++ b/packages/techdocs-common/__mocks__/aws-sdk.ts @@ -80,10 +80,16 @@ export class S3 { }; } - headBucket() { - return new Promise(resolve => { - resolve(''); - }); + headBucket({ Bucket }) { + return { + promise: async () => { + if (Bucket === 'errorBucket') { + throw new Error('Bucket does not exist'); + } + + return {}; + }, + }; } upload({ Key }: { Key: string }) { diff --git a/packages/techdocs-common/__mocks__/pkgcloud.ts b/packages/techdocs-common/__mocks__/pkgcloud.ts index 5d9f81cda0..dca86238e4 100644 --- a/packages/techdocs-common/__mocks__/pkgcloud.ts +++ b/packages/techdocs-common/__mocks__/pkgcloud.ts @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { EventEmitter } from 'events'; import fs from 'fs-extra'; import os from 'os'; import path from 'path'; -import { EventEmitter } from 'events'; +import { ClientError } from 'pkgcloud'; const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; @@ -37,12 +38,11 @@ class PkgCloudStorageClient { getFile( containerName: string, file: string, - callback: (err: any, file: string) => any, + callback: (err: any, file: any) => any, ) { checkFileExists(file).then(res => { if (!res) { - callback('File does not exist', file); - throw new Error('File does not exist'); + callback('File does not exist', undefined); } else { callback(undefined, 'success'); } @@ -51,13 +51,12 @@ class PkgCloudStorageClient { getContainer( containerName: string, - callback: (err: string, container: string) => any, + callback: (err: ClientError, container: any) => any, ) { if (containerName !== 'mock') { - callback('Container does not exist', containerName); - throw new Error('Container does not exist'); + callback(new Error('Container does not exist'), undefined); } else { - callback('Container does not exist', 'success'); + callback(undefined, 'success'); } } diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts index d3281c2645..b0000b6464 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.test.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -13,16 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { getVoidLogger } from '@backstage/backend-common'; import { Entity, - EntityName, ENTITY_DEFAULT_NAMESPACE, + EntityName, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import mockFs from 'mock-fs'; import os from 'os'; import path from 'path'; -import * as winston from 'winston'; import { AwsS3Publish } from './awsS3'; import { PublisherBase, TechDocsMetadata } from './types'; @@ -59,9 +59,7 @@ const getEntityRootDir = (entity: Entity) => { return path.join(rootDir, namespace || ENTITY_DEFAULT_NAMESPACE, kind, name); }; -const logger = winston.createLogger(); -jest.spyOn(logger, 'info').mockReturnValue(logger); -jest.spyOn(logger, 'error').mockReturnValue(logger); +const logger = getVoidLogger(); let publisher: PublisherBase; @@ -87,6 +85,39 @@ beforeEach(() => { }); describe('AwsS3Publish', () => { + describe('validateConfiguration', () => { + it('should validate correct config', async () => { + expect(await publisher.validateConfiguration()).toEqual({ + isValid: true, + }); + }); + + it('should reject incorrect config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'awsS3', + awsS3: { + credentials: { + accessKeyId: 'accessKeyId', + secretAccessKey: 'secretAccessKey', + }, + // this bucket name will throw an error + bucketName: 'errorBucket', + }, + }, + }, + }); + + const errorPublisher = AwsS3Publish.fromConfig(mockConfig, logger); + + expect(await errorPublisher.validateConfiguration()).toEqual({ + isValid: false, + }); + }); + }); + describe('publish', () => { beforeEach(() => { const entity = createMockEntity(); diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index c4c39898f4..77bb771fb6 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -17,16 +17,21 @@ import { Entity, EntityName } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import aws, { Credentials } from 'aws-sdk'; import { ManagedUpload } from 'aws-sdk/clients/s3'; +import { CredentialsOptions } from 'aws-sdk/lib/credentials'; import express from 'express'; import fs from 'fs-extra'; import JSON5 from 'json5'; import createLimiter from 'p-limit'; -import { CredentialsOptions } from 'aws-sdk/lib/credentials'; import path from 'path'; import { Readable } from 'stream'; import { Logger } from 'winston'; import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers'; -import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; +import { + ConfigurationValidationResponse, + PublisherBase, + PublishRequest, + TechDocsMetadata, +} from './types'; const streamToBuffer = (stream: Readable): Promise => { return new Promise((resolve, reject) => { @@ -81,30 +86,6 @@ export class AwsS3Publish implements PublisherBase { ...(endpoint && { endpoint }), }); - // Check if the defined bucket exists. Being able to connect means the configuration is good - // and the storage client will work. - storageClient.headBucket( - { - Bucket: bucketName, - }, - err => { - if (err) { - logger.error( - `Could not retrieve metadata about the AWS S3 bucket ${bucketName}. ` + - 'Make sure the bucket exists. Also make sure that authentication is setup either by ' + - 'explicitly defining credentials and region in techdocs.publisher.awsS3 in app config or ' + - 'by using environment variables. Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', - ); - logger.error(`from AWS client library: ${err.message}`); - throw new Error(); - } else { - logger.info( - `Successfully connected to the AWS S3 bucket ${bucketName}.`, - ); - } - }, - ); - return new AwsS3Publish(storageClient, bucketName, logger); } @@ -149,6 +130,35 @@ export class AwsS3Publish implements PublisherBase { this.logger = logger; } + /** + * Check if the defined bucket exists. Being able to connect means the configuration is good + * and the storage client will work. + */ + async validateConfiguration(): Promise { + try { + await this.storageClient + .headBucket({ Bucket: this.bucketName }) + .promise(); + + this.logger.info( + `Successfully connected to the AWS S3 bucket ${this.bucketName}.`, + ); + + return { isValid: true }; + } catch (error) { + this.logger.error( + `Could not retrieve metadata about the AWS S3 bucket ${this.bucketName}. ` + + 'Make sure the bucket exists. Also make sure that authentication is setup either by ' + + 'explicitly defining credentials and region in techdocs.publisher.awsS3 in app config or ' + + 'by using environment variables. Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + ); + this.logger.error(`from AWS client library`, error); + return { + isValid: false, + }; + } + } + /** * Upload all the files from the generated `directory` to the S3 bucket. * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts index 9442fb0e37..b67e47c675 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -16,8 +16,8 @@ import { getVoidLogger } from '@backstage/backend-common'; import { Entity, - EntityName, ENTITY_DEFAULT_NAMESPACE, + EntityName, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import mockFs from 'mock-fs'; @@ -59,12 +59,9 @@ const getEntityRootDir = (entity: Entity) => { return path.join(rootDir, namespace || ENTITY_DEFAULT_NAMESPACE, kind, name); }; -function createLogger() { - const logger = getVoidLogger(); - jest.spyOn(logger, 'info').mockReturnValue(logger); - jest.spyOn(logger, 'error').mockReturnValue(logger); - return logger; -} +const logger = getVoidLogger(); +jest.spyOn(logger, 'info').mockReturnValue(logger); +jest.spyOn(logger, 'error').mockReturnValue(logger); let publisher: PublisherBase; beforeEach(async () => { @@ -85,13 +82,51 @@ beforeEach(async () => { }, }); - publisher = await AzureBlobStoragePublish.fromConfig( - mockConfig, - createLogger(), - ); + publisher = AzureBlobStoragePublish.fromConfig(mockConfig, logger); }); describe('publishing with valid credentials', () => { + describe('validateConfiguration', () => { + it('should validate correct config', async () => { + expect(await publisher.validateConfiguration()).toEqual({ + isValid: true, + }); + }); + + it('should reject incorrect config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'accountName', + accountKey: 'accountKey', + }, + containerName: 'bad_container', + }, + }, + }, + }); + + const errorPublisher = await AzureBlobStoragePublish.fromConfig( + mockConfig, + logger, + ); + + expect(await errorPublisher.validateConfiguration()).toEqual({ + isValid: false, + }); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + `Could not retrieve metadata about the Azure Blob Storage container bad_container.`, + ), + ); + }); + }); + describe('publish', () => { beforeEach(() => { const entity = createMockEntity(); @@ -151,6 +186,60 @@ describe('publishing with valid credentials', () => { }); mockFs.restore(); }); + + it('reports an error when bad account credentials', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'failupload', + accountKey: 'accountKey', + }, + containerName: 'containerName', + }, + }, + }, + }); + + publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger); + + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + }, + }); + + let error; + try { + await publisher.publish({ + entity, + directory: entityRootDir, + }); + } catch (e) { + error = e; + } + + expect(error.message).toContain( + `Unable to upload file(s) to Azure Blob Storage.`, + ); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + `Unable to upload file(s) to Azure Blob Storage. Error: Upload failed for ${path.join( + entityRootDir, + 'index.html', + )} with status code 500`, + ), + ); + + mockFs.restore(); + }); }); describe('hasDocsBeenGenerated', () => { @@ -243,156 +332,3 @@ describe('publishing with valid credentials', () => { }); }); }); - -describe('error reporting', () => { - it('reports an error when unable to read container properties', async () => { - const mockConfig = new ConfigReader({ - techdocs: { - requestUrl: 'http://localhost:7000', - publisher: { - type: 'azureBlobStorage', - azureBlobStorage: { - credentials: { - accountName: 'accountName', - }, - containerName: 'bad_container', - }, - }, - }, - }); - - const logger = createLogger(); - - let error; - try { - publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger); - } catch (e) { - error = e; - } - - expect(error).toBeInstanceOf(Error); - - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining( - `Could not retrieve metadata about the Azure Blob Storage container bad_container.`, - ), - ); - }); - - it('reports an error when bad account credentials', async () => { - const mockConfig = new ConfigReader({ - techdocs: { - requestUrl: 'http://localhost:7000', - publisher: { - type: 'azureBlobStorage', - azureBlobStorage: { - credentials: { - accountName: 'failupload', - accountKey: 'accountKey', - }, - containerName: 'containerName', - }, - }, - }, - }); - - const logger = createLogger(); - - publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger); - - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); - - mockFs({ - [entityRootDir]: { - 'index.html': '', - }, - }); - - let error; - try { - await publisher.publish({ - entity, - directory: entityRootDir, - }); - } catch (e) { - error = e; - } - - expect(error.message).toContain( - `Unable to upload file(s) to Azure Blob Storage.`, - ); - - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining( - `Unable to upload file(s) to Azure Blob Storage. Error: Upload failed for ${path.join( - entityRootDir, - 'index.html', - )} with status code 500`, - ), - ); - - mockFs.restore(); - }); - - describe('fetchTechDocsMetadata', () => { - it('should return tech docs metadata', async () => { - const entityNameMock = createMockEntityName(); - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); - - mockFs({ - [entityRootDir]: { - 'techdocs_metadata.json': - '{"site_name": "backstage", "site_description": "site_content", "etag": "etag"}', - }, - }); - const expectedMetadata: TechDocsMetadata = { - site_name: 'backstage', - site_description: 'site_content', - etag: 'etag', - }; - expect( - await publisher.fetchTechDocsMetadata(entityNameMock), - ).toStrictEqual(expectedMetadata); - mockFs.restore(); - }); - - it('should return tech docs metadata when json encoded with single quotes', async () => { - const entityNameMock = createMockEntityName(); - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); - - mockFs({ - [entityRootDir]: { - 'techdocs_metadata.json': `{'site_name': 'backstage', 'site_description': 'site_content', 'etag': 'etag'}`, - }, - }); - - const expectedMetadata: TechDocsMetadata = { - site_name: 'backstage', - site_description: 'site_content', - etag: 'etag', - }; - expect( - await publisher.fetchTechDocsMetadata(entityNameMock), - ).toStrictEqual(expectedMetadata); - mockFs.restore(); - }); - - it('should return an error if the techdocs_metadata.json file is not present', async () => { - const entityNameMock = createMockEntityName(); - - let error; - try { - await publisher.fetchTechDocsMetadata(entityNameMock); - } catch (e) { - error = e; - } - - expect(error.message).toEqual( - expect.stringContaining('TechDocs metadata fetch'), - ); - }); - }); -}); diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts index 155d14fbb5..6ecc2b76cc 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -26,16 +26,18 @@ import limiterFactory from 'p-limit'; import { default as path, default as platformPath } from 'path'; import { Logger } from 'winston'; import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers'; -import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; +import { + ConfigurationValidationResponse, + PublisherBase, + PublishRequest, + TechDocsMetadata, +} from './types'; // The number of batches that may be ongoing at the same time. const BATCH_CONCURRENCY = 3; export class AzureBlobStoragePublish implements PublisherBase { - static async fromConfig( - config: Config, - logger: Logger, - ): Promise { + static fromConfig(config: Config, logger: Logger): PublisherBase { let containerName = ''; try { containerName = config.getString( @@ -78,26 +80,6 @@ export class AzureBlobStoragePublish implements PublisherBase { credential, ); - try { - const response = await storageClient - .getContainerClient(containerName) - .getProperties(); - - if (response._response.status >= 400) { - throw new Error( - `Failed to retrieve metadata from ${response._response.request.url} with status code ${response._response.status}.`, - ); - } - } catch (e) { - logger.error( - `Could not retrieve metadata about the Azure Blob Storage container ${containerName}. ` + - 'Make sure that the Azure project and container exist and the access key is setup correctly ' + - 'techdocs.publisher.azureBlobStorage.credentials defined in app config has correct permissions. ' + - 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', - ); - throw new Error(`from Azure Blob Storage client library: ${e.message}`); - } - return new AzureBlobStoragePublish(storageClient, containerName, logger); } @@ -111,6 +93,37 @@ export class AzureBlobStoragePublish implements PublisherBase { this.logger = logger; } + async validateConfiguration(): Promise { + try { + const response = await this.storageClient + .getContainerClient(this.containerName) + .getProperties(); + + if (response._response.status === 200) { + return { + isValid: true, + }; + } + + if (response._response.status >= 400) { + this.logger.error( + `Failed to retrieve metadata from ${response._response.request.url} with status code ${response._response.status}.`, + ); + } + } catch (e) { + this.logger.error(`from Azure Blob Storage client library: ${e.message}`); + } + + this.logger.error( + `Could not retrieve metadata about the Azure Blob Storage container ${this.containerName}. ` + + 'Make sure that the Azure project and container exist and the access key is setup correctly ' + + 'techdocs.publisher.azureBlobStorage.credentials defined in app config has correct permissions. ' + + 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + ); + + return { isValid: false }; + } + /** * Upload all the files from the generated `directory` to the Azure Blob Storage container. * Directory structure used in the container is - entityNamespace/entityKind/entityName/index.html diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts index 07bc5c06b9..c8cbcb0fda 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts @@ -16,8 +16,8 @@ import { getVoidLogger } from '@backstage/backend-common'; import { Entity, - EntityName, ENTITY_DEFAULT_NAMESPACE, + EntityName, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import mockFs from 'mock-fs'; @@ -83,6 +83,35 @@ beforeEach(async () => { }); describe('GoogleGCSPublish', () => { + describe('validateConfiguration', () => { + it('should validate correct config', async () => { + expect(await publisher.validateConfiguration()).toEqual({ + isValid: true, + }); + }); + + it('should reject incorrect config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'googleGcs', + googleGcs: { + credentials: '{}', + bucketName: 'errorBucket', + }, + }, + }, + }); + + const errorPublisher = GoogleGCSPublish.fromConfig(mockConfig, logger); + + expect(await errorPublisher.validateConfiguration()).toEqual({ + isValid: false, + }); + }); + }); + describe('publish', () => { beforeEach(() => { const entity = createMockEntity(); diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.ts b/packages/techdocs-common/src/stages/publish/googleStorage.ts index 12ecf45b2d..047518d43f 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.ts @@ -26,13 +26,15 @@ import createLimiter from 'p-limit'; import path from 'path'; import { Logger } from 'winston'; import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers'; -import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; +import { + ConfigurationValidationResponse, + PublisherBase, + PublishRequest, + TechDocsMetadata, +} from './types'; export class GoogleGCSPublish implements PublisherBase { - static async fromConfig( - config: Config, - logger: Logger, - ): Promise { + static fromConfig(config: Config, logger: Logger): PublisherBase { let bucketName = ''; try { bucketName = config.getString('techdocs.publisher.googleGcs.bucketName'); @@ -65,21 +67,6 @@ export class GoogleGCSPublish implements PublisherBase { }), }); - // Check if the defined bucket exists. Being able to connect means the configuration is good - // and the storage client will work. - try { - await storageClient.bucket(bucketName).getMetadata(); - logger.info(`Successfully connected to the GCS bucket ${bucketName}.`); - } catch (err) { - logger.error( - `Could not retrieve metadata about the GCS bucket ${bucketName}. ` + - 'Make sure the bucket exists. Also make sure that authentication is setup either by explicitly defining ' + - 'techdocs.publisher.googleGcs.credentials in app config or by using environment variables. ' + - 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', - ); - throw new Error(err.message); - } - return new GoogleGCSPublish(storageClient, bucketName, logger); } @@ -93,6 +80,33 @@ export class GoogleGCSPublish implements PublisherBase { this.logger = logger; } + /** + * Check if the defined bucket exists. Being able to connect means the configuration is good + * and the storage client will work. + */ + async validateConfiguration(): Promise { + try { + await this.storageClient.bucket(this.bucketName).getMetadata(); + this.logger.info( + `Successfully connected to the GCS bucket ${this.bucketName}.`, + ); + + return { + isValid: true, + }; + } catch (err) { + this.logger.error( + `Could not retrieve metadata about the GCS bucket ${this.bucketName}. ` + + 'Make sure the bucket exists. Also make sure that authentication is setup either by explicitly defining ' + + 'techdocs.publisher.googleGcs.credentials in app config or by using environment variables. ' + + 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + ); + this.logger.error(`from GCS client library: ${err.message}`); + + return { isValid: false }; + } + } + /** * Upload all the files from the generated `directory` to the GCS bucket. * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html diff --git a/packages/techdocs-common/src/stages/publish/local.ts b/packages/techdocs-common/src/stages/publish/local.ts index 3088c1a695..f641c63031 100644 --- a/packages/techdocs-common/src/stages/publish/local.ts +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -25,6 +25,7 @@ import os from 'os'; import path from 'path'; import { Logger } from 'winston'; import { + ConfigurationValidationResponse, PublisherBase, PublishRequest, PublishResponse, @@ -65,6 +66,12 @@ export class LocalPublish implements PublisherBase { this.discovery = discovery; } + async validateConfiguration(): Promise { + return { + isValid: true, + }; + } + publish({ entity, directory }: PublishRequest): Promise { const entityNamespace = entity.metadata.namespace ?? 'default'; diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts index f3b788916f..67de30245b 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts @@ -13,16 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { getVoidLogger } from '@backstage/backend-common'; import { Entity, - EntityName, ENTITY_DEFAULT_NAMESPACE, + EntityName, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import mockFs from 'mock-fs'; import os from 'os'; import path from 'path'; -import * as winston from 'winston'; import { OpenStackSwiftPublish } from './openStackSwift'; import { PublisherBase, TechDocsMetadata } from './types'; @@ -59,9 +59,7 @@ const getEntityRootDir = (entity: Entity) => { return path.join(rootDir, namespace || ENTITY_DEFAULT_NAMESPACE, kind, name); }; -const logger = winston.createLogger(); -jest.spyOn(logger, 'info').mockReturnValue(logger); -jest.spyOn(logger, 'error').mockReturnValue(logger); +const logger = getVoidLogger(); let publisher: PublisherBase; @@ -89,6 +87,43 @@ beforeEach(() => { }); describe('OpenStackSwiftPublish', () => { + describe('validateConfiguration', () => { + it('should validate correct config', async () => { + expect(await publisher.validateConfiguration()).toEqual({ + isValid: true, + }); + }); + + it('should reject incorrect config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'openStackSwift', + openStackSwift: { + credentials: { + username: 'mockuser', + password: 'verystrongpass', + }, + authUrl: 'mockauthurl', + region: 'mockregion', + containerName: 'errorBucket', + }, + }, + }, + }); + + const errorPublisher = OpenStackSwiftPublish.fromConfig( + mockConfig, + logger, + ); + + expect(await errorPublisher.validateConfiguration()).toEqual({ + isValid: false, + }); + }); + }); + describe('publish', () => { beforeEach(() => { const entity = createMockEntity(); diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.ts index 0e60bc5796..ca05cf2ce5 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.ts @@ -15,16 +15,21 @@ */ import { Entity, EntityName } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; -import { storage } from 'pkgcloud'; import express from 'express'; import fs from 'fs-extra'; import JSON5 from 'json5'; import createLimiter from 'p-limit'; import path from 'path'; +import { storage } from 'pkgcloud'; import { Readable } from 'stream'; import { Logger } from 'winston'; import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers'; -import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; +import { + ConfigurationValidationResponse, + PublisherBase, + PublishRequest, + TechDocsMetadata, +} from './types'; const streamToBuffer = (stream: Readable): Promise => { return new Promise((resolve, reject) => { @@ -70,25 +75,6 @@ export class OpenStackSwiftPublish implements PublisherBase { region: openStackSwiftConfig.getString('region'), }); - // Check if the defined container exists. Being able to connect means the configuration is good - // and the storage client will work. - storageClient.getContainer(containerName, (err, container) => { - if (container) { - logger.info( - `Successfully connected to the OpenStack Swift container ${containerName}.`, - ); - } else { - logger.error( - `Could not retrieve metadata about the OpenStack Swift container ${containerName}. ` + - 'Make sure the container exists. Also make sure that authentication is setup either by ' + - 'explicitly defining credentials and region in techdocs.publisher.openStackSwift in app config or ' + - 'by using environment variables. Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', - ); - - logger.error(`from OpenStack client library: ${err.message}`); - } - }); - return new OpenStackSwiftPublish(storageClient, containerName, logger); } @@ -102,6 +88,37 @@ export class OpenStackSwiftPublish implements PublisherBase { this.logger = logger; } + /* + * Check if the defined container exists. Being able to connect means the configuration is good + * and the storage client will work. + */ + validateConfiguration(): Promise { + return new Promise(resolve => { + this.storageClient.getContainer(this.containerName, (err, container) => { + if (container) { + this.logger.info( + `Successfully connected to the OpenStack Swift container ${this.containerName}.`, + ); + resolve({ + isValid: true, + }); + } else { + this.logger.error( + `Could not retrieve metadata about the OpenStack Swift container ${this.containerName}. ` + + 'Make sure the container exists. Also make sure that authentication is setup either by ' + + 'explicitly defining credentials and region in techdocs.publisher.openStackSwift in app config or ' + + 'by using environment variables. Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + ); + + this.logger.error(`from OpenStack client library: ${err.message}`); + resolve({ + isValid: false, + }); + } + }); + }); + } + /** * Upload all the files from the generated `directory` to the OpenStack Swift container. * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html diff --git a/packages/techdocs-common/src/stages/publish/publish.ts b/packages/techdocs-common/src/stages/publish/publish.ts index f70a5f7626..909a4092ac 100644 --- a/packages/techdocs-common/src/stages/publish/publish.ts +++ b/packages/techdocs-common/src/stages/publish/publish.ts @@ -13,16 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Logger } from 'winston'; -import { Config } from '@backstage/config'; -import { PluginEndpointDiscovery } from '@backstage/backend-common'; -import { PublisherType, PublisherBase } from './types'; -import { LocalPublish } from './local'; -import { GoogleGCSPublish } from './googleStorage'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { Config } from '@backstage/config'; +import { Logger } from 'winston'; import { AwsS3Publish } from './awsS3'; import { AzureBlobStoragePublish } from './azureBlobStorage'; +import { GoogleGCSPublish } from './googleStorage'; +import { LocalPublish } from './local'; import { OpenStackSwiftPublish } from './openStackSwift'; +import { PublisherBase, PublisherType } from './types'; type factoryOptions = { logger: Logger; @@ -45,7 +45,7 @@ export class Publisher { switch (publisherType) { case 'googleGcs': logger.info('Creating Google Storage Bucket publisher for TechDocs'); - return await GoogleGCSPublish.fromConfig(config, logger); + return GoogleGCSPublish.fromConfig(config, logger); case 'awsS3': logger.info('Creating AWS S3 Bucket publisher for TechDocs'); return AwsS3Publish.fromConfig(config, logger); diff --git a/packages/techdocs-common/src/stages/publish/types.ts b/packages/techdocs-common/src/stages/publish/types.ts index 83fee55133..a4a02ae285 100644 --- a/packages/techdocs-common/src/stages/publish/types.ts +++ b/packages/techdocs-common/src/stages/publish/types.ts @@ -37,6 +37,14 @@ export type PublishResponse = { remoteUrl?: string; } | void; +/** + * Result for the validation check. + */ +export type ConfigurationValidationResponse = { + /** Tells whether the configuration is valid. */ + isValid: boolean; +}; + /** * Type to hold metadata found in techdocs_metadata.json and associated with each site * @param etag ETag of the resource used to generate the site. Usually the latest commit sha of the source repository. @@ -53,6 +61,14 @@ export type TechDocsMetadata = { * It also provides APIs to communicate with the storage service. */ export interface PublisherBase { + /** + * Check if the configuration is valid. This check tries to perform certain checks to see if the + * publisher is configured correctly and can be used to publish or read documentations. + * The different implementations might e.g. use the provided service credentials to access the + * target or check if a folder/bucket is available. + */ + validateConfiguration(): Promise; + /** * Store the generated static files onto a storage service (either local filesystem or external service). *