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 @@
+
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.
+
+
+
+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';