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
+6
View File
@@ -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.
+1 -1
View File
@@ -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
+144
View File
@@ -0,0 +1,144 @@
<svg host="65bd71144e" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="605px" height="362px" viewBox="-0.5 -0.5 605 362" content="&lt;mxfile host=&quot;046c5cfb-a3a2-464c-a595-f674d31136ed&quot; modified=&quot;2020-12-26T01:33:35.255Z&quot; agent=&quot;5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Code/1.52.1 Chrome/83.0.4103.122 Electron/9.3.5 Safari/537.36&quot; etag=&quot;nIR1gVsObWk5MHRRKeB7&quot; version=&quot;13.10.0&quot; type=&quot;embed&quot;&gt;&lt;diagram id=&quot;fZcCmqQ9Q5JzR6r55YHy&quot; name=&quot;Page-1&quot;&gt;7VhNc5swEP01HJtBiA/7iI3dHtqT20lzlIUCajCishzb/fVdQBhkSOLWjtPJ9JJo367Qat/TImzh6Wr3UZIi/SJillmOHe8sHFmO43sI/pbAXgPBuAYSyeMasltgwX+xGkQNuuExW2ushpQQmeKFCVKR54wqAyNSiq0Zdi+y2AAKkrAesKAk66O3PFapRn3bbh2fGE9SvfS4cSwJfUik2OR6PcvBczQPZ7PavSLNs3T8OiWx2HYgPLPwVAqh6tFqN2VZWVqzbPMnvIe8JcvVKRM8nccjyTasSblKTO2bYmxTrtiiILS0t8C3hSepWmVgIRje8yybikxIsHORQ9BkraR4YA0INXC9YDKOwKNXY1Kx3ZMpo0MhQF9MrJiSewjRE5xRPUNLy3Frc9vy5Nu4xtIORdjXWyVaG8nhyW19YKBLNFwu1+9VR0iVikTkJPssRKFr8oMptdeyJhslzIqxHVffYWzr8V0Hj3YdR7RvjBwSrabceI151/W10yqrnReH5XFoqQFkzsv9Vf6aqEbi6FnqKlWzWM+s68Di3jk6ohFqJTaS6ijNiyIyYTrK9YbZliwjij+ajz+Lu6DHXZn+Qpu6PuYuzyAX/SPkDlHZp/1ydLpXohMPNC4/U2VDEpByl2f/50Y0jg/rirgQApBf7FonjBL9v3rKsgHCeMXzBoWslm2k/W3NZMdVr9x4j8RmKuuFprp+YIqmLxLZ7b2Ah6MojKZ6px0cRdjzgkNSA6z2OvLT7ddFRv9Fdr8Bo8DtN+Cxd4Ez7FyN9dsFBIeUAmtqmP3TaIeyqiNuDTr10R14i5KMJzmYFMgCmeFJSRKHS0qoHSsex+Uyg2Iy5Wbo6RXVgbEhDuz3tYFQXxvOJd7N7rW08ZXRNBJ0/a6awll3Mhd5R5eyAeJfrSl41yJ+Aeq2JxsKPJzTEt4J6X7whpyPXuKcHjbd0lqWazmOu9AJ2nCHtNE95EvZoIXION3//atBMliXLKuAku/yellnUrlPfyMUgueqqq83sbxjgQx+pg1egC/RG4Lxjdkd0Aj3lNKIx/hkcy8glPEbC8VsF/+l8pxUkBOcIBVv6Ov+z6UCZvs7S+Xr/JaFZ78B&lt;/diagram&gt;&lt;/mxfile&gt;" style="background-color: rgb(241, 250, 238);">
<defs/>
<g>
<rect x="0" y="0" width="603" height="360" fill="none" stroke="#457b9d" pointer-events="all"/>
<path d="M 213 175 L 126 222" fill="none" stroke="#457b9d" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 387 175 L 474 222" fill="none" stroke="#457b9d" stroke-miterlimit="10" pointer-events="stroke"/>
<rect x="213" y="80" width="174" height="95" fill="#a8dadc" stroke="#457b9d" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 172px; height: 1px; padding-top: 128px; margin-left: 214px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #1D3557; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<font style="font-size: 16px">
<b>
Admin
</b>
User
</font>
</div>
</div>
</div>
</foreignObject>
<text x="300" y="131" fill="#1D3557" font-family="Helvetica" font-size="12px" text-anchor="middle">
Admin User
</text>
</switch>
</g>
<rect x="5" y="12" width="111" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 109px; height: 1px; padding-top: 22px; margin-left: 6px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #1D3557; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<font style="font-size: 16px">
<b>
AWS Account
</b>
</font>
</div>
</div>
</div>
</foreignObject>
<text x="61" y="26" fill="#1D3557" font-family="Helvetica" font-size="12px" text-anchor="middle">
AWS Account
</text>
</switch>
</g>
<rect x="387" y="222" width="174" height="95" fill="#a8dadc" stroke="#457b9d" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 172px; height: 1px; padding-top: 270px; margin-left: 388px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #1D3557; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<font style="font-size: 16px">
<b>
TechDocs
</b>
User
</font>
</div>
</div>
</div>
</foreignObject>
<text x="474" y="273" fill="#1D3557" font-family="Helvetica" font-size="12px" text-anchor="middle">
TechDocs User
</text>
</switch>
</g>
<rect x="39" y="222" width="174" height="95" fill="#a8dadc" stroke="#457b9d" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 172px; height: 1px; padding-top: 270px; margin-left: 40px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #1D3557; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">
<font style="font-size: 16px">
<b>
S3 Bucket
</b>
</font>
</div>
</div>
</div>
</foreignObject>
<text x="126" y="273" fill="#1D3557" font-family="Helvetica" font-size="12px" text-anchor="middle">
S3 Bucket
</text>
</switch>
</g>
<rect x="451.5" y="159" width="46" height="34" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 176px; margin-left: 475px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">
<font color="#457b9d" style="font-size: 14px">
User
<br/>
policy
</font>
</div>
</div>
</div>
</foreignObject>
<text x="475" y="180" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">
User...
</text>
</switch>
</g>
<rect x="99.5" y="159" width="53" height="34" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 176px; margin-left: 126px;">
<div style="box-sizing: border-box; font-size: 0; text-align: center; ">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">
<font color="#457b9d" style="font-size: 14px">
Bucket
<br/>
policy
</font>
</div>
</div>
</div>
</foreignObject>
<text x="126" y="180" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">
Bucket...
</text>
</switch>
</g>
</g>
<switch>
<g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/>
<a transform="translate(0,-5)" xlink:href="https://desk.draw.io/support/solutions/articles/16000042487" target="_blank">
<text text-anchor="middle" font-size="10px" x="50%" y="100%">
Viewer does not support full SVG 1.1
</text>
</a>
</switch>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+6 -6
View File
@@ -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.
+16
View File
@@ -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',
```
@@ -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.
<img data-zoomable src="../../assets/techdocs/aws-s3.drawio.svg" alt="AWS S3" width="500" />
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.
@@ -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,
};
+1
View File
@@ -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;
+44 -39
View File
@@ -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:
}
}
+49 -4
View File
@@ -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';