Merge pull request #3794 from ayshiff/feature/techdocs-aws-s3
This commit is contained in:
@@ -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 type { S3 as S3Types } from 'aws-sdk';
|
||||
import { EventEmitter } from 'events';
|
||||
import fs from 'fs';
|
||||
|
||||
export class S3 {
|
||||
private readonly options;
|
||||
|
||||
constructor(options: S3Types.ClientConfiguration) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
headObject({ Key }: { Key: string }) {
|
||||
return {
|
||||
promise: () => this.checkFileExists(Key),
|
||||
};
|
||||
}
|
||||
|
||||
getObject({ Key }: { Key: string }) {
|
||||
return {
|
||||
promise: () => this.checkFileExists(Key),
|
||||
createReadStream: () => {
|
||||
const emitter = new EventEmitter();
|
||||
process.nextTick(() => {
|
||||
if (fs.existsSync(Key)) {
|
||||
emitter.emit('data', Buffer.from(fs.readFileSync(Key)));
|
||||
} else {
|
||||
emitter.emit(
|
||||
'error',
|
||||
new Error(`The file ${Key} doest not exist !`),
|
||||
);
|
||||
}
|
||||
emitter.emit('end');
|
||||
});
|
||||
return emitter;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
checkFileExists(Key: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (fs.existsSync(Key)) {
|
||||
resolve('');
|
||||
} else {
|
||||
reject({ message: 'The object doest not exist !' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
headBucket() {
|
||||
return new Promise(resolve => {
|
||||
resolve('');
|
||||
});
|
||||
}
|
||||
|
||||
upload({ Key }: { Key: string }) {
|
||||
return {
|
||||
promise: () =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (!fs.existsSync(Key)) {
|
||||
reject('');
|
||||
} else {
|
||||
resolve('');
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
S3,
|
||||
};
|
||||
@@ -43,6 +43,7 @@
|
||||
"@google-cloud/storage": "^5.6.0",
|
||||
"@types/dockerode": "^3.2.1",
|
||||
"@types/express": "^4.17.6",
|
||||
"aws-sdk": "^2.817.0",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"dockerode": "^3.2.1",
|
||||
"express": "^4.17.1",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user