diff --git a/.changeset/chilly-dodos-drop.md b/.changeset/chilly-dodos-drop.md new file mode 100644 index 0000000000..4a03bb8ae1 --- /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 AWS S3 as a choice to store the static generated files for TechDocs. diff --git a/app-config.yaml b/app-config.yaml index 66e69462aa..48869cc0a0 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -76,7 +76,7 @@ techdocs: generators: techdocs: 'docker' # Alternatives - 'local' publisher: - type: 'local' # Alternatives - 'googleGcs'. Read documentation for using alternatives. + type: 'local' # Alternatives - 'googleGcs' or 'awsS3'. Read documentation for using alternatives. sentry: organization: my-company diff --git a/docs/assets/techdocs/aws-s3.drawio.svg b/docs/assets/techdocs/aws-s3.drawio.svg new file mode 100644 index 0000000000..3bb730f91e --- /dev/null +++ b/docs/assets/techdocs/aws-s3.drawio.svg @@ -0,0 +1,144 @@ + + + + + + + + + + +
+
+
+ + + Admin + + User + +
+
+
+
+ + Admin User + +
+
+ + + + +
+
+
+ + + AWS Account + + +
+
+
+
+ + AWS Account + +
+
+ + + + +
+
+
+ + + TechDocs + + User + +
+
+
+
+ + TechDocs User + +
+
+ + + + +
+
+
+ + + S3 Bucket + + +
+
+
+
+ + S3 Bucket + +
+
+ + + + +
+
+
+ + User +
+ policy +
+
+
+
+
+ + User... + +
+
+ + + + +
+
+
+ + Bucket +
+ policy +
+
+
+
+
+ + Bucket... + +
+
+
+ + + + + Viewer does not support full SVG 1.1 + + + +
diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index f4e89d8f6f..cf94efda5e 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 | Track status | -| --------------------------------- | -------------- | ----------------------------------------------------------- | -| Local Filesystem of Backstage app | Yes ✅ | | -| Google Cloud Storage (GCS) | Yes ✅ | | -| Amazon Web Services (AWS) S3 | No ❌ | [#3714](https://github.com/backstage/backstage/issues/3714) | -| Azure Storage | No ❌ | | +| 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 ❌ | [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 b3cb349778..1190c219a8 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -53,6 +53,7 @@ techdocs: # techdocs.publisher.type can be - 'local' or 'googleGcs' (awsS3, azureStorage, etc. to be available as well). # 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. type: 'local' @@ -70,4 +71,19 @@ techdocs: # Cloud Storage Bucket Name bucketName: 'techdocs-storage', + # Required when techdocs.publisher.type is set to 'awsS3'. Skip otherwise. + + awsS3: + # An API key is required to write to a storage bucket. + credentials: + accessKeyId: + $env: TECHDOCS_AWSS3_ACCESS_KEY_ID_CREDENTIAL + secretAccessKey: + $env: TECHDOCS_AWSS3_SECRET_ACCESS_KEY_CREDENTIAL + region: + $env: AWSS3_REGION + + # AWS S3 Bucket Name + bucketName: 'techdocs-storage', + ``` diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index c7b5ec9a9f..c05346ca4a 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -93,3 +93,175 @@ techdocs: Your Backstage app is now ready to use Google Cloud Storage for TechDocs, to store the static generated documentation files. + +## Configuring AWS S3 Bucket with TechDocs + +Follow the +[official AWS S3 documentation](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html) +for the latest instructions on the following steps involving AWS S3. + +**1. Set `techdocs.publisher.type` config in your `app-config.yaml`** + +Set `techdocs.publisher.type` to `'awsS3'`. + +```yaml +techdocs: + publisher: + type: 'awsS3' +``` + +**2. AWS Policies** + +AWS Policies lets you **control access** to Amazon Web Services (AWS) products +and resources. +Here we will use a user policy **and** a bucket policy to show you the different +possibilities you have but you can use only one. + +AWS S3 + +This is an example of how you can manage your policies: + +a. Admin user creates a **bucket policy** granting a set of permissions to our +TechDocs user. + +b. Admin user attaches a **user policy** to the TechDocs user granting +additional permissions. + +c. TechDocs User then tries permissions granted via both the **bucket** policy +and the **user** policy. + +**2.1 Creation** + +**2.1.1 Create an Admin user** (if you don't have one yet) + +Create an **administrator user** account `ADMIN_USER` and grant it administrator +privileges by attaching a user policy giving the account **full access**. +Note down the Admin User credentials and IAM User Sign-In URL as you will need +to use this information in the next step. + +**2.1.2 Create an AWS S3 Bucket** + +Using the credentials of your Admin User `ADMIN_USER`, and the special IAM user +sign-in URL, create a dedicated **bucket** for TechDocs sites. techdocs-backend +will publish documentation to this bucket. TechDocs will fetch files from here +to serve documentation in Backstage. + +Set the name of the bucket to `techdocs.publisher.awsS3.bucketName`. + +```yaml +techdocs: + publisher: + type: 'awsS3' + awsS3: + bucketName: 'name-of-techdocs-storage-bucket' +``` + +**2.1.3 Create the `TechDocs` user** + +This user will be used to interact with your bucket, it will only have +permissions to **get - put** objects. + +In the IAM console, do the following: + +- Create a new user, `TechDocs` +- Note down the TechDocs User credentials +- Note down the Amazon Resource Name (ARN) for the TechDocs user. In the IAM + console, select the TechDocs user, and you can find the user ARN in the + Summary tab. + +**2.2 Attach policies** + +Remember that you can use Bucket policy **or** User policy. +Just make sure that you grant all the permissions to the TechDocs user: +`3:PutObject`, `s3:GetObject`, `s3:ListBucket` and `s3:GetBucketLocation`. + +**2.2.1 Create the bucket policy** + +You now have to attach the following policy to your bucket in the Permission +section: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "statement1", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::YOUR_ACCOUNT_ID:user/TechDocs" + }, + "Action": ["s3:GetBucketLocation", "s3:ListBucket"], + "Resource": ["arn:aws:s3:::name-of-techdocs-storage-bucket"] + }, + { + "Sid": "statement2", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::YOUR_ACCOUNT_ID:user/TechDocs" + }, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::name-of-techdocs-storage-bucket/*"] + } + ] +} +``` + +- The first statement grants **TechDocs User** the bucket operation permissions + `s3:GetBucketLocation` and `s3:ListBucket` which are permissions required by + the console. +- The second statement grants the `s3:GetObject` permission. + (**NOTE :** if you do not use the user policy defined below you must also add + the `s3:PutObject` permission to allow the TechDocs user to add objects.) + +**2.2.2 Create the user policy** + +Create an inline policy for the TechDocs user by using the following policy: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PermissionForObjectOperations", + "Effect": "Allow", + "Action": ["s3:PutObject"], + "Resource": ["arn:aws:s3:::name-of-techdocs-storage-bucket/*"] + } + ] +} +``` + +See more details in the section +[Working with Inline Policies](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_manage.html). + +Now you need to fill in the environment variables with the `TechDocs` User +credentials. You can also specify a region if you want to accesses the resources +in a specific region. Otherwise no region will be selected by default. + +```properties +TECHDOCS_AWSS3_ACCESS_KEY_ID_CREDENTIAL="TECHDOCS_ACCESS_KEY_ID" +TECHDOCS_AWSS3_SECRET_ACCESS_KEY_CREDENTIAL="TECHDOCS_SECRET_ACCESS_KEY" +AWSS3_REGION="" // Optional +``` + +Make it available in your Backstage server and/or your local development server +and set it in the app config techdocs.publisher.awsS3. + +```yaml +techdocs: + publisher: + type: 'awsS3' + awsS3: + credentials: + accessKeyId: + $env: TECHDOCS_AWSS3_ACCESS_KEY_ID_CREDENTIAL + secretAccessKey: + $env: TECHDOCS_AWSS3_SECRET_ACCESS_KEY_CREDENTIAL + region: + $env: AWSS3_REGION +``` + +**3. That's it!** + +Your Backstage app is now ready to use AWS S3 for TechDocs, to store the static +generated documentation files. diff --git a/packages/techdocs-common/__mocks__/aws-sdk.ts b/packages/techdocs-common/__mocks__/aws-sdk.ts new file mode 100644 index 0000000000..987b3d2800 --- /dev/null +++ b/packages/techdocs-common/__mocks__/aws-sdk.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 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, +}; diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index fd20475352..7ddb0b7dff 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -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", diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts new file mode 100644 index 0000000000..501c171c2e --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -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 !`, + ), + ), + ); + }); + }); +}); diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts new file mode 100644 index 0000000000..15ff33408a --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -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 { + 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> = []; + + 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 { + try { + return await new Promise((resolve, reject) => { + const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; + + const fileStreamChunks: Array = []; + 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 = []; + 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 { + 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); + } + } +} diff --git a/packages/techdocs-common/src/stages/publish/publish.test.ts b/packages/techdocs-common/src/stages/publish/publish.test.ts index c61b9aa3ec..d7cc257b89 100644 --- a/packages/techdocs-common/src/stages/publish/publish.test.ts +++ b/packages/techdocs-common/src/stages/publish/publish.test.ts @@ -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 = { @@ -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); + }); }); diff --git a/packages/techdocs-common/src/stages/publish/publish.ts b/packages/techdocs-common/src/stages/publish/publish.ts index 95b5cf83e2..caaf8a4cec 100644 --- a/packages/techdocs-common/src/stages/publish/publish.ts +++ b/packages/techdocs-common/src/stages/publish/publish.ts @@ -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); diff --git a/packages/techdocs-common/src/stages/publish/types.ts b/packages/techdocs-common/src/stages/publish/types.ts index 9bfd8cb334..24a9d05adf 100644 --- a/packages/techdocs-common/src/stages/publish/types.ts +++ b/packages/techdocs-common/src/stages/publish/types.ts @@ -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; diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 37d5dd59ed..c2ecaab61b 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -133,49 +133,54 @@ export async function createRouter({ entity, config, }); - if (publisherType === 'local') { - if (!(await docsBuilder.docsUpToDate())) { - await docsBuilder.build(); - } - } else if (publisherType === '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. - if (!(await publisher.hasDocsBeenGenerated(entity))) { - logger.info( - 'No pre-generated documentation files found for the entity in the storage. Building docs...', - ); - await docsBuilder.build(); - // With a maximum of ~5 seconds wait, check if the files got published and if docs will be fetched - // on the user's page. If not, respond with a message asking them to check back later. - // The delay here is to make sure GCS registers newly uploaded files which is usually <1 second - let foundDocs = false; - for (let attempt = 0; attempt < 5; attempt++) { - if (await publisher.hasDocsBeenGenerated(entity)) { - foundDocs = true; - break; - } - await new Promise(r => setTimeout(r, 1000)); + switch (publisherType) { + case 'local': + if (!(await docsBuilder.docsUpToDate())) { + await docsBuilder.build(); } - if (!foundDocs) { - logger.error( - 'Published files are taking longer to show up in storage. Something went wrong.', + break; + case 'awsS3': + 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. + if (!(await publisher.hasDocsBeenGenerated(entity))) { + logger.info( + 'No pre-generated documentation files found for the entity in the storage. Building docs...', ); - res - .status(408) - .send( - 'Sorry! It is taking longer for the generated docs to show up in storage. Check back later.', + await docsBuilder.build(); + // With a maximum of ~5 seconds wait, check if the files got published and if docs will be fetched + // on the user's page. If not, respond with a message asking them to check back later. + // The delay here is to make sure GCS/AWS/etc. registers newly uploaded files which is usually <1 second + let foundDocs = false; + for (let attempt = 0; attempt < 5; attempt++) { + if (await publisher.hasDocsBeenGenerated(entity)) { + foundDocs = true; + break; + } + await new Promise(r => setTimeout(r, 1000)); + } + if (!foundDocs) { + logger.error( + 'Published files are taking longer to show up in storage. Something went wrong.', ); - return; + res + .status(408) + .send( + 'Sorry! It is taking longer for the generated docs to show up in storage. Check back later.', + ); + return; + } + } else { + logger.info( + 'Found pre-generated docs for this entity. Serving them.', + ); + // TODO: re-trigger build for cache invalidation. + // Compare the date modified of the requested file on storage and compare it against + // the last modified or last commit timestamp in the repository. + // Without this, docs will not be re-built once they have been generated. } - } else { - logger.info( - 'Found pre-generated docs for this entity. Serving them.', - ); - // TODO: re-trigger build for cache invalidation. - // Compare the date modified of the requested file on storage and compare it against - // the last modified or last commit timestamp in the repository. - // Without this, docs will not be re-built once they have been generated. - } + break; + default: } } diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts index 543579e85f..ad2daeb9a3 100644 --- a/plugins/techdocs/config.d.ts +++ b/plugins/techdocs/config.d.ts @@ -45,7 +45,7 @@ export interface Config { /** * attr: 'techdocs' - accepts a string value * e.g. type: 'docker' - * aleternatives: 'local' etc. + * alternatives: 'local' etc. * @see http://backstage.io/docs/features/techdocs/configuration */ techdocs: 'local' | 'docker'; @@ -59,16 +59,61 @@ export interface Config { /** * attr: 'type' - accepts a string value * e.g. type: 'local' - * aleternatives: 'googleGcs' etc. + * alternatives: 'googleGcs' etc. * @see http://backstage.io/docs/features/techdocs/configuration */ - type: 'local' | 'awsS3'; + type: 'local'; + } + | { + /** + * attr: 'type' - accepts a string value + * e.g. type: 'awsS3' + * alternatives: 'googleGcs' etc. + * @see http://backstage.io/docs/features/techdocs/configuration + */ + type: 'awsS3'; + + /** + * awsS3 required when 'type' is set to awsS3 + */ + awsS3?: { + /** + * Credentials used to access a storage bucket + * @visibility secret + */ + credentials: { + /** + * User access key id + * attr: 'accessKeyId' - accepts a string value + * @visibility secret + */ + accessKeyId: string; + /** + * User secret access key + * attr: 'secretAccessKey' - accepts a string value + * @visibility secret + */ + secretAccessKey: string; + }; + /** + * Cloud Storage Bucket Name + * attr: 'bucketName' - accepts a string value + * @visibility secret + */ + bucketName: string; + /** + * AWS Region + * attr: 'region' - accepts a string value + * @visibility secret + */ + region?: string; + }; } | { /** * attr: 'type' - accepts a string value * e.g. type: 'googleGcs' - * aleternatives: 'googleGcs' etc. + * alternatives: 'googleGcs' etc. * @see http://backstage.io/docs/features/techdocs/configuration */ type: 'googleGcs';