diff --git a/.changeset/chilly-dodos-drop.md b/.changeset/chilly-dodos-drop.md new file mode 100644 index 0000000000..2af5255e70 --- /dev/null +++ b/.changeset/chilly-dodos-drop.md @@ -0,0 +1,6 @@ +--- +'@backstage/techdocs-common': patch +'@backstage/plugin-techdocs-backend': patch +--- + +1. Added option to use Azure Blob Storage as a choice to store the static generated files for TechDocs. diff --git a/app-config.yaml b/app-config.yaml index 010c9b9710..f71649659b 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -80,7 +80,7 @@ techdocs: generators: techdocs: 'docker' # Alternatives - 'local' publisher: - type: 'local' # Alternatives - 'googleGcs' or 'awsS3'. Read documentation for using alternatives. + type: 'local' # Alternatives - 'googleGcs' or 'awsS3' or 'azureBlobStorage'. Read documentation for using alternatives. sentry: organization: my-company diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index c43d6d978d..f81f41668d 100644 --- a/docs/features/techdocs/README.md +++ b/docs/features/techdocs/README.md @@ -108,12 +108,12 @@ providers are used. | GitLab | Yes ✅ | | GitLab Enterprise | Yes ✅ | -| File Storage Provider | Support Status | -| --------------------------------- | ----------------------------------------------------------------- | -| Local Filesystem of Backstage app | Yes ✅ | -| Google Cloud Storage (GCS) | Yes ✅ | -| Amazon Web Services (AWS) S3 | Yes ✅ | -| Azure Storage | No ❌ [#3938](https://github.com/backstage/backstage/issues/3938) | +| File Storage Provider | Support Status | +| --------------------------------- | -------------- | +| Local Filesystem of Backstage app | Yes ✅ | +| Google Cloud Storage (GCS) | Yes ✅ | +| Amazon Web Services (AWS) S3 | Yes ✅ | +| Azure Blob Storage | Yes ✅ | [Reach out to us](#feedback) if you want to request more platforms. diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index 1580abe69e..bb44050dcf 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -44,7 +44,7 @@ techdocs: # or you want to use External storage providers like Google Cloud Storage, AWS S3, etc. publisher: - # techdocs.publisher.type can be - 'local' or 'googleGcs' or 'awsS3' (azureStorage to be available in future). + # techdocs.publisher.type can be - 'local' or 'googleGcs' or 'awsS3' or 'azureBlobStorage'. # When set to 'local', techdocs-backend will create a 'static' directory at its root to store generated documentation files. # When set to 'googleGcs', techdocs-backend will use a Google Cloud Storage Bucket to store generated documentation files. # When set to 'awsS3', techdocs-backend will use an Amazon Web Service (AWS) S3 bucket to store generated documentation files. @@ -84,4 +84,21 @@ techdocs: # https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html region: $env: AWS_REGION + + # Required when techdocs.publisher.type is set to 'azureBlobStorage'. Skip otherwise. + + azureBlobStorage: + # (Required) Azure Blob Storage Container Name + containerName: 'techdocs-storage' + + # (Required) An account name is required to write to a storage blob container. + # https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key + credentials: + accountName: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME + # (Optional) An account key is required to write to a storage container. + # If missing,AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET environment variable will be used. + # https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json + accountKey: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY ``` diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index 21206dede5..de2d505c87 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -195,3 +195,94 @@ Your Backstage app is now ready to use AWS S3 for TechDocs, to store and read the static generated documentation files. When you start the backend of the app, you should be able to see `techdocs info Successfully connected to the AWS S3 bucket` in the logs. + +## Configuring Azure Blob Storage Container with TechDocs + +Follow the +[official Azure Blob Storage documentation](https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json) +for the latest instructions on the following steps involving Azure Blob Storage. + +**1. Set `techdocs.publisher.type` config in your `app-config.yaml`** + +Set `techdocs.publisher.type` to `'azureBlobStorage'`. + +```yaml +techdocs: + publisher: + type: 'azureBlobStorage' +``` + +**2. Create an Azure Blob Storage Container** + +Create a dedicated container for TechDocs sites. +[Refer to the official documentation](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-quickstart-blobs-portal). + +TechDocs will publish documentation to this container and will fetch files from +here to serve documentation in Backstage. Note that the container names are +globally unique. + +Set the config `techdocs.publisher.azureBlobStorage.containerName` in your +`app-config.yaml` to the name of the container you just created. + +```yaml +techdocs: + publisher: + type: 'azureBlobStorage' + azureBlobStorage: + containerName: 'name-of-techdocs-storage-container' +``` + +**3a. (Recommended) Authentication using environment variable** + +Set the config `techdocs.publisher.azureBlobStorage.credentials.accountName` in +your `app-config.yaml` to the your account name. + +The storage blob client will automatically use the environment variable +`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` to authenticate with +Azure Blob Storage. +[Steps to create the service where the variables can be retrieved from](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal). + +https://docs.microsoft.com/en-us/azure/storage/common/storage-auth-aad for more +details. + +```yaml +techdocs: + publisher: + type: 'azureBlobStorage' + azureBlobStorage: + containerName: 'name-of-techdocs-storage-bucket' + credentials: + accountName: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME +``` + +**3b. Authentication using app-config.yaml** + +If you do not prefer (3a) and optionally like to use a service account, you can +follow these steps. + +To get credentials, access the Azure Portal and go to "Settings > Access Keys", +and get your Storage account name and Primary Key. +https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key +for more details. + +```yaml +techdocs: + publisher: + type: 'azureBlobStorage' + azureBlobStorage: + containerName: 'name-of-techdocs-storage-bucket' + credentials: + accountName: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME + accountKey: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY +``` + +**4. That's it!** + +Your Backstage app is now ready to use Azure Blob Storage for TechDocs, to store +and read the static generated documentation files. When you start the backend of +the app, you should be able to see +`techdocs info Successfully connected to the Azure Blob Storage container` in +the logs. diff --git a/packages/techdocs-common/__mocks__/@azure/identity.ts b/packages/techdocs-common/__mocks__/@azure/identity.ts new file mode 100644 index 0000000000..cc89a4d514 --- /dev/null +++ b/packages/techdocs-common/__mocks__/@azure/identity.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 class DefaultAzureCredential { + /** + * Creates an instance of the DefaultAzureCredential class. + */ +} diff --git a/packages/techdocs-common/__mocks__/@azure/storage-blob.ts b/packages/techdocs-common/__mocks__/@azure/storage-blob.ts new file mode 100644 index 0000000000..60a705f6a7 --- /dev/null +++ b/packages/techdocs-common/__mocks__/@azure/storage-blob.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 fs from 'fs'; + +export class BlockBlobClient { + private readonly blobName; + + constructor(blobName: string) { + this.blobName = blobName; + } + + uploadFile(source: string) { + return new Promise((resolve, reject) => { + if (!fs.existsSync(source)) { + reject(''); + } else { + resolve(''); + } + }); + } + + exists() { + return new Promise((resolve, reject) => { + if (fs.existsSync(this.blobName)) { + resolve(true); + } else { + reject({ message: 'The object doest not exist !' }); + } + }); + } +} + +export class ContainerClient { + private readonly containerName; + + constructor(containerName: string) { + this.containerName = containerName; + } + + getProperties() { + return new Promise(resolve => { + resolve(''); + }); + } + + getBlockBlobClient(blobName: string) { + return new BlockBlobClient(blobName); + } +} + +export class BlobServiceClient { + private readonly url; + private readonly credential; + + constructor(url: string, credential?: StorageSharedKeyCredential) { + this.url = url; + this.credential = credential; + } + + getContainerClient(containerName: string) { + return new ContainerClient(containerName); + } +} + +export class StorageSharedKeyCredential { + private readonly accountName; + private readonly accountKey; + + constructor(accountName: string, accountKey: string) { + this.accountName = accountName; + this.accountKey = accountKey; + } +} diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index 64c5c19425..2a065600a1 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -37,6 +37,8 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1.0", + "@azure/identity": "^1.2.2", + "@azure/storage-blob": "^12.4.0", "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts new file mode 100644 index 0000000000..9d2ee1ba9b --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -0,0 +1,149 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 mockFs from 'mock-fs'; +import { ConfigReader } from '@backstage/config'; +import { getVoidLogger } from '@backstage/backend-common'; +import { AzureBlobStoragePublish } from './azureBlobStorage'; +import { PublisherBase } from './types'; +import type { Entity } from '@backstage/catalog-model'; + +const createMockEntity = (annotations = {}) => { + return { + apiVersion: 'version', + kind: 'TestKind', + metadata: { + name: 'test-component-name', + namespace: 'test-namespace', + annotations: { + ...annotations, + }, + }, + }; +}; + +const getEntityRootDir = (entity: Entity) => { + const { + kind, + metadata: { namespace, name }, + } = entity; + const entityRootDir = `${namespace}/${kind}/${name}`; + return entityRootDir; +}; + +const logger = getVoidLogger(); +jest.spyOn(logger, 'info').mockReturnValue(logger); +jest.spyOn(logger, 'error').mockReturnValue(logger); + +let publisher: PublisherBase; + +beforeEach(async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'accountName', + accountKey: 'accountKey', + }, + containerName: 'containerName', + }, + }, + }, + }); + + publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger); +}); + +describe('AzureBlobStoragePublish', () => { + describe('publish', () => { + it('should publish a directory', async () => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + '404.html': '', + assets: { + 'main.css': '', + }, + }, + }); + + expect( + await publisher.publish({ + entity, + directory: entityRootDir, + }), + ).toBeUndefined(); + mockFs.restore(); + }); + + it('should fail to publish a directory', async () => { + const wrongPathToGeneratedDirectory = 'wrong/path/to/generatedDirectory'; + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + '404.html': '', + assets: { + 'main.css': '', + }, + }, + }); + + await publisher + .publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }) + .catch(error => + expect(error).toEqual( + new Error( + `Unable to upload file(s) to Azure Blob Storage. Error Failed to read template directory: ENOENT, no such file or directory '${wrongPathToGeneratedDirectory}'`, + ), + ), + ); + mockFs.restore(); + }); + }); + + describe('hasDocsBeenGenerated', () => { + it('should return true if docs has been generated', async () => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': 'file-content', + }, + }); + + expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true); + mockFs.restore(); + }); + + it('should return false if docs has not been generated', async () => { + const entity = createMockEntity(); + + expect(await publisher.hasDocsBeenGenerated(entity)).toBe(false); + }); + }); +}); diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts new file mode 100644 index 0000000000..1b4e59a714 --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -0,0 +1,259 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 path from 'path'; +import express from 'express'; +import { + BlobServiceClient, + BlobUploadCommonResponse, + StorageSharedKeyCredential, +} from '@azure/storage-blob'; +import { DefaultAzureCredential } from '@azure/identity'; +import { Logger } from 'winston'; +import { Entity, EntityName } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { getHeadersForFileExtension, getFileTreeRecursively } from './helpers'; +import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; +import limiterFactory from 'p-limit'; +import JSON5 from 'json5'; + +// 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 { + let containerName = ''; + try { + containerName = config.getString( + 'techdocs.publisher.azureBlobStorage.containerName', + ); + } catch (error) { + throw new Error( + "Since techdocs.publisher.type is set to 'azureBlobStorage' in your app config, " + + 'techdocs.publisher.azureBlobStorage.containerName is required.', + ); + } + + let accountName = ''; + try { + accountName = config.getString( + 'techdocs.publisher.azureBlobStorage.credentials.accountName', + ); + } catch (error) { + throw new Error( + "Since techdocs.publisher.type is set to 'azureBlobStorage' in your app config, " + + 'techdocs.publisher.azureBlobStorage.credentials.accountName is required.', + ); + } + + // Credentials is an optional config. If missing, default Azure Blob Storage environment variables will be used. + // https://docs.microsoft.com/en-us/azure/storage/common/storage-auth-aad-app + const accountKey = config.getOptionalString( + 'techdocs.publisher.azureBlobStorage.credentials.accountKey', + ); + + let credential; + if (accountKey) { + credential = new StorageSharedKeyCredential(accountName, accountKey); + } else { + credential = new DefaultAzureCredential(); + } + + const storageClient = new BlobServiceClient( + `https://${accountName}.blob.core.windows.net`, + credential, + ); + + await storageClient + .getContainerClient(containerName) + .getProperties() + .then(() => { + logger.info( + `Successfully connected to the Azure Blob Storage container ${containerName}.`, + ); + }) + .catch(reason => { + 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: ${reason.message}`, + ); + }); + + return new AzureBlobStoragePublish(storageClient, containerName, logger); + } + + constructor( + private readonly storageClient: BlobServiceClient, + private readonly containerName: string, + private readonly logger: Logger, + ) { + this.storageClient = storageClient; + this.containerName = containerName; + this.logger = logger; + } + + /** + * 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 + */ + async publish({ entity, directory }: PublishRequest): Promise { + try { + // Note: Azure Blob Storage manages creation of parent directories if they do not exist. + // So collecting path of only the files is good enough. + const allFilesToUpload = await getFileTreeRecursively(directory); + + const uploadPromises: Array> = []; + + // Bound the number of concurrent batches. We want a bit of concurrency for + // performance reasons, but not so much that we starve the connection pool + // or start thrashing. + const limiter = limiterFactory(BATCH_CONCURRENCY); + + const promises = allFilesToUpload.map(filePath => { + // Remove the absolute path prefix of the source directory + // Path of all files to upload, relative to the root of the source directory + // e.g. ['index.html', 'sub-page/index.html', 'assets/images/favicon.png'] + const relativeFilePath = filePath.replace(`${directory}/`, ''); + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + const destination = path.normalize( + `${entityRootDir}/${relativeFilePath}`, + ); // Azure Blob Storage Container file relative path + + return limiter(async () => { + await uploadPromises.push( + this.storageClient + .getContainerClient(this.containerName) + .getBlockBlobClient(destination) + .uploadFile(filePath), + ); + }); + }); + + await Promise.all(promises).then(() => { + this.logger.info( + `Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`, + ); + }); + return; + } catch (e) { + const errorMessage = `Unable to upload file(s) to Azure Blob Storage. Error ${e.message}`; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + } + + private download(containerName: string, path: string): Promise { + return new Promise((resolve, reject) => { + const fileStreamChunks: Array = []; + this.storageClient + .getContainerClient(containerName) + .getBlockBlobClient(path) + .download() + .then(res => { + const body = res.readableStreamBody; + if (!body) { + reject(new Error(`Unable to parse the response data`)); + return; + } + body + .on('error', e => { + this.logger.error(e.message); + reject(e.message); + }) + .on('data', chunk => { + fileStreamChunks.push(chunk); + }) + .on('end', () => { + resolve(Buffer.concat(fileStreamChunks)); + }); + }); + }); + } + + async fetchTechDocsMetadata( + entityName: EntityName, + ): Promise { + const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; + try { + return await new Promise(resolve => { + const download = this.download( + this.containerName, + `${entityRootDir}/techdocs_metadata.json`, + ); + resolve(JSON5.parse(download.toString())); + }); + } catch (e) { + this.logger.error(e.message); + throw e; + } + } + + /** + * Express route middleware to serve static files on a route in techdocs-backend. + */ + docsRouter(): express.Handler { + return (req, res) => { + // Trim the leading forward slash + // filePath example - /default/Component/documented-component/index.html + const filePath = req.path.replace(/^\//, ''); + // Files with different extensions (CSS, HTML) need to be served with different headers + const fileExtension = path.extname(filePath); + const responseHeaders = getHeadersForFileExtension(fileExtension); + + try { + this.download(this.containerName, filePath).then(fileContent => { + // Inject response headers + for (const [headerKey, headerValue] of Object.entries( + responseHeaders, + )) { + res.setHeader(headerKey, headerValue); + } + res.send(fileContent); + }); + } catch (e) { + this.logger.error(e.message); + res.status(404).send(e.message); + } + }; + } + + /** + * A helper function which checks if index.html of an Entity's docs site is available. This + * can be used to verify if there are any pre-generated docs available to serve. + */ + async hasDocsBeenGenerated(entity: Entity): Promise { + return new Promise(resolve => { + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + this.storageClient + .getContainerClient(this.containerName) + .getBlockBlobClient(`${entityRootDir}/index.html`) + .exists() + .then((response: boolean) => { + resolve(response); + }) + .catch(() => { + resolve(false); + }); + }); + } +} diff --git a/packages/techdocs-common/src/stages/publish/publish.test.ts b/packages/techdocs-common/src/stages/publish/publish.test.ts index 89f2c0ffdd..d1faf82b78 100644 --- a/packages/techdocs-common/src/stages/publish/publish.test.ts +++ b/packages/techdocs-common/src/stages/publish/publish.test.ts @@ -22,6 +22,7 @@ import { Publisher } from './publish'; import { LocalPublish } from './local'; import { GoogleGCSPublish } from './googleStorage'; import { AwsS3Publish } from './awsS3'; +import { AzureBlobStoragePublish } from './azureBlobStorage'; const logger = getVoidLogger(); const discovery: jest.Mocked = { @@ -30,6 +31,10 @@ const discovery: jest.Mocked = { }; describe('Publisher', () => { + beforeEach(() => { + jest.resetModules(); // clear the cache + }); + it('should create local publisher by default', async () => { const mockConfig = new ConfigReader({ techdocs: { @@ -105,4 +110,55 @@ describe('Publisher', () => { }); expect(publisher).toBeInstanceOf(AwsS3Publish); }); + + it('should create Azure Blob Storage publisher from config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'accountName', + accountKey: 'accountKey', + }, + containerName: 'containerName', + }, + }, + }, + }); + + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); + expect(publisher).toBeInstanceOf(AzureBlobStoragePublish); + }); + + it('should create Azure Blob Storage publisher from environment variables', async () => { + process.env.AZURE_TENANT_ID = 'AZURE_TENANT_ID'; + process.env.AZURE_CLIENT_ID = 'AZURE_CLIENT_ID'; + process.env.AZURE_CLIENT_SECRET = 'AZURE_CLIENT_SECRET'; + + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'accountName', + }, + containerName: 'containerName', + }, + }, + }, + }); + + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); + expect(publisher).toBeInstanceOf(AzureBlobStoragePublish); + }); }); diff --git a/packages/techdocs-common/src/stages/publish/publish.ts b/packages/techdocs-common/src/stages/publish/publish.ts index 82232c2fd1..a4e33a2d9c 100644 --- a/packages/techdocs-common/src/stages/publish/publish.ts +++ b/packages/techdocs-common/src/stages/publish/publish.ts @@ -21,6 +21,7 @@ import { PublisherType, PublisherBase } from './types'; import { LocalPublish } from './local'; import { GoogleGCSPublish } from './googleStorage'; import { AwsS3Publish } from './awsS3'; +import { AzureBlobStoragePublish } from './azureBlobStorage'; type factoryOptions = { logger: Logger; @@ -47,6 +48,11 @@ export class Publisher { case 'awsS3': logger.info('Creating AWS S3 Bucket publisher for TechDocs'); return AwsS3Publish.fromConfig(config, logger); + case 'azureBlobStorage': + logger.info( + 'Creating Azure Blob Storage Container publisher for TechDocs', + ); + return AzureBlobStoragePublish.fromConfig(config, logger); case 'local': logger.info('Creating Local publisher for TechDocs'); return new LocalPublish(config, logger, discovery); diff --git a/packages/techdocs-common/src/stages/publish/types.ts b/packages/techdocs-common/src/stages/publish/types.ts index db6a075d43..5e953deb81 100644 --- a/packages/techdocs-common/src/stages/publish/types.ts +++ b/packages/techdocs-common/src/stages/publish/types.ts @@ -19,7 +19,11 @@ import express from 'express'; /** * Key for all the different types of TechDocs publishers that are supported. */ -export type PublisherType = 'local' | 'googleGcs' | 'awsS3'; +export type PublisherType = + | 'local' + | 'googleGcs' + | 'awsS3' + | 'azureBlobStorage'; export type PublishRequest = { entity: Entity; diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 5ad23cde84..dc5e6a4911 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -148,6 +148,7 @@ export async function createRouter({ } break; case 'awsS3': + case 'azureBlobStorage': case 'googleGcs': // This block should be valid for all external storage implementations. So no need to duplicate in future, // add the publisher type in the list here. diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts index f9831f27cb..0af3ab4290 100644 --- a/plugins/techdocs/config.d.ts +++ b/plugins/techdocs/config.d.ts @@ -114,6 +114,47 @@ export interface Config { region?: string; }; } + | { + /** + * attr: 'type' - accepts a string value + * e.g. type: 'azureBlobStorage' + * alternatives: 'azureBlobStorage' etc. + * @see http://backstage.io/docs/features/techdocs/configuration + */ + type: 'azureBlobStorage'; + + /** + * azureBlobStorage required when 'type' is set to azureBlobStorage + */ + azureBlobStorage?: { + /** + * (Required) Credentials used to access a storage container. + * @visibility secret + */ + credentials: { + /** + * Account access name + * attr: 'account' - accepts a string value + * @visibility secret + */ + accountName: string; + /** + * (Optional) Account secret primary key + * If not set, environment variables will be used to authenticate. + * https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json + * attr: 'accountKey' - accepts a string value + * @visibility secret + */ + accountKey?: string; + }; + /** + * (Required) Cloud Storage Container Name + * attr: 'containerName' - accepts a string value + * @visibility backend + */ + containerName: string; + }; + } | { /** * attr: 'type' - accepts a string value