Merge pull request #3794 from ayshiff/feature/techdocs-aws-s3

This commit is contained in:
Himanshu Mishra
2021-01-05 17:48:57 +01:00
committed by GitHub
15 changed files with 972 additions and 51 deletions
@@ -0,0 +1,197 @@
/*
* 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 * as winston from 'winston';
import { ConfigReader } from '@backstage/config';
import { AwsS3Publish } from './awsS3';
import { PublisherBase } from './types';
const createMockEntity = (annotations = {}) => {
return {
apiVersion: 'version',
kind: 'TestKind',
metadata: {
name: 'test-component-name',
namespace: 'test-namespace',
annotations: {
...annotations,
},
},
};
};
const logger = winston.createLogger();
jest.spyOn(logger, 'info').mockReturnValue(logger);
jest.spyOn(logger, 'error').mockReturnValue(logger);
let publisher: PublisherBase;
beforeEach(() => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7000',
publisher: {
type: 'awsS3',
awsS3: {
credentials: {
accessKeyId: 'accessKeyId',
secretAccessKey: 'secretAccessKey',
},
bucketName: 'bucketName',
},
},
},
});
publisher = AwsS3Publish.fromConfig(mockConfig, logger);
});
describe('AwsS3Publish', () => {
describe('publish', () => {
it('should publish a directory', async () => {
const entity = createMockEntity();
const {
kind,
metadata: { namespace, name },
} = entity;
mockFs({
[`${namespace}/${kind}/${name}`]: {
'index.html': '',
'404.html': '',
assets: {
'main.css': '',
},
},
});
expect(
await publisher.publish({
entity,
directory: `${namespace}/${kind}/${name}`,
}),
).toBeUndefined();
mockFs.restore();
});
it('should fail to publish a directory', async () => {
const wrongPathToGeneratedDirectory = '/wrong/path/to/generatedDirectory';
const entity = createMockEntity();
const {
kind,
metadata: { namespace, name },
} = entity;
mockFs({
[`${namespace}/${kind}/${name}`]: {
'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 AWS S3. 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 entityMock = {
apiVersion: 'apiVersion',
kind: 'kind',
metadata: {
namespace: '/namespace',
name: 'name',
},
};
const entityRootDir = `${entityMock.metadata.namespace}/${entityMock.kind}/${entityMock.metadata.name}`;
mockFs({
[entityRootDir]: {
'index.html': 'file-content',
},
});
expect(await publisher.hasDocsBeenGenerated(entityMock)).toBe(true);
mockFs.restore();
});
it('should return false if docs has not been generated', async () => {
const entityMock = {
apiVersion: 'apiVersion',
kind: 'kind',
metadata: {
namespace: 'namespace',
name: 'name',
},
};
expect(await publisher.hasDocsBeenGenerated(entityMock)).toBe(false);
});
});
describe('fetchTechDocsMetadata', () => {
it('should return tech docs metadata', async () => {
const entityNameMock = {
name: 'name',
namespace: '/namespace',
kind: 'kind',
};
const entityRootDir = `${entityNameMock.namespace}/${entityNameMock.kind}/${entityNameMock.name}`;
mockFs({
[entityRootDir]: {
'techdocs_metadata.json': 'file-content',
},
});
expect(await publisher.fetchTechDocsMetadata(entityNameMock)).toBe(
'file-content',
);
mockFs.restore();
});
it('should return an error if the techdocs_metadata.json file is not present', async () => {
const entityNameMock = {
name: 'name',
namespace: 'namespace',
kind: 'kind',
};
const entityRootDir = `${entityNameMock.namespace}/${entityNameMock.kind}/${entityNameMock.name}`;
await publisher
.fetchTechDocsMetadata(entityNameMock)
.catch(error =>
expect(error).toEqual(
new Error(
`TechDocs metadata fetch failed, The file ${entityRootDir}/techdocs_metadata.json doest not exist !`,
),
),
);
});
});
});
@@ -0,0 +1,220 @@
/*
* 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 aws from 'aws-sdk';
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';
import { ManagedUpload } from 'aws-sdk/clients/s3';
import fs from 'fs-extra';
export class AwsS3Publish implements PublisherBase {
static fromConfig(config: Config, logger: Logger): PublisherBase {
let region = null;
let accessKeyId = null;
let secretAccessKey = null;
let bucketName = '';
try {
accessKeyId = config.getString(
'techdocs.publisher.awsS3.credentials.accessKeyId',
);
secretAccessKey = config.getString(
'techdocs.publisher.awsS3.credentials.secretAccessKey',
);
region = config.getOptionalString('techdocs.publisher.awsS3.region');
bucketName = config.getString('techdocs.publisher.awsS3.bucketName');
} catch (error) {
throw new Error(
"Since techdocs.publisher.type is set to 'awsS3' in your app config, " +
'credentials and bucketName are required in techdocs.publisher.awsS3 ' +
'required to authenticate with AWS S3.',
);
}
const storageClient = new aws.S3({
credentials: { accessKeyId, secretAccessKey },
...(region && { region }),
});
// 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 AWS project and the bucket exists and the access key located at the path ' +
"techdocs.publisher.awsS3.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 AWS client library: ${err.message}`);
} else {
logger.info(
`Successfully connected to the AWS S3 bucket ${bucketName}.`,
);
}
},
);
return new AwsS3Publish(storageClient, bucketName, logger);
}
constructor(
private readonly storageClient: aws.S3,
private readonly bucketName: string,
private readonly logger: Logger,
) {
this.storageClient = storageClient;
this.bucketName = bucketName;
this.logger = logger;
}
/**
* Upload all the files from the generated `directory` to the S3 bucket.
* Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html
*/
async publish({ entity, directory }: PublishRequest): Promise<void> {
try {
// Note: S3 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<ManagedUpload.SendData>> = [];
for (const filePath of allFilesToUpload) {
// 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 = `${entityRootDir}/${relativeFilePath}`; // S3 Bucket file relative path
const fileContent = await fs.readFile(filePath, 'utf8');
const params = {
Bucket: this.bucketName,
Key: destination,
Body: fileContent,
};
uploadPromises.push(this.storageClient.upload(params).promise());
}
await Promise.all(uploadPromises);
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 AWS S3. Error ${e.message}`;
this.logger.error(errorMessage);
throw new Error(errorMessage);
}
}
async fetchTechDocsMetadata(entityName: EntityName): Promise<string> {
try {
return await new Promise<string>((resolve, reject) => {
const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`;
const fileStreamChunks: Array<any> = [];
this.storageClient
.getObject({
Bucket: this.bucketName,
Key: `${entityRootDir}/techdocs_metadata.json`,
})
.createReadStream()
.on('error', err => {
this.logger.error(err.message);
reject(new Error(err.message));
})
.on('data', chunk => {
fileStreamChunks.push(chunk);
})
.on('end', () => {
const techdocsMetadataJson = Buffer.concat(
fileStreamChunks,
).toString();
resolve(techdocsMetadataJson);
});
});
} catch (e) {
throw new Error(`TechDocs metadata fetch failed, ${e.message}`);
}
}
/**
* 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);
const fileStreamChunks: Array<any> = [];
this.storageClient
.getObject({ Bucket: this.bucketName, Key: filePath })
.createReadStream()
.on('error', err => {
this.logger.warn(err.message);
res.status(404).send(err.message);
})
.on('data', chunk => {
fileStreamChunks.push(chunk);
})
.on('end', () => {
const fileContent = Buffer.concat(fileStreamChunks).toString();
// Inject response headers
for (const [headerKey, headerValue] of Object.entries(
responseHeaders,
)) {
res.setHeader(headerKey, headerValue);
}
res.send(fileContent);
});
};
}
/**
* 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> {
try {
const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
await this.storageClient
.headObject({
Bucket: this.bucketName,
Key: `${entityRootDir}/index.html`,
})
.promise();
return Promise.resolve(true);
} catch (e) {
return Promise.resolve(false);
}
}
}
@@ -21,6 +21,7 @@ import { ConfigReader } from '@backstage/config';
import { Publisher } from './publish';
import { LocalPublish } from './local';
import { GoogleGCSPublish } from './googleStorage';
import { AwsS3Publish } from './awsS3';
const logger = getVoidLogger();
const discovery: jest.Mocked<PluginEndpointDiscovery> = {
@@ -81,4 +82,28 @@ describe('Publisher', () => {
});
expect(publisher).toBeInstanceOf(GoogleGCSPublish);
});
it('should create AWS S3 publisher from config', async () => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7000',
publisher: {
type: 'awsS3',
awsS3: {
credentials: {
accessKeyId: 'accessKeyId',
secretAccessKey: 'secretAccessKey',
},
bucketName: 'bucketName',
},
},
},
});
const publisher = await Publisher.fromConfig(mockConfig, {
logger,
discovery,
});
expect(publisher).toBeInstanceOf(AwsS3Publish);
});
});
@@ -20,6 +20,7 @@ import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { PublisherType, PublisherBase } from './types';
import { LocalPublish } from './local';
import { GoogleGCSPublish } from './googleStorage';
import { AwsS3Publish } from './awsS3';
type factoryOptions = {
logger: Logger;
@@ -43,6 +44,9 @@ export class Publisher {
case 'googleGcs':
logger.info('Creating Google Storage Bucket publisher for TechDocs');
return GoogleGCSPublish.fromConfig(config, logger);
case 'awsS3':
logger.info('Creating AWS S3 Bucket publisher for TechDocs');
return AwsS3Publish.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';
export type PublisherType = 'local' | 'googleGcs' | 'awsS3';
export type PublishRequest = {
entity: Entity;