feat(techdocs-common): add Azure Storage

This commit is contained in:
vitorgrenzel
2021-01-13 09:20:58 -03:00
committed by Tiago A. Simões
parent 64e35f7d30
commit c777df180a
14 changed files with 616 additions and 8 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/techdocs-common': patch
'@backstage/plugin-techdocs-backend': patch
---
1. Added option to use Azure Storage as a choice to store the static generated files for TechDocs.
+1 -1
View File
@@ -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 'azureStorage'. Read documentation for using alternatives.
sentry:
organization: my-company
+6 -6
View File
@@ -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 Storage | Yes ✅ |
[Reach out to us](#feedback) if you want to request more platforms.
+13
View File
@@ -84,4 +84,17 @@ 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 'azureStorage'. Skip otherwise.
azureStorage:
# An API key is required to write to a storage container.
credentials:
account:
$env: TECHDOCS_AZURE_STORAGE_ACCOUNT
accountKey:
$env: TECHDOCS_AZURE_STORAGE_ACCOUNT_KEY
# Azure Storage Container Name
containerName: 'techdocs-storage'
```
@@ -195,3 +195,64 @@ 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 Storage Container with TechDocs
Follow the
[official Azure Storage documentation](https://docs.microsoft.com/pt-br/javascript/api/@azure/storage-blob/?view=azure-node-latest)
for the latest instructions on the following steps involving Azure Storage.
**1. Set `techdocs.publisher.type` config in your `app-config.yaml`**
Set `techdocs.publisher.type` to `'azureStorage'`.
```yaml
techdocs:
publisher:
type: 'azureStorage'
```
**2. Service account credentials**
To get credentials, access the Azure Portal and go to "Settings > Access Keys",
and get your Storage account name and Primary Key.
```yaml
techdocs:
publisher:
type: 'azureStorage'
azureStorage:
credentials:
account: 'account'
accountKey: 'accountKey'
```
**3. Azure Storage Container**
Create a dedicated container for TechDocs sites. techdocs-backend will publish
documentation to this container. TechDocs will fetch files from here to serve
documentation in Backstage.
To create a new container, access "Blob Service > Containers > New Container".
Set the name of the container to
`techdocs.publisher.azureStorage.containerName`.
```yaml
techdocs:
publisher:
type: 'azureStorage'
azureStorage:
credentials:
account: 'account'
accountKey: 'accountKey'
containerName: 'name-of-techdocs-storage-container'
```
**4. That's it!**
Your Backstage app is now ready to use Azure 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 Storage container` in the
logs.
@@ -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;
}
}
+1
View File
@@ -36,6 +36,7 @@
"url": "https://github.com/backstage/backstage/issues"
},
"dependencies": {
"@azure/storage-blob": "^12.3.0",
"@aws-sdk/client-s3": "^3.1.0",
"@backstage/backend-common": "^0.5.1",
"@backstage/catalog-model": "^0.7.0",
@@ -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 { AzureStoragePublish } from './azureStorage';
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: 'azureStorage',
azureStorage: {
credentials: {
account: 'account',
accountKey: 'accountKey',
},
containerName: 'containerName',
},
},
},
});
publisher = await AzureStoragePublish.fromConfig(mockConfig, logger);
});
describe('AzureStoragePublish', () => {
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 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);
});
});
});
@@ -0,0 +1,223 @@
/*
* 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 { Logger } from 'winston';
import { Entity, EntityName } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { getHeadersForFileExtension, getFileTreeRecursively } from './helpers';
import { PublisherBase, PublishRequest } from './types';
export class AzureStoragePublish implements PublisherBase {
static async fromConfig(
config: Config,
logger: Logger,
): Promise<PublisherBase> {
let account = '';
let accountKey = '';
let containerName = '';
try {
account = config.getString(
'techdocs.publisher.azureStorage.credentials.account',
);
accountKey = config.getString(
'techdocs.publisher.azureStorage.credentials.accountKey',
);
containerName = config.getString(
'techdocs.publisher.azureStorage.containerName',
);
} catch (error) {
throw new Error(
"Since techdocs.publisher.type is set to 'azureStorage' in your app config, " +
'credentials and containerName are required in techdocs.publisher.azureStorage ' +
'required to authenticate with Azure Storage.',
);
}
const credential = new StorageSharedKeyCredential(account, accountKey);
const storageClient = new BlobServiceClient(
`https://${account}.blob.core.windows.net`,
credential,
);
await storageClient
.getContainerClient(containerName)
.getProperties()
.then(() => {
logger.info(
`Successfully connected to the Azure Storage container ${containerName}.`,
);
})
.catch(reason => {
logger.error(
`Could not retrieve metadata about the Azure Storage container ${containerName}. ` +
'Make sure the Azure project and the container exists and the access key located at the path ' +
"techdocs.publisher.azureStorage.credentials defined in app config has the role 'Storage Object Creator'. " +
'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage',
);
throw new Error(`from Azure Storage client library: ${reason.message}`);
});
return new AzureStoragePublish(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 Storage container.
* Directory structure used in the container is - entityNamespace/entityKind/entityName/index.html
*/
async publish({ entity, directory }: PublishRequest): Promise<void> {
try {
// Note: Azure 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<Promise<BlobUploadCommonResponse>> = [];
allFilesToUpload.forEach(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 Storage Container file relative path
// TODO: Upload in chunks of ~10 files instead of all files at once.
uploadPromises.push(
this.storageClient
.getContainerClient(this.containerName)
.getBlockBlobClient(destination)
.uploadFile(filePath),
);
});
await Promise.all(uploadPromises).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 Storage. Error ${e.message}`;
this.logger.error(errorMessage);
throw new Error(errorMessage);
}
}
download(containerName: string, path: string): Promise<string> {
return new Promise((resolve, reject) => {
const fileStreamChunks: Array<any> = [];
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).toString());
});
});
});
}
async fetchTechDocsMetadata(entityName: EntityName): Promise<string> {
const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`;
try {
return this.download(
this.containerName,
`${entityRootDir}/techdocs_metadata.json`,
);
} 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<boolean> {
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);
});
});
}
}
@@ -22,6 +22,7 @@ import { Publisher } from './publish';
import { LocalPublish } from './local';
import { GoogleGCSPublish } from './googleStorage';
import { AwsS3Publish } from './awsS3';
import { AzureStoragePublish } from './azureStorage';
const logger = getVoidLogger();
const discovery: jest.Mocked<PluginEndpointDiscovery> = {
@@ -105,4 +106,28 @@ describe('Publisher', () => {
});
expect(publisher).toBeInstanceOf(AwsS3Publish);
});
it('should create Azure Storage publisher from config', async () => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7000',
publisher: {
type: 'azureStorage',
azureStorage: {
credentials: {
account: 'account',
accountKey: 'accountKey',
},
containerName: 'containerName',
},
},
},
});
const publisher = await Publisher.fromConfig(mockConfig, {
logger,
discovery,
});
expect(publisher).toBeInstanceOf(AzureStoragePublish);
});
});
@@ -21,6 +21,7 @@ import { PublisherType, PublisherBase } from './types';
import { LocalPublish } from './local';
import { GoogleGCSPublish } from './googleStorage';
import { AwsS3Publish } from './awsS3';
import { AzureStoragePublish } from './azureStorage';
type factoryOptions = {
logger: Logger;
@@ -47,6 +48,9 @@ export class Publisher {
case 'awsS3':
logger.info('Creating AWS S3 Bucket publisher for TechDocs');
return AwsS3Publish.fromConfig(config, logger);
case 'azureStorage':
logger.info('Creating Azure Storage Container publisher for TechDocs');
return AzureStoragePublish.fromConfig(config, logger);
case 'local':
logger.info('Creating Local publisher for TechDocs');
return new LocalPublish(config, logger, discovery);
@@ -19,7 +19,7 @@ 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' | 'azureStorage';
export type PublishRequest = {
entity: Entity;
@@ -148,6 +148,7 @@ export async function createRouter({
}
break;
case 'awsS3':
case 'azureStorage':
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.
+39
View File
@@ -114,6 +114,45 @@ export interface Config {
region?: string;
};
}
| {
/**
* attr: 'type' - accepts a string value
* e.g. type: 'azureStorage'
* alternatives: 'azureStorage' etc.
* @see http://backstage.io/docs/features/techdocs/configuration
*/
type: 'azureStorage';
/**
* azureStorage required when 'type' is set to azureStorage
*/
azureStorage?: {
/**
* Credentials used to access a storage container
* @visibility secret
*/
credentials: {
/**
* Account access name
* attr: 'account' - accepts a string value
* @visibility secret
*/
account: string;
/**
* Account secret primary key
* attr: 'accountKey' - accepts a string value
* @visibility secret
*/
accountKey: string;
};
/**
* Cloud Storage Container Name
* attr: 'containerName' - accepts a string value
* @visibility backend
*/
containerName: string;
};
}
| {
/**
* attr: 'type' - accepts a string value