Add terraform plugin into backstage-app (#23)
This commit is contained in:
parent
c2ff2abd11
commit
10b78fca7a
38 changed files with 28053 additions and 26347 deletions
|
@ -133,4 +133,4 @@ argocd:
|
|||
# replace with your argocd password e.g. kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d
|
||||
password: ${ARGOCD_ADMIN_PASSWORD}
|
||||
argoWorkflows:
|
||||
baseUrl: https://cnoe.localtest.me:8443/argo-workflows
|
||||
baseUrl: https://cnoe.localtest.me:8443/argo-workflows
|
|
@ -44,6 +44,7 @@
|
|||
"@internal/plugin-apache-spark": "^0.1.0",
|
||||
"@internal/plugin-argo-workflows": "^0.1.0",
|
||||
"@internal/plugin-cnoe-ui": "^0.1.0",
|
||||
"@internal/plugin-terraform": "^0.1.0",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@roadiehq/backstage-plugin-argo-cd": "^2.5.1",
|
||||
|
|
|
@ -45,7 +45,7 @@ import { ApacheSparkPage } from '@internal/plugin-apache-spark';
|
|||
import {
|
||||
UnifiedThemeProvider
|
||||
} from "@backstage/theme";
|
||||
|
||||
import { TerraformPluginPage } from '@internal/plugin-terraform';
|
||||
|
||||
const app = createApp({
|
||||
apis,
|
||||
|
@ -148,6 +148,7 @@ const routes = (
|
|||
<Route path="/catalog-graph" element={<CatalogGraphPage />} />
|
||||
<Route path="/argo-workflows" element={<ArgoWorkflowsPage />} />
|
||||
<Route path="/apache-spark" element={<ApacheSparkPage />} />
|
||||
<Route path="/terraform" element={<TerraformPluginPage />} />
|
||||
</FlatRoutes>
|
||||
);
|
||||
|
||||
|
|
|
@ -70,6 +70,7 @@ import {
|
|||
isArgoWorkflowsAvailable,
|
||||
} from '@internal/plugin-argo-workflows';
|
||||
import {ApacheSparkPage, isApacheSparkAvailable} from "@internal/plugin-apache-spark";
|
||||
import { isTerraformAvailable, TerraformPluginPage } from '@internal/plugin-terraform';
|
||||
|
||||
const techdocsContent = (
|
||||
<EntityTechdocsContent>
|
||||
|
@ -157,6 +158,13 @@ const overviewContent = (
|
|||
</Grid>
|
||||
</EntitySwitch.Case>
|
||||
</EntitySwitch>
|
||||
<EntitySwitch>
|
||||
<EntitySwitch.Case if={e => isTerraformAvailable(e)}>
|
||||
<Grid item md={6}>
|
||||
<TerraformPluginPage />
|
||||
</Grid>
|
||||
</EntitySwitch.Case>
|
||||
</EntitySwitch>
|
||||
<Grid item md={6} xs={12}>
|
||||
<EntityCatalogGraphCard variant="gridItem" height={400} />
|
||||
</Grid>
|
||||
|
@ -171,6 +179,10 @@ const overviewContent = (
|
|||
</Grid>
|
||||
);
|
||||
|
||||
const terraFormContent = (
|
||||
<TerraformPluginPage />
|
||||
);
|
||||
|
||||
const serviceEntityPage = (
|
||||
<EntityLayout>
|
||||
<EntityLayout.Route path="/" title="Overview">
|
||||
|
|
|
@ -50,6 +50,7 @@
|
|||
"@kubernetes/client-node": "~0.20.0",
|
||||
"@roadiehq/backstage-plugin-argo-cd-backend": "3.0.2",
|
||||
"@roadiehq/scaffolder-backend-module-utils": "^1.17.0",
|
||||
"@internal/backstage-plugin-terraform-backend": "^0.1.0",
|
||||
"app": "link:../app",
|
||||
"better-sqlite3": "^9.0.0",
|
||||
"dockerode": "^3.3.1",
|
||||
|
|
|
@ -32,5 +32,6 @@ backend.add(legacyPlugin('argocd', import('./plugins/argocd')));
|
|||
// cnoe plugins
|
||||
backend.add(authModuleKeycloakOIDCProvider);
|
||||
backend.add(cnoeScaffolderActions);
|
||||
backend.add(import('@internal/backstage-plugin-terraform-backend'));
|
||||
|
||||
backend.start();
|
||||
|
|
1
plugins/terraform-backend/.eslintrc.js
Normal file
1
plugins/terraform-backend/.eslintrc.js
Normal file
|
@ -0,0 +1 @@
|
|||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
14
plugins/terraform-backend/README.md
Normal file
14
plugins/terraform-backend/README.md
Normal file
|
@ -0,0 +1,14 @@
|
|||
# terraform
|
||||
|
||||
Welcome to the terraform backend plugin!
|
||||
|
||||
_This plugin was created through the Backstage CLI_
|
||||
|
||||
## Getting started
|
||||
|
||||
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn
|
||||
start` in the root directory, and then navigating to [/terraformPlugin/health](http://localhost:7007/api/terraformv2Plugin/health).
|
||||
|
||||
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
|
||||
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
|
||||
It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory.
|
9
plugins/terraform-backend/dev/index.ts
Normal file
9
plugins/terraform-backend/dev/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { createBackend } from '@backstage/backend-defaults';
|
||||
|
||||
const backend = createBackend();
|
||||
|
||||
backend.add(import('@backstage/plugin-auth-backend'));
|
||||
backend.add(import('@backstage/plugin-auth-backend-module-guest-provider'));
|
||||
backend.add(import('../src'));
|
||||
|
||||
backend.start();
|
49
plugins/terraform-backend/package.json
Normal file
49
plugins/terraform-backend/package.json
Normal file
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"name": "@internal/backstage-plugin-terraform-backend",
|
||||
"version": "0.1.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "backend-plugin"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli package start",
|
||||
"build": "backstage-cli package build",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"clean": "backstage-cli package clean",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.22.0",
|
||||
"@backstage/backend-defaults": "^0.2.18",
|
||||
"@backstage/backend-plugin-api": "^0.6.18",
|
||||
"@backstage/config": "^1.2.0",
|
||||
"@types/express": "*",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"pako": "^2.1.0",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.26.4",
|
||||
"@backstage/plugin-auth-backend": "^0.22.5",
|
||||
"@backstage/plugin-auth-backend-module-guest-provider": "^0.1.4",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"msw": "^1.0.0",
|
||||
"supertest": "^6.2.4"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
2
plugins/terraform-backend/src/index.ts
Normal file
2
plugins/terraform-backend/src/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './service/router';
|
||||
export { terraformPlugin as default } from './plugin';
|
38
plugins/terraform-backend/src/plugin.ts
Normal file
38
plugins/terraform-backend/src/plugin.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import {
|
||||
coreServices,
|
||||
createBackendPlugin,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { createRouter } from './service/router';
|
||||
|
||||
/**
|
||||
* terraformPlugin backend plugin
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const terraformPlugin = createBackendPlugin({
|
||||
pluginId: 'terraform',
|
||||
register(env) {
|
||||
env.registerInit({
|
||||
deps: {
|
||||
httpRouter: coreServices.httpRouter,
|
||||
logger: coreServices.logger,
|
||||
config: coreServices.rootConfig,
|
||||
},
|
||||
async init({
|
||||
httpRouter,
|
||||
logger,
|
||||
config,
|
||||
}) {
|
||||
httpRouter.addAuthPolicy({
|
||||
path: '/health',
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
|
||||
httpRouter.use(await createRouter({
|
||||
config,
|
||||
logger,
|
||||
}));
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
29
plugins/terraform-backend/src/service/router.test.ts
Normal file
29
plugins/terraform-backend/src/service/router.test.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
import { createRouter } from './router';
|
||||
|
||||
describe('createRouter', () => {
|
||||
let app: express.Express;
|
||||
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('returns ok', async () => {
|
||||
const response = await request(app).get('/health');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({ status: 'ok' });
|
||||
});
|
||||
});
|
||||
});
|
126
plugins/terraform-backend/src/service/router.ts
Normal file
126
plugins/terraform-backend/src/service/router.ts
Normal file
|
@ -0,0 +1,126 @@
|
|||
import { errorHandler } from '@backstage/backend-common';
|
||||
import { coreServices } from '@backstage/backend-plugin-api';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import {DefaultAwsCredentialsManager} from '@backstage/integration-aws-node';
|
||||
import {S3Client, ListObjectsV2Command, GetObjectCommand} from "@aws-sdk/client-s3";
|
||||
import * as fs from 'fs';
|
||||
const {inflate} = require('pako');
|
||||
|
||||
type ListObjectsInput = {
|
||||
Bucket: string,
|
||||
Prefix: string,
|
||||
ContinuationToken?: string,
|
||||
};
|
||||
|
||||
export interface RouterOptions {
|
||||
logger: coreServices.logger;
|
||||
config: coreServices.rootConfig,
|
||||
}
|
||||
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const {logger, config} = options;
|
||||
const awsCredentialsManager = DefaultAwsCredentialsManager.fromConfig(config);
|
||||
const credProvider = await awsCredentialsManager.getCredentialProvider({});
|
||||
const client = new S3Client({
|
||||
credentialDefaultProvider: () => credProvider.sdkCredentialProvider,
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
router.get('/health', (_, response) => {
|
||||
logger.info('PONG!');
|
||||
response.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
router.post('/deflate', async (req, res) => {
|
||||
let jsonData:any = {};
|
||||
|
||||
if(req.body.tfState) {
|
||||
var bytes = [];
|
||||
const inputString = atob(req.body.tfState);
|
||||
for (var i = 0; i < inputString.length; i++) {
|
||||
var abyte = inputString.charCodeAt(i) & 0xff;
|
||||
bytes.push(abyte);
|
||||
}
|
||||
const binData = new Uint8Array(bytes);
|
||||
const inflated = inflate(binData,{to:'string'});
|
||||
jsonData = JSON.parse(inflated);
|
||||
}
|
||||
|
||||
res.json(jsonData);
|
||||
});
|
||||
|
||||
router.post('/getFileList', async (req, res) => {
|
||||
let responseObject: any = [];
|
||||
let token: string | undefined = "1";
|
||||
while (token) {
|
||||
let input: ListObjectsInput = {
|
||||
Bucket: req.body.Bucket,
|
||||
Prefix: req.body.Key,
|
||||
}
|
||||
if (token != "1" && token) {
|
||||
input.ContinuationToken = token;
|
||||
}
|
||||
const command = new ListObjectsV2Command(input);
|
||||
const commandResponse = await client.send(command);
|
||||
responseObject = responseObject.concat(commandResponse.Contents);
|
||||
logger.debug(JSON.stringify(commandResponse));
|
||||
token = commandResponse.NextContinuationToken;
|
||||
}
|
||||
res.json(responseObject);
|
||||
});
|
||||
|
||||
router.post('/getLocalFileList', async (req, res) => {
|
||||
let responseObject: any[] = [];
|
||||
|
||||
try {
|
||||
const fsstat = fs.lstatSync(req.body.FileLocation);
|
||||
if (fsstat.isDirectory()) {
|
||||
const filenames = fs.readdirSync(req.body.FileLocation);
|
||||
for (let i in filenames) {
|
||||
responseObject.push({
|
||||
Key: req.body.FileLocation + "/" + filenames[i]
|
||||
});
|
||||
}
|
||||
} else if (fsstat.isFile()) {
|
||||
responseObject.push({
|
||||
Key: req.body.FileLocation
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
}
|
||||
|
||||
res.json(responseObject);
|
||||
});
|
||||
|
||||
router.post('/getTFStateFile', async (req, res) => {
|
||||
if (req.body.Bucket) {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: req.body.Bucket,
|
||||
Key: req.body.Key,
|
||||
});
|
||||
const commandResponse = await client.send(command);
|
||||
const str: any = await commandResponse.Body?.transformToString();
|
||||
res.json(JSON.parse(str));
|
||||
|
||||
} else {
|
||||
let jsonData: any = {};
|
||||
try {
|
||||
const data = fs.readFileSync(req.body.Key, {encoding: 'utf8', flag: 'r'});
|
||||
jsonData = JSON.parse(data);
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
}
|
||||
|
||||
res.json(jsonData);
|
||||
}
|
||||
});
|
||||
|
||||
router.use(errorHandler());
|
||||
return router;
|
||||
}
|
1
plugins/terraform-backend/src/setupTests.ts
Normal file
1
plugins/terraform-backend/src/setupTests.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export {};
|
1
plugins/terraform/.eslintrc.js
Normal file
1
plugins/terraform/.eslintrc.js
Normal file
|
@ -0,0 +1 @@
|
|||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
148
plugins/terraform/README.md
Normal file
148
plugins/terraform/README.md
Normal file
|
@ -0,0 +1,148 @@
|
|||
# Terraform Backstage Plugin
|
||||
|
||||
Welcome to the Terraform plugin! This plugin can show Terraform outputs/resources from TFState files associated with a particular Backstage components. It does this by utilizing various annotations which point to where the TFState might be stored. It will then fetch those files, parse them, and display them in a Backstage component.
|
||||
|
||||
## Getting started
|
||||
|
||||
### Terraform State Files
|
||||
This plugin supports three storage locations for Terraform state files (tfstate): K8s secrets, S3 and local file systems. S3 will require additional configuration for AWS credentials to access S3. To access local file systems, the terraform backend will need proper file permissions to access those files.
|
||||
|
||||
### Configuration - Frontend
|
||||
|
||||
Entities must be annotated with Kubernetes annotations. An example component
|
||||
would look like the following where you can configure the `spec` to your
|
||||
liking. Information specific to Terraform goes under `annotations` as
|
||||
shown below:
|
||||
|
||||
```yaml
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: backstage
|
||||
annotations:
|
||||
terraform.cnoe.io/s3-bucket: backstage-terraform-plugin
|
||||
terraform.cnoe.io/s3-prefix: tfstates/
|
||||
terraform.cnoe.io/local-filepath: /var/lib/tfstatefiles
|
||||
terraform.cnoe.io/secret-name: secret
|
||||
terraform.cnoe.io/secret-namespace: namespace
|
||||
spec:
|
||||
type: service
|
||||
lifecycle: experimental
|
||||
owner: user1
|
||||
system: system1
|
||||
```
|
||||
|
||||
Update your Entity page.
|
||||
|
||||
For example if you have want to have a Terraform link in the top toolbar to expand to a new page:
|
||||
```typescript
|
||||
// in packages/app/src/components/catalog/EntityPage.tsx
|
||||
import { isTerraformAvailable, TerraformPluginPage } from '@cnoe-io/plugin-terraform';
|
||||
...
|
||||
const terraFormContent = (
|
||||
<TerraformPluginPage />
|
||||
);
|
||||
...
|
||||
const websiteEntityPage = (
|
||||
<EntityLayout>
|
||||
...
|
||||
<EntityLayout.Route path="/terraform" title="Terraform" if={isTerraformAvailable}>
|
||||
{terraFormContent}
|
||||
</EntityLayout.Route>
|
||||
</EntityLayout>
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
If you want to have the Terraform outputs/resources tables on the overview Entity page:
|
||||
```typescript
|
||||
// in packages/app/src/components/catalog/EntityPage.tsx
|
||||
const overviewContent = (
|
||||
<Grid container spacing={3} alignItems="stretch">
|
||||
...
|
||||
<EntitySwitch>
|
||||
<EntitySwitch.Case if={e => isTerraformAvailable(e)}>
|
||||
<Grid item md={6}>
|
||||
<TerraformPluginPage />
|
||||
</Grid>
|
||||
</EntitySwitch.Case>
|
||||
</EntitySwitch>
|
||||
...
|
||||
</Grid>
|
||||
);
|
||||
```
|
||||
|
||||
#### Annotations
|
||||
As shown in the example above, the following annotations could go under
|
||||
`annotations` in the backstage `Component` and will be recognized by this plugin.
|
||||
|
||||
- One of the three annotations below are required:
|
||||
- `terraform.cnoe.io/s3-bucket`: Optional. The S3 bucket where tfstate files would be stored.
|
||||
- `terraform.cnoe.io/local-filepath`: Optional. The local file system path of where tfstate files would be stored.
|
||||
- If storing tfstate files in S3, you can optionally define a prefix:
|
||||
- `terraform.cnoe.io/s3-prefix`: Optional. This is a S3 prefix of where tfstate files would be stored in the S3 bucket.
|
||||
- `terraform.cnoe.io/secret-name`: Optional. The secret name where the tfstate file would be stored in the K8s cluster.
|
||||
- `terraform.cnoe.io/secret-namespace`: Optional. The namespace of the secret.
|
||||
|
||||
Note: The plugin only supports using one storage location at a time. It looks at the following annotations in this order:
|
||||
|
||||
- secret-name/secret-namespace
|
||||
- s3-bucket/s3-prefix
|
||||
- local-filepath
|
||||
|
||||
### Configuration - Backend
|
||||
|
||||
In `packages/backend/src/index.ts`, import the backend plugin.
|
||||
|
||||
```typescript
|
||||
...
|
||||
// cnoe plugins
|
||||
backend.add(authModuleKeycloakOIDCProvider);
|
||||
backend.add(cnoeScaffolderActions);
|
||||
backend.add(import('@internal/backstage-plugin-terraformv2-backend'));
|
||||
|
||||
backend.start();
|
||||
...
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
#### AWS Credentails
|
||||
|
||||
By default, the Terraform backend plugin relies on the [default behavior of the AWS SDK for Javascript](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/modules/_aws_sdk_credential_provider_node.html) to determine the AWS credentials that it uses to authenticate an identity to use with AWS APIs.
|
||||
|
||||
The Terraform backend plugin that runs in your Backstage app searches for credentials in the following order:
|
||||
|
||||
1. Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`)
|
||||
1. SSO credentials from the token cache
|
||||
1. Web identity token credentials (including running in an Amazon EKS cluster using IAM roles for service accounts)
|
||||
1. Shared credentials and config ini files (`~/.aws/credentials`, `~/.aws/config`)
|
||||
1. Amazon Elastic Container Service (Amazon ECS) task metadata service
|
||||
1. Amazon Elastic Compute Cloud (Amazon EC2) instance metadata service
|
||||
|
||||
We recommend that you don't hard-code long lived AWS credentials in your production Backstage application configuration. Hard-coding credentials is risky and might expose your access key ID and secret access key.
|
||||
|
||||
Instead, we recommend that you use short lived AWS credentials for your production Backstage application by deploying it to Amazon ECS, Amazon Elastic Kubernetes Service (Amazon EKS), or Amazon EC2. For more information about deploying Backstage to Amazon EKS using a Helm chart or to Amazon ECS on AWS Fargate using the AWS Cloud Development Kit (CDK), see [Deploying Backstage](https://backstage.io/docs/deployment/) in the Backstage documentation.
|
||||
|
||||
To use multiple AWS accounts with your Backstage app or to explicitly configure credentials for an AWS account, you can configure AWS accounts in your Backstage app's configuration.
|
||||
For example, to configure an AWS account to use with the Terraform backend plugin which requires using an IAM role to retrieve credentials, add the following to your Backstage app-config.yaml file.
|
||||
|
||||
```yaml
|
||||
aws:
|
||||
accounts:
|
||||
- accountId: '111111111111'
|
||||
roleName: 'my-iam-role-name'
|
||||
```
|
||||
|
||||
For more account configuration examples, see the [Backstage integration-aws-node package documentation](https://www.npmjs.com/package/@backstage/integration-aws-node).
|
||||
|
||||
## IAM permissions
|
||||
|
||||
The Terraform backend plugin requires the AWS identity that it uses to have the following IAM permissions for getting tfstate files from S3:
|
||||
|
||||
* s3:GetObject
|
||||
* s3:ListObjectsV2
|
||||
|
||||
## Diagram
|
||||
|
||||

|
12
plugins/terraform/dev/index.tsx
Normal file
12
plugins/terraform/dev/index.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { terraformPlugin, TerraformPage } from '../src/plugin';
|
||||
|
||||
createDevApp()
|
||||
.registerPlugin(terraformPlugin)
|
||||
.addPage({
|
||||
element: <TerraformPage />,
|
||||
title: 'Root Page',
|
||||
path: '/terraform'
|
||||
})
|
||||
.render();
|
BIN
plugins/terraform/images/terraform.png
Normal file
BIN
plugins/terraform/images/terraform.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 247 KiB |
51
plugins/terraform/package.json
Normal file
51
plugins/terraform/package.json
Normal file
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"name": "@internal/plugin-terraform",
|
||||
"version": "0.1.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "frontend-plugin"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"start": "backstage-cli package start",
|
||||
"build": "backstage-cli package build",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"clean": "backstage-cli package clean",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core-components": "^0.14.8",
|
||||
"@backstage/core-plugin-api": "^1.9.3",
|
||||
"@backstage/theme": "^0.5.6",
|
||||
"@material-ui/core": "^4.9.13",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "^4.0.0-alpha.61",
|
||||
"react-use": "^17.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1 || ^17.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.26.4",
|
||||
"@backstage/core-app-api": "^1.12.6",
|
||||
"@backstage/dev-utils": "^1.0.26",
|
||||
"@backstage/test-utils": "^1.4.7",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^12.1.3",
|
||||
"@testing-library/user-event": "^14.0.0",
|
||||
"msw": "^1.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
81
plugins/terraform/src/api/Terraform.test.ts
Normal file
81
plugins/terraform/src/api/Terraform.test.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
import { Terraform } from "./Terraform";
|
||||
import { KubernetesApi } from "@backstage/plugin-kubernetes";
|
||||
import { FrontendHostDiscovery } from "@backstage/core-app-api";
|
||||
import { UserIdentity } from "@backstage/core-components";
|
||||
|
||||
describe("TerraformClient", () => {
|
||||
const mockKClient: jest.Mocked<KubernetesApi> = {
|
||||
getObjectsByEntity: jest.fn(),
|
||||
getClusters: jest.fn(),
|
||||
getWorkloadsByEntity: jest.fn(),
|
||||
getCustomObjectsByEntity: jest.fn(),
|
||||
proxy: jest.fn(),
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
jest
|
||||
.spyOn(FrontendHostDiscovery.prototype, "getBaseUrl")
|
||||
.mockImplementation((id) => {
|
||||
return Promise.resolve(`https://backstage.io/${id}`);
|
||||
});
|
||||
jest
|
||||
.spyOn(UserIdentity.prototype, "getCredentials")
|
||||
.mockImplementation(() => {
|
||||
return Promise.resolve({ token: "abc" });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("can fetch from k8s", async () => {
|
||||
mockKClient.proxy.mockResolvedValue({
|
||||
status: 200,
|
||||
ok: true,
|
||||
text: async () => "teststring",
|
||||
} as Response);
|
||||
const a = new Terraform(mockKClient);
|
||||
const spy = jest.spyOn(mockKClient, "proxy");
|
||||
const resp = await a.getSecret("abc", "default", "test");
|
||||
expect(resp).toBeDefined();
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
clusterName: "abc",
|
||||
path: "/apis/v1/namespaces/default/secrets/test?timeoutSeconds=30",
|
||||
});
|
||||
});
|
||||
it("can fetch from default k8s cluster", async () => {
|
||||
mockKClient.proxy.mockResolvedValue({
|
||||
status: 200,
|
||||
ok: true,
|
||||
text: async () => "teststring"
|
||||
} as Response);
|
||||
mockKClient.getClusters.mockResolvedValue([
|
||||
{
|
||||
name: "cluster-1",
|
||||
authProvider: "provider-1",
|
||||
},
|
||||
]);
|
||||
|
||||
const a = new Terraform(mockKClient);
|
||||
const spy = jest.spyOn(a, "getFirstCluster");
|
||||
const resp = await a.getSecret(undefined, "default", "test");
|
||||
expect(resp).toBeDefined();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
it("rejects when non-ok status returned", async () => {
|
||||
mockKClient.proxy.mockResolvedValue({
|
||||
status: 500,
|
||||
ok: false,
|
||||
statusText: "something went wrong",
|
||||
text: async () => "oh no",
|
||||
} as Response);
|
||||
|
||||
const a = new Terraform(mockKClient);
|
||||
await expect(
|
||||
a.getSecret("abc", "default", "test")
|
||||
).rejects.toEqual(
|
||||
"failed to fetch resources: 500, something went wrong, oh no"
|
||||
);
|
||||
});
|
||||
});
|
159
plugins/terraform/src/api/Terraform.ts
Normal file
159
plugins/terraform/src/api/Terraform.ts
Normal file
|
@ -0,0 +1,159 @@
|
|||
import { IdentityApi, ConfigApi } from "@backstage/core-plugin-api"
|
||||
import { KubernetesApi } from "@backstage/plugin-kubernetes";
|
||||
import { TerraformApi } from "./index";
|
||||
|
||||
const API_VERSION = "v1";
|
||||
const K8s_API_TIMEOUT = "timeoutSeconds";
|
||||
|
||||
export class Terraform implements TerraformApi {
|
||||
private kubernetesApi: KubernetesApi;
|
||||
private identityApi: IdentityApi;
|
||||
private configApi: ConfigApi;
|
||||
|
||||
constructor(
|
||||
kubernetesApi: KubernetesApi,
|
||||
identityApi: IdentityApi,
|
||||
configApi: ConfigApi,
|
||||
) {
|
||||
this.kubernetesApi = kubernetesApi;
|
||||
this.identityApi = identityApi;
|
||||
this.configApi = configApi;
|
||||
}
|
||||
|
||||
async fetchURL(url: string, type: string, requestBody: any) {
|
||||
const { token } = await this.identityApi.getCredentials();
|
||||
const backendUrl = this.configApi.getString('backend.baseUrl');
|
||||
const response = await fetch(backendUrl+""+url, {
|
||||
method: type,
|
||||
body: JSON.stringify(requestBody),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
}
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async deflate(
|
||||
tfState:string
|
||||
): Promise<any> {
|
||||
const requestBody = {
|
||||
tfState
|
||||
};
|
||||
|
||||
const response = await this.fetchURL('/api/terraform/deflate', 'post', requestBody);
|
||||
|
||||
if (!response.ok) {
|
||||
return Promise.reject(
|
||||
`failed to fetch resources: ${response.status}, ${
|
||||
response.statusText
|
||||
}, ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async s3GetFileList(
|
||||
Bucket:string,
|
||||
Prefix:string
|
||||
):Promise<any> {
|
||||
const requestBody = {
|
||||
Bucket,
|
||||
Prefix
|
||||
};
|
||||
|
||||
const response = await this.fetchURL('/api/terraform/getFileList', 'post', requestBody);
|
||||
if (!response.ok) {
|
||||
return Promise.reject(
|
||||
`failed to fetch resources: ${response.status}, ${
|
||||
response.statusText
|
||||
}, ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async localGetFileList(
|
||||
FileLocation:string
|
||||
):Promise<any> {
|
||||
const requestBody = {
|
||||
FileLocation,
|
||||
};
|
||||
|
||||
const response = await this.fetchURL('/api/terraform/getLocalFileList', 'post', requestBody);
|
||||
if (!response.ok) {
|
||||
return Promise.reject(
|
||||
`failed to fetch resources: ${response.status}, ${
|
||||
response.statusText
|
||||
}, ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getTFStateFile(
|
||||
Bucket:string,
|
||||
file:any
|
||||
):Promise<any> {
|
||||
let bodyObj:any = {
|
||||
Key: file.Key
|
||||
};
|
||||
if(Bucket) {
|
||||
bodyObj.Bucket = Bucket;
|
||||
}
|
||||
|
||||
const response = await this.fetchURL('/api/terraform/getTFStateFile', 'post', bodyObj);
|
||||
if (!response.ok) {
|
||||
return Promise.reject(
|
||||
`failed to fetch resources: ${response.status}, ${
|
||||
response.statusText
|
||||
}, ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
|
||||
|
||||
async getSecret(
|
||||
clusterName: string | undefined,
|
||||
namespace: string,
|
||||
secretName: string
|
||||
): Promise<any> {
|
||||
const ns = namespace !== undefined ? namespace : "flux-system";
|
||||
const path = `/api/${API_VERSION}/namespaces/${ns}/secrets/${secretName}`;
|
||||
const query = new URLSearchParams({
|
||||
[K8s_API_TIMEOUT]: "30",
|
||||
});
|
||||
// need limits and pagination
|
||||
const resp = await this.kubernetesApi.proxy({
|
||||
clusterName:
|
||||
clusterName !== undefined ? clusterName : await this.getFirstCluster(),
|
||||
path: `${path}?${query.toString()}`,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return Promise.reject(
|
||||
`failed to fetch resources: ${resp.status}, ${
|
||||
resp.statusText
|
||||
}, ${await resp.text()}`
|
||||
);
|
||||
}
|
||||
// need validation
|
||||
const responseText = await resp.text()
|
||||
const secretJSON = JSON.parse(responseText)
|
||||
return [
|
||||
{
|
||||
"TFStateContents": secretJSON.data.tfstate
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async getFirstCluster(): Promise<string> {
|
||||
const clusters = await this.kubernetesApi.getClusters();
|
||||
if (clusters.length > 0) {
|
||||
return Promise.resolve(clusters[0].name);
|
||||
}
|
||||
return Promise.reject("no clusters found in configuration");
|
||||
}
|
||||
}
|
34
plugins/terraform/src/api/index.ts
Normal file
34
plugins/terraform/src/api/index.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { createApiRef } from "@backstage/core-plugin-api";
|
||||
|
||||
export { Terraform } from "./Terraform";
|
||||
|
||||
export const TerraformApiRef = createApiRef<TerraformApi>({
|
||||
id: "plugin.terraform",
|
||||
});
|
||||
export interface TerraformApi {
|
||||
|
||||
getSecret(
|
||||
clusterName: string | undefined,
|
||||
namespace: string | undefined,
|
||||
secretName: string,
|
||||
): Promise<string>;
|
||||
|
||||
deflate(
|
||||
tfState:string
|
||||
): Promise<string>;
|
||||
|
||||
s3GetFileList(
|
||||
Bucket:string,
|
||||
Prefix:string
|
||||
): Promise<string>;
|
||||
|
||||
localGetFileList(
|
||||
FileLocation:string
|
||||
): Promise<string>;
|
||||
|
||||
getTFStateFile(
|
||||
Bucket:string,
|
||||
file:any
|
||||
): Promise<string>;
|
||||
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import { MainPageComponent } from './MainPageComponent';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { screen } from '@testing-library/react';
|
||||
import {
|
||||
setupRequestMockHandlers,
|
||||
renderInTestApp,
|
||||
} from "@backstage/test-utils";
|
||||
|
||||
describe('MainPageComponent', () => {
|
||||
const server = setupServer();
|
||||
// Enable sane handlers for network requests
|
||||
setupRequestMockHandlers(server);
|
||||
|
||||
// setup mock response
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))),
|
||||
);
|
||||
});
|
||||
|
||||
it('should render', async () => {
|
||||
await renderInTestApp(<MainPageComponent />);
|
||||
expect(screen.getByText('Terraform')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
|
||||
// import {
|
||||
// Header,
|
||||
// Page,
|
||||
// Content,
|
||||
// } from '@backstage/core-components';
|
||||
import { MainPageFetchComponent } from '../MainPageFetchComponent';
|
||||
|
||||
export const MainPageComponent = () => (
|
||||
<MainPageFetchComponent/>
|
||||
);
|
|
@ -0,0 +1 @@
|
|||
export { MainPageComponent } from './MainPageComponent';
|
|
@ -0,0 +1,74 @@
|
|||
import React, {Dispatch, SetStateAction} from 'react';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { OutputTable, ResourceTable } from './MainPageFetchComponent';
|
||||
import {
|
||||
renderInTestApp,
|
||||
} from "@backstage/test-utils";
|
||||
|
||||
describe('MainPageFetchComponent', () => {
|
||||
it('renders the outputs table', async () => {
|
||||
let fakeOutputs = [{
|
||||
"type": "string",
|
||||
"value": "Hello World!"
|
||||
}];
|
||||
|
||||
renderInTestApp(<OutputTable outputs={fakeOutputs}/>);
|
||||
|
||||
// Wait for the table to render
|
||||
const table = await screen.findByRole('table');
|
||||
// Assert that the table contains the expected output data
|
||||
expect(table).toBeInTheDocument();
|
||||
expect(screen.getByText('Hello World!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the resources table', async () => {
|
||||
let fakeResources = [
|
||||
{
|
||||
"module": "module.eks",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudwatch_log_group",
|
||||
"name": "this",
|
||||
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
|
||||
"instances": [
|
||||
{
|
||||
"index_key": 0,
|
||||
"schema_version": 0,
|
||||
"attributes": {
|
||||
"arn": "arn:aws:logs:us-west-2:833162080385:log-group:/aws/eks/emr-eks-fargate/cluster",
|
||||
"id": "/aws/eks/emr-eks-fargate/cluster",
|
||||
"kms_key_id": "",
|
||||
"name": "/aws/eks/emr-eks-fargate/cluster",
|
||||
"name_prefix": "",
|
||||
"retention_in_days": 90,
|
||||
"skip_destroy": false,
|
||||
"tags": {
|
||||
"Blueprint": "emr-eks-fargate",
|
||||
"GithubRepo": "github.com/awslabs/data-on-eks",
|
||||
"Name": "/aws/eks/emr-eks-fargate/cluster"
|
||||
},
|
||||
"tags_all": {
|
||||
"Blueprint": "emr-eks-fargate",
|
||||
"GithubRepo": "github.com/awslabs/data-on-eks",
|
||||
"Name": "/aws/eks/emr-eks-fargate/cluster"
|
||||
}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"private": "bnVsbA==",
|
||||
"create_before_destroy": true
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const noop: Dispatch<SetStateAction<any>> = () => {};
|
||||
renderInTestApp(
|
||||
<ResourceTable resources={fakeResources} setResourceDetail={noop}/>
|
||||
);
|
||||
|
||||
// Wait for the table to render
|
||||
const table = await screen.getAllByRole('table');
|
||||
// Assert that the table contains the expected resource data
|
||||
expect(table[0]).toBeInTheDocument();
|
||||
expect(screen.getByText('/aws/eks/emr-eks-fargate/cluster')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,341 @@
|
|||
import React, { useState, useEffect, Dispatch, SetStateAction } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableColumn,
|
||||
Progress,
|
||||
ResponseErrorPanel,
|
||||
Link,
|
||||
StructuredMetadataTable,
|
||||
InfoCard,
|
||||
DependencyGraph,
|
||||
DependencyGraphTypes,
|
||||
} from '@backstage/core-components';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import Drawer from '@material-ui/core/Drawer';
|
||||
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import {
|
||||
TERRAFORM_S3_BUCKET,
|
||||
TERRAFORM_S3_PREFIX,
|
||||
TERRAFORM_LOCAL_PATH,
|
||||
TERRAFORM_SECRET_NAME,
|
||||
TERRAFORM_SECRET_NAMESPACE,
|
||||
} from '../../consts'
|
||||
import { TerraformApiRef } from '../../api';
|
||||
|
||||
export const OutputTable = ({ outputs }:any) => {
|
||||
let data:any = {};
|
||||
for(let i in outputs) {
|
||||
data[Number(i)+1] = outputs[i].value;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<InfoCard title="Terraform Outputs">
|
||||
<StructuredMetadataTable
|
||||
metadata={data}
|
||||
/>
|
||||
</InfoCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const ResourceTable = ({ resources,setResourceDetail }:{resources:any, setResourceDetail:Dispatch<SetStateAction<any>>}) => {
|
||||
const columns: TableColumn[] = [
|
||||
{ title: 'Name',
|
||||
render: (row: any) => {
|
||||
const resourceDetailsObj = {
|
||||
name: row.name,
|
||||
displayName: row.displayName,
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
to="/terraform/resourcedetails"
|
||||
onClick={(e:any) => {
|
||||
e.preventDefault();
|
||||
setResourceDetail(resourceDetailsObj);
|
||||
}}
|
||||
>{row.displayName}</Link>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: 'Type', field: 'type' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
title="Terraform Resources"
|
||||
options={{ search: true, paging: true }}
|
||||
columns={columns}
|
||||
data={resources}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const TerraformTables = ({ resources,outputs,setResourceDetail }: {resources: any[], outputs: any[], setResourceDetail:Dispatch<SetStateAction<any>>}) => {
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<OutputTable outputs={outputs}/>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<ResourceTable resources={resources} setResourceDetail={setResourceDetail}/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResourceDetailComponent = ({resourceDetail,allResources,setResourceDetail}:{resourceDetail:any,allResources:any,setResourceDetail:Dispatch<SetStateAction<any>>}) => {
|
||||
const [details,setDetails] = useState<any>({});
|
||||
const [attributes,setAttributes] = useState<any>({});
|
||||
const [dependNodes,setDependNodes] = useState<any[]>([]);
|
||||
const [dependEdges,setDependEdges] = useState<any[]>([]);
|
||||
|
||||
const graphStyle = { border: '1px solid grey' };
|
||||
|
||||
useEffect(()=>{
|
||||
const resourceObj = allResources[resourceDetail.name];
|
||||
|
||||
let newDetails:any = {};
|
||||
for(let i in resourceObj) {
|
||||
if(!Array.isArray(resourceObj[i])) {
|
||||
newDetails[i] = resourceObj[i];
|
||||
}
|
||||
}
|
||||
setDetails(newDetails);
|
||||
|
||||
const newAttributes:any = {};
|
||||
for(let i in resourceObj?.instances[0]?.attributes) {
|
||||
let attribute:any = resourceObj?.instances[0]?.attributes[i];
|
||||
if(Array.isArray(attribute) || typeof attribute === "object") {
|
||||
newAttributes[i] = JSON.stringify(attribute);
|
||||
} else if (!attribute) {
|
||||
newAttributes[i] = "";
|
||||
} else {
|
||||
newAttributes[i] = attribute;
|
||||
}
|
||||
}
|
||||
setAttributes(newAttributes);
|
||||
|
||||
let newDependNodes:any[] = [{'id': resourceDetail.displayName, 'name': resourceDetail.name}];
|
||||
let newDependEdges:any[] = [];
|
||||
for(let i in resourceObj?.instances[0]?.dependencies) {
|
||||
newDependNodes.push({
|
||||
'id': resourceObj.instances[0]?.dependencies[i].displayName, 'name': resourceObj.instances[0]?.dependencies[i].name
|
||||
});
|
||||
newDependEdges.push({
|
||||
'from': resourceObj.instances[0]?.dependencies[i].displayName, 'to': resourceDetail.displayName
|
||||
});
|
||||
}
|
||||
setDependNodes(newDependNodes);
|
||||
setDependEdges(newDependEdges);
|
||||
},[resourceDetail,allResources]);
|
||||
|
||||
|
||||
return (
|
||||
<div style={{maxWidth: '800px'}}>
|
||||
<InfoCard title="Details">
|
||||
{ <StructuredMetadataTable metadata={details} /> }
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Attributes">
|
||||
{ <StructuredMetadataTable metadata={attributes} /> }
|
||||
</InfoCard>
|
||||
<InfoCard title="Dependencies">
|
||||
<DependencyGraph
|
||||
nodes={dependNodes}
|
||||
edges={dependEdges}
|
||||
direction={DependencyGraphTypes.Direction.RIGHT_LEFT}
|
||||
style={graphStyle}
|
||||
paddingX={50}
|
||||
paddingY={50}
|
||||
renderNode={props => {
|
||||
const height = 100;
|
||||
const width = (props.node.id?.length*12);
|
||||
const resourceDetailsObj = {
|
||||
name: props.node.name,
|
||||
displayName: props.node.id,
|
||||
};
|
||||
return (
|
||||
<g>
|
||||
<rect width={width} height={height} rx={20} fill='#36baa2'/>
|
||||
<text
|
||||
y={height/2}
|
||||
x={width/2}
|
||||
alignmentBaseline="middle"
|
||||
textAnchor="middle"
|
||||
>
|
||||
<Link
|
||||
style={{fontSize: 20}}
|
||||
to="/terraform/resourcedetails"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setResourceDetail(resourceDetailsObj);
|
||||
}}
|
||||
>{props.node.id}</Link>
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</InfoCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const MainPageFetchComponent = () => {
|
||||
const apiClient = useApi(TerraformApiRef);
|
||||
const { entity } = useEntity();
|
||||
|
||||
const [resourceDetail,setResourceDetail] = useState<any>({});
|
||||
const [allResources,setAllResources] = useState<any>({});
|
||||
const [resources, setResources] = useState<any[]>([]);
|
||||
const [outputs, setOutputs] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<ResponseError>();
|
||||
|
||||
function parseResources(resourcesArr:any[]) {
|
||||
let resourcesObj:any = {};
|
||||
let nameIndex:any = {};
|
||||
let data:any[] = resourcesArr.filter((resource:any)=> {
|
||||
if(resource.mode === "managed") {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}).map((resource:any)=> {
|
||||
let resourceName:string = "";
|
||||
if(resource.module) {
|
||||
resourceName += resource.module.split("[")[0] + ".";
|
||||
} else {
|
||||
resourceName += resource.mode + "."
|
||||
}
|
||||
resourceName += resource.type + "." + resource.name;
|
||||
resourcesObj[resourceName] = resource;
|
||||
let displayName = resourceName;
|
||||
if(resource.instances[0].attributes.name) {
|
||||
displayName = resource.instances[0].attributes.name;
|
||||
} else if(resource.instances[0].attributes.id) {
|
||||
displayName = resource.instances[0].attributes.id;
|
||||
}
|
||||
nameIndex[resourceName] = displayName;
|
||||
return {
|
||||
name: resourceName,
|
||||
displayName: displayName,
|
||||
type: resource.type,
|
||||
}
|
||||
});
|
||||
|
||||
for(let i in resourcesObj) {
|
||||
let newDependenciesObj:any[] = [];
|
||||
if(resourcesObj[i].instances[0].dependencies) {
|
||||
for(let j in resourcesObj[i].instances[0].dependencies) {
|
||||
if(nameIndex[resourcesObj[i].instances[0].dependencies[j]]) {
|
||||
newDependenciesObj.push({name: resourcesObj[i].instances[0].dependencies[j], displayName: nameIndex[resourcesObj[i].instances[0].dependencies[j]]});
|
||||
}
|
||||
}
|
||||
resourcesObj[i].instances[0].dependencies = newDependenciesObj;
|
||||
}
|
||||
}
|
||||
|
||||
setResources(data);
|
||||
setAllResources(resourcesObj);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const getStateFiles = async() => {
|
||||
let resourcesArr:any[] = [];
|
||||
let outputsArr:any[] = [];
|
||||
let responseJSON:any = {};
|
||||
|
||||
if(SecretName) {
|
||||
responseJSON = await apiClient.getSecret(undefined, SecretNamespace, SecretName);
|
||||
} else if(Bucket) {
|
||||
responseJSON = await apiClient.s3GetFileList(Bucket,Prefix);
|
||||
} else if(FileLocation) {
|
||||
responseJSON = await apiClient.localGetFileList(FileLocation);
|
||||
}
|
||||
|
||||
for(let i in responseJSON) {
|
||||
let tfStateJSON:any = {};
|
||||
let file = responseJSON[i];
|
||||
if(file.TFStateContents) {
|
||||
tfStateJSON = await apiClient.deflate(file.TFStateContents);
|
||||
} else if(file.Key && !file.Key?.endsWith("/")) {
|
||||
tfStateJSON = await apiClient.getTFStateFile(Bucket,file);
|
||||
}
|
||||
|
||||
if(tfStateJSON.outputs) {
|
||||
for(let i in tfStateJSON.outputs) {
|
||||
outputsArr.push(tfStateJSON.outputs[i]);
|
||||
}
|
||||
}
|
||||
if(tfStateJSON.resources) {
|
||||
for(let i in tfStateJSON.resources) {
|
||||
resourcesArr.push(tfStateJSON.resources[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parseResources(resourcesArr);
|
||||
setOutputs(outputsArr);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
let Bucket = "";
|
||||
let Prefix = "";
|
||||
let FileLocation = "";
|
||||
let SecretName = "";
|
||||
let SecretNamespace = "";
|
||||
|
||||
if(entity.metadata.annotations?.[TERRAFORM_SECRET_NAME]) {
|
||||
SecretName = entity.metadata.annotations?.[TERRAFORM_SECRET_NAME] || "";
|
||||
}
|
||||
|
||||
if(entity.metadata.annotations?.[TERRAFORM_SECRET_NAMESPACE]) {
|
||||
SecretNamespace = entity.metadata.annotations?.[TERRAFORM_SECRET_NAMESPACE] || "";
|
||||
}
|
||||
|
||||
if(!SecretName) {
|
||||
if(entity.metadata.annotations?.[TERRAFORM_S3_BUCKET]) {
|
||||
Bucket = entity.metadata.annotations?.[TERRAFORM_S3_BUCKET] || "";
|
||||
}
|
||||
|
||||
if(entity.metadata.annotations?.[TERRAFORM_S3_PREFIX]) {
|
||||
Prefix = entity.metadata.annotations?.[TERRAFORM_S3_PREFIX] || "";
|
||||
}
|
||||
|
||||
if(!Bucket) {
|
||||
FileLocation = entity.metadata.annotations?.[TERRAFORM_LOCAL_PATH] || "";
|
||||
}
|
||||
}
|
||||
|
||||
getStateFiles();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
} else if (error) {
|
||||
return <ResponseErrorPanel error={error} />;
|
||||
}
|
||||
|
||||
return <>
|
||||
<TerraformTables resources={resources} outputs={outputs} setResourceDetail={setResourceDetail}/>
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={resourceDetail.name}
|
||||
onClose={() => setResourceDetail({})}
|
||||
>
|
||||
<ResourceDetailComponent resourceDetail={resourceDetail} allResources={allResources} setResourceDetail={setResourceDetail}/>
|
||||
</Drawer>
|
||||
</>;
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { MainPageFetchComponent } from './MainPageFetchComponent';
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { MainPageComponent } from "../MainPageComponent";
|
||||
|
||||
export const RootComponent = () => {
|
||||
return (
|
||||
<Routes>
|
||||
{/* myPlugin.routes.root will take the user to this page */}
|
||||
<Route path="/" element={<MainPageComponent/>} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
1
plugins/terraform/src/components/RootComponent/index.ts
Normal file
1
plugins/terraform/src/components/RootComponent/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { RootComponent } from './RootComponent';
|
5
plugins/terraform/src/consts.ts
Normal file
5
plugins/terraform/src/consts.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export const TERRAFORM_S3_BUCKET = "terraform.cnoe.io/s3-bucket"
|
||||
export const TERRAFORM_S3_PREFIX = "terraform.cnoe.io/s3-prefix"
|
||||
export const TERRAFORM_LOCAL_PATH = "terraform.cnoe.io/local-filepath"
|
||||
export const TERRAFORM_SECRET_NAME = "terraform.cnoe.io/secret-name"
|
||||
export const TERRAFORM_SECRET_NAMESPACE = "terraform.cnoe.io/secret-namespace"
|
1
plugins/terraform/src/index.ts
Normal file
1
plugins/terraform/src/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export {isTerraformAvailable, terraformPlugin, TerraformPluginPage} from './plugin';
|
7
plugins/terraform/src/plugin.test.ts
Normal file
7
plugins/terraform/src/plugin.test.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { terraformPlugin } from './plugin';
|
||||
|
||||
describe('terraform', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(terraformPlugin).toBeDefined();
|
||||
});
|
||||
});
|
50
plugins/terraform/src/plugin.ts
Normal file
50
plugins/terraform/src/plugin.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { createPlugin, createRoutableExtension, createApiFactory, identityApiRef, configApiRef } from '@backstage/core-plugin-api';
|
||||
import { rootRouteRef } from './routes';
|
||||
import {Terraform, TerraformApiRef} from "./api";
|
||||
import {kubernetesApiRef} from "@backstage/plugin-kubernetes";
|
||||
|
||||
import {
|
||||
TERRAFORM_S3_BUCKET,
|
||||
TERRAFORM_S3_PREFIX,
|
||||
TERRAFORM_LOCAL_PATH,
|
||||
TERRAFORM_SECRET_NAME,
|
||||
TERRAFORM_SECRET_NAMESPACE,
|
||||
} from './consts';
|
||||
|
||||
import {Entity} from '@backstage/catalog-model';
|
||||
|
||||
export const isTerraformAvailable = (entity: Entity) =>
|
||||
((Boolean(entity.metadata.annotations?.[TERRAFORM_S3_BUCKET]) &&
|
||||
Boolean(entity.metadata.annotations?.[TERRAFORM_S3_PREFIX])) ||
|
||||
Boolean(entity.metadata.annotations?.[TERRAFORM_LOCAL_PATH]) ||
|
||||
(Boolean(entity.metadata.annotations?.[TERRAFORM_SECRET_NAME]) &&
|
||||
Boolean(entity.metadata.annotations?.[TERRAFORM_SECRET_NAMESPACE]))
|
||||
);
|
||||
|
||||
export const terraformPlugin = createPlugin({
|
||||
id: 'terraform',
|
||||
routes: {
|
||||
root: rootRouteRef,
|
||||
},
|
||||
apis: [
|
||||
createApiFactory({
|
||||
api: TerraformApiRef,
|
||||
deps: {
|
||||
kubernetesApi: kubernetesApiRef,
|
||||
identityApi: identityApiRef,
|
||||
configApi: configApiRef,
|
||||
},
|
||||
factory: ({kubernetesApi, identityApi, configApi}) =>
|
||||
new Terraform(kubernetesApi, identityApi, configApi),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const TerraformPluginPage = terraformPlugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'TerraformPluginPage',
|
||||
component: () =>
|
||||
import('./components/RootComponent').then(m => m.RootComponent),
|
||||
mountPoint: rootRouteRef,
|
||||
}),
|
||||
);
|
5
plugins/terraform/src/routes.ts
Normal file
5
plugins/terraform/src/routes.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { createRouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
id: 'terraform',
|
||||
});
|
1
plugins/terraform/src/setupTests.ts
Normal file
1
plugins/terraform/src/setupTests.ts
Normal file
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom';
|
Loading…
Reference in a new issue