Merge pull request #18010 from jjtimmons/jjtimmons/add-nomad-plugin

Add Nomad plugin
This commit is contained in:
Johan Haals
2023-06-15 11:01:58 +02:00
committed by GitHub
34 changed files with 1596 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+50
View File
@@ -0,0 +1,50 @@
# @backstage/plugin-nomad-backend
A backend for [Nomad](https://www.nomadproject.io/), this plugin exposes a service with routes that are used by the `@backstage/plugin-nomad` plugin to query Job and Group information from a Nomad API.
## Set Up
1. Install the plugin using:
```bash
# From your Backstage root directory
yarn add --cwd packages/backend @backstage/plugin-nomad-backend
```
2. Create a `nomad.ts` file inside `packages/backend/src/plugins/`:
```typescript
import { createRouter } from '@backstage/plugin-nomad-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
export default async function createPlugin(
props: PluginEnvironment,
): Promise<Router> {
return await createRouter(props);
}
```
3. Modify your `packages/backend/src/index.ts` to include:
```diff
...
import { Config } from '@backstage/config';
import app from './plugins/app';
+import nomad from './plugins/nomad';
...
async function main() {
...
const authEnv = useHotMemoize(module, () => createEnv('auth'));
+ const nomadEnv = useHotMemoize(module, () => createEnv('nomad'));
...
const apiRouter = Router();
apiRouter.use('/catalog', await catalog(catalogEnv));
+ apiRouter.use('/nomad', await nomad(nomadEnv));
```
Note: for this backend to work, the `nomad` configuration described in the README of `@backstage/plugin-nomad` must be implemented.
+22
View File
@@ -0,0 +1,22 @@
## API Report File for "@backstage/plugin-nomad-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Config } from '@backstage/config';
import express from 'express';
import { Logger } from 'winston';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
// @public (undocumented)
export interface RouterOptions {
// (undocumented)
config: Config;
// (undocumented)
logger: Logger;
}
// (No @packageDocumentation comment for this package)
```
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@backstage/plugin-nomad-backend",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"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": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@types/express": "*",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"node-fetch": "^2.6.7",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@types/supertest": "^2.0.8",
"msw": "^1.0.0",
"supertest": "^6.2.4"
},
"files": [
"dist"
]
}
+16
View File
@@ -0,0 +1,16 @@
/*
* Copyright 2023 The Backstage Authors
*
* 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.
*/
export * from './service/router';
+34
View File
@@ -0,0 +1,34 @@
/*
* Copyright 2023 The Backstage Authors
*
* 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 { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';
import { ConfigReader } from '@backstage/config';
const config = new ConfigReader({});
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ config, port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
@@ -0,0 +1,51 @@
/*
* Copyright 2023 The Backstage Authors
*
* 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 { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
import { ConfigReader } from '@backstage/config';
describe('createRouter', () => {
let app: express.Express;
beforeAll(async () => {
const router = await createRouter({
config: new ConfigReader({
nomad: {
addr: 'http://localhost.com',
token: 'asdf',
},
}),
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' });
});
});
});
+128
View File
@@ -0,0 +1,128 @@
/*
* Copyright 2023 The Backstage Authors
*
* 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 fetch from 'node-fetch';
import { errorHandler, requestLoggingHandler } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import { InputError } from '@backstage/errors';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
/** @public */
export interface RouterOptions {
logger: Logger;
config: Config;
}
/** @public */
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { config, logger } = options;
// Get Nomad addr and token from config
const addr = config.getString('nomad.addr');
const token = config.getOptionalString('nomad.token');
const router = Router();
router.use(express.json());
router.get('/health', (_, resp) => {
resp.json({ status: 'ok' });
});
router.get('/v1/allocations', async (req, res) => {
// Check namespace argument
const namespace = (req.query.namespace as string) ?? '';
if (!namespace || namespace === '') {
throw new InputError(`Missing "namespace" query parameter`);
}
// Check filter argument
const filter = (req.query.filter as string) ?? '';
logger.debug(`request headers: namespace=${namespace} filter=${filter}`);
// Issue the request
const endpoint = `${addr}/v1/allocations?namespace=${encodeURIComponent(
namespace,
)}&filter=${encodeURIComponent(filter)}`;
const allocationsResp = await fetch(endpoint, {
method: 'GET',
headers: {
Accept: 'application/json',
'X-Nomad-Token': token || '',
},
});
// Check response
if (allocationsResp.status !== 200) {
const body = await allocationsResp.text();
logger.error(`failed to call /v1/allocations endpoint: ${body}`);
res.status(allocationsResp.status).json({ message: body });
return;
}
// Deserialize and return
const allocationsBody = await allocationsResp.json();
logger.debug(`/v1/allocations response: ${allocationsBody}`);
res.json(allocationsBody);
});
router.get('/v1/job/:job_id/versions', async (req, resp) => {
// Check namespace argument
const namespace = (req.query.namespace as string) ?? '';
if (!namespace || namespace === '') {
throw new InputError(`Missing "namespace" query parameter`);
}
// Get job ID
const jobID = (req.params.job_id as string) ?? '';
logger.info(`token: ${token}`);
// Issue the request
const apiResp = await fetch(
`${addr}/v1/job/${jobID}/versions?namespace=${encodeURIComponent(
namespace,
)}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'X-Nomad-Token': token || '',
},
},
);
// Check response
if (apiResp.status !== 200) {
const body = await apiResp.text();
logger.error(`failed to call /v1/job/:job_id/versions endpoint: ${body}`);
resp.status(apiResp.status).json({ message: body });
return;
}
// Deserialize and return
const versionsBody = await apiResp.json();
logger.debug(`/v1/job/:job_id/versions response: ${versionsBody}`);
resp.json(versionsBody);
});
router.use(requestLoggingHandler());
router.use(errorHandler());
return router;
}
@@ -0,0 +1,52 @@
/*
* Copyright 2023 The Backstage Authors
*
* 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 { createServiceBuilder } from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';
import { Config } from '@backstage/config';
export interface ServerOptions {
config: Config;
enableCors: boolean;
logger: Logger;
port: number;
}
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'nomad' });
logger.debug('Starting application server...');
const router = await createRouter({
logger,
config: options.config,
});
let service = createServiceBuilder(module)
.setPort(options.port)
.addRouter('/nomad', router);
if (options.enableCors) {
service = service.enableCors({ origin: 'http://localhost:3000' });
}
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
module.hot?.accept();
+16
View File
@@ -0,0 +1,16 @@
/*
* Copyright 2023 The Backstage Authors
*
* 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.
*/
export {};
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+167
View File
@@ -0,0 +1,167 @@
# @backstage/plugin-nomad
This is a frontend plugin is for viewing [Nomad](https://www.nomadproject.io/) [job versions](https://developer.hashicorp.com/nomad/docs/concepts/architecture#job) and [task group allocations](https://developer.hashicorp.com/nomad/docs/concepts/architecture#allocation).
This plugin has a corresponding backend plugin required to call the Nomad cluster's API: ` @backstage/plugin-nomad-backend`.
## Introduction
### [Nomad](https://www.nomadproject.io/)
> A simple and flexible scheduler and orchestrator to deploy and manage containers and non-containerized applications across on-prem and clouds at scale.
### Features
This plugin provides two components:
- a table to view recent [job versions](https://developer.hashicorp.com/nomad/docs/commands/job/history)
- a table to view [allocations for a job and/or group](https://developer.hashicorp.com/nomad/tutorials/manage-jobs/jobs-inspect)
## Getting Started
### Requirements
You will need to have the backend Nomad plugin, `@backstage/plugin-nomad-backend`, installed and running. See its README for set up instructions.
You will need a running Nomad cluster with an API address that is reachable from the `@backstage/plugin-nomad/backend` plugin [running in the back end](https://backstage.io/docs/overview/architecture-overview/#third-party-backed-plugins). You can follow [this tutorial](https://developer.hashicorp.com/nomad/tutorials/enterprise/production-deployment-guide-vm-with-consul) to learn how to deploy one.
If your Nomad cluster has ACLs enabled, you will need a `token` with at least the [`list-jobs`capability](https://developer.hashicorp.com/nomad/tutorials/access-control/access-control-policies#namespace-rules). You can check [this tutorial](https://developer.hashicorp.com/nomad/tutorials/access-control/access-control-create-policy) for more info or the minimal [example below](#example-policy).
### Installation
```bash
# From your Backstage root directory
yarn add --cwd packages/app @backstage/plugin-nomad
```
### Configuration
Add configuration to your [`app-config.yaml`](https://github.com/backstage/backstage/blob/master/app-config.yaml). For example:
```yaml
nomad:
addr: 'http://localhost:4646'
token: '93e034ad-e504-42f9-129d-5d81be9f13d3'
```
The `token` can be excluded if [ACLs are not enabled](https://developer.hashicorp.com/nomad/api-docs#authentication).
### Annotate Components
Several annotations are available for Components that make use of this plugin:
```yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
annotations:
nomad.io/namespace: default
nomad.io/job-id: redis
nomad.io/group: 'redis|prometheus-collector'
```
- `nomad.io/job-id` annotation's value is matched exactly and corresponds to `JobID` in the Nomad API. It is required for the Job Versions Card but optional for the Allocations Table
- `nomad.io/group` annotation's value is used as a regex pattern against `TaskGroup` using [Nomad's server-side filtering](https://developer.hashicorp.com/nomad/api-docs#filtering). It is optional for the [Allocations Table](#allocations-table)
- `nomad.io/namespace` is the Namespace of the Job and Allocations of the Component. If omitted, it defaults to `default`
### Job Versions Card
The snippet below adds a card to the overview tab on the EntityPage. It shows versions of a Nomad job associated with a Component in the Catalog.
```typescript
// In packages/app/src/components/catalog/EntityPage.tsx
import { EntityNomadJobVersionListCard, isNomadJobIDAvailable } from '@backstage/plugin-nomad';
const overviewContent = (
...
<EntitySwitch>
<EntitySwitch.Case if={isNomadJobIDAvailable}>
<Grid item md={6} xs={12}>
<EntityNomadJobVersionListCard />
</Grid>
</EntitySwitch.Case>
</EntitySwitch>
);
```
<div>
<img src="./img/job-versions-component.png" width="350em">
</div>
#### Requirements
- the `nomad.io/job-id` annotation must be set
### Allocations Table
The snippet below adds a `/nomad` tab to the EntityPage that displays all allocations associated the `nomad.io/job-id` and/or `nomad.io/group` of Component's annotations.
```typescript
// In packages/app/src/components/catalog/EntityPage.tsx
import { EntityNomadAllocationListTable, isNomadAllocationsAvailable } from '@backstage/plugin-nomad';
const serviceEntityPage = (
...
<EntityLayout.Route
if={isNomadAllocationsAvailable}
path="/nomad"
title="Nomad"
>
<EntityNomadAllocationListTable />
</EntityLayout.Route>
)
```
<div>
<img src="./img/allocations-component.png" width="800em">
</div>
#### Requirements
- `nomad.io/job-id` and/or `nomad.io/group` annotations must be set
## ACL Policy Example
Because this plugin uses API endpoints that require the `list-jobs` capability, the token you provide to the plugin's [`nomad` configuration](#configuration) needs at least that.
To create such a token you can create a policy like below. This policy applies to all namespaces:
```hcl
# backstage.policy.hcl
namespace "*" {
policy = "read"
}
node {
policy = "read"
}
```
And create a policy for it:
```bash
nomad acl policy apply backstage backstage.policy.hcl
```
Then create a client token for it:
```bash
nomad acl token create -name=backstage -policy=backstage
Accessor ID = 5e9fe97b-76c5-8803-21b8-083308dc6c11
Secret ID = 93e034ad-e504-42f9-129d-5d81be9f13d3
Name = backstage
Type = client
Global = false
Create Time = 2023-06-05 00:45:20.51905 +0000 UTC
Expiry Time = <none>
Create Index = 54
Modify Index = 54
Policies = [backstage]
Roles
<none>
```
In the example above, the `Secret ID` is the `token` to use in the [configuration](#configuration).
+35
View File
@@ -0,0 +1,35 @@
## API Report File for "@backstage/plugin-nomad"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { RouteRef } from '@backstage/core-plugin-api';
// @public
export const EntityNomadAllocationListTable: () => JSX.Element;
// @public
export const EntityNomadJobVersionListCard: () => JSX.Element | null;
// @public (undocumented)
export const isNomadAllocationsAvailable: (entity: Entity) => boolean;
// @public (undocumented)
export const isNomadJobIDAvailable: (entity: Entity) => boolean;
// @public (undocumented)
export const nomadPlugin: BackstagePlugin<
{
root: RouteRef<undefined>;
entityContent: RouteRef<undefined>;
},
{},
{}
>;
// (No @packageDocumentation comment for this package)
```
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2023 The Backstage Authors
*
* 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 { createDevApp } from '@backstage/dev-utils';
import { nomadPlugin } from '../src/plugin';
createDevApp().registerPlugin(nomadPlugin).render();
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+81
View File
@@ -0,0 +1,81 @@
{
"name": "@backstage/plugin-nomad",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "frontend-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/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/plugin-catalog-react": "workspace:^",
"@backstage/theme": "workspace:^",
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.60",
"luxon": "^3.3.0",
"react-use": "^17.2.4"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0",
"react-router-dom": "*"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/dev-utils": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^14.0.0",
"@types/node": "*",
"cross-fetch": "^3.1.5",
"msw": "^1.0.0"
},
"files": [
"dist"
],
"configSchema": {
"$schema": "https://backstage.io/schema/config-v1",
"title": "@backstage/plugin-nomad",
"type": "object",
"properties": {
"nomad": {
"type": "object",
"properties": {
"addr": {
"type": "string",
"description": "The address of the Nomad API. See: https://developer.hashicorp.com/nomad/api-docs#addressing-and-ports",
"visibility": "frontend"
},
"token": {
"type": "string",
"description": "The token to call the Nomad API with. See: https://developer.hashicorp.com/nomad/api-docs#authentication",
"visibility": "secret"
}
},
"required": [
"addr"
]
}
}
}
}
+58
View File
@@ -0,0 +1,58 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 React from 'react';
import { Entity } from '@backstage/catalog-model';
import { useEntity } from '@backstage/plugin-catalog-react';
import { MissingAnnotationEmptyState } from '@backstage/core-components';
import { Route, Routes } from 'react-router-dom';
import { EntityNomadAllocationListTable } from './components/EntityNomadAllocationListTable/EntityNomadAllocationListTable';
/** @public */
export const NOMAD_NAMESPACE_ANNOTATION = 'nomad.io/namespace';
/** @public */
export const NOMAD_JOB_ID_ANNOTATION = 'nomad.io/job-id';
/** @public */
export const NOMAD_GROUP_ANNOTATION = 'nomad.io/group';
/** @public */
export const isNomadJobIDAvailable = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[NOMAD_JOB_ID_ANNOTATION]);
/** @public */
export const isNomadAllocationsAvailable = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[NOMAD_JOB_ID_ANNOTATION]) ||
Boolean(entity.metadata.annotations?.[NOMAD_GROUP_ANNOTATION]);
export const EmbeddedRouter = () => {
const { entity } = useEntity();
if (!isNomadAllocationsAvailable(entity)) {
return (
<MissingAnnotationEmptyState
annotation={[NOMAD_JOB_ID_ANNOTATION, NOMAD_GROUP_ANNOTATION]}
/>
);
}
return (
<Routes>
<Route path="/" element={<EntityNomadAllocationListTable />} />
</Routes>
);
};
+160
View File
@@ -0,0 +1,160 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 {
DiscoveryApi,
FetchApi,
createApiRef,
} from '@backstage/core-plugin-api';
/** @public */
export const nomadApiRef = createApiRef<NomadApi>({
id: 'plugin.nomad.service',
});
/** @public */
export type NomadApi = {
/**
* listAllocations is for listing all allocations matching some part of 'filter'.
*
* See: https://developer.hashicorp.com/nomad/api-docs/allocations#list-allocations
*/
listAllocations: (
options: ListAllocationsRequest,
) => Promise<ListAllocationsResponse>;
/**
* listJobVersions is for listing all deployments matching some part of 'filter'.
*
* See: https://developer.hashicorp.com/nomad/api-docs/deployments#list-deployments
*/
listJobVersions: (
options: ListJobVersionsRequest,
) => Promise<ListJobVersionsResponse>;
};
/** @public */
export interface ListAllocationsRequest {
namespace: string;
filter: string;
}
/** @public */
export interface ListAllocationsResponse {
allocations: Allocation[];
}
/** @public */
export interface Allocation {
ClientStatus: string;
CreateTime: number;
DeploymentStatus: DeploymentStatus;
ID: string;
JobID: string;
JobVersion: string;
ModifyTime: number;
Name: string;
Namespace: string;
NodeID: string;
TaskGroup: string;
}
/** @public */
export interface DeploymentStatus {
Healthy: boolean;
Timestamp: string;
}
/** @public */
export interface ListJobVersionsRequest {
namespace: string;
jobID: string;
}
/** @public */
export interface ListJobVersionsResponse {
versions: Version[];
}
/** @public */
export interface Version {
ID: string;
SubmitTime: number;
Stable: boolean;
Version: number;
}
/** @public */
export class FetchError extends Error {
get name(): string {
return this.constructor.name;
}
static async forResponse(resp: Response): Promise<FetchError> {
return new FetchError(
`Request failed with status code ${
resp.status
}.\nReason: ${await resp.text()}`,
);
}
}
/** @public */
export class NomadHttpApi implements NomadApi {
static create(discoveryApi: DiscoveryApi, fetchApi: FetchApi) {
return new NomadHttpApi(discoveryApi, fetchApi);
}
constructor(private discoveryApi: DiscoveryApi, private fetchApi: FetchApi) {}
// TODO: pagination
async listAllocations(
options: ListAllocationsRequest,
): Promise<ListAllocationsResponse> {
const apiUrl = await this.discoveryApi.getBaseUrl('nomad');
const resp = await this.fetchApi.fetch(
`${apiUrl}/v1/allocations?namespace=${encodeURIComponent(
options.namespace,
)}&filter=${encodeURIComponent(options.filter)}`,
);
if (!resp.ok) throw await FetchError.forResponse(resp);
return {
allocations: await resp.json(),
};
}
// TODO: pagination
async listJobVersions(
options: ListJobVersionsRequest,
): Promise<ListJobVersionsResponse> {
const apiUrl = await this.discoveryApi.getBaseUrl('nomad');
const resp = await this.fetchApi.fetch(
`${apiUrl}/v1/job/${
options.jobID
}/versions?namespace=${encodeURIComponent(options.namespace)}`,
);
if (!resp.ok) throw await FetchError.forResponse(resp);
const respJson = await resp.json();
return {
versions: respJson.Versions,
};
}
}
@@ -0,0 +1,201 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { DateTime } from 'luxon';
import {
Link,
MissingAnnotationEmptyState,
ResponseErrorPanel,
StatusError,
StatusOK,
StatusPending,
StatusRunning,
Table,
TableColumn,
} from '@backstage/core-components';
import { useEntity } from '@backstage/plugin-catalog-react';
import React, { useState } from 'react';
import { Allocation, nomadApiRef } from '../../api';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import {
NOMAD_GROUP_ANNOTATION,
NOMAD_JOB_ID_ANNOTATION,
NOMAD_NAMESPACE_ANNOTATION,
isNomadAllocationsAvailable,
} from '../../Router';
import useAsync from 'react-use/lib/useAsync';
type rowType = Allocation & { nomadAddr: string };
const columns: TableColumn<rowType>[] = [
{
title: 'ID',
field: 'ID',
render: row => (
<Link to={`${row.nomadAddr}/ui/allocations/${row.ID}`} underline="always">
{row.ID.split('-')[0]}
</Link>
),
},
{
title: 'Task Group',
field: 'TaskGroup',
render: row => (
<Link
to={`${row.nomadAddr}/ui/jobs/${row.JobID}/${row.TaskGroup}`}
underline="always"
>
{row.TaskGroup}
</Link>
),
},
{
title: 'Created',
field: 'CreateTime',
render: row =>
DateTime.fromMillis(row.CreateTime / 1000000).toLocaleString(
DateTime.DATETIME_MED_WITH_SECONDS,
),
},
{
title: 'Status',
field: 'ClientStatus',
render: row =>
({
pending: <StatusPending>pending</StatusPending>,
running: <StatusOK>running</StatusOK>,
failed: <StatusError>failed</StatusError>,
complete: <StatusRunning>complete</StatusRunning>,
}[row.ClientStatus] || <text>{row.ClientStatus}</text>),
},
{
title: 'Version',
field: 'JobVersion',
render: row => row.JobVersion,
},
{
title: 'Client',
field: 'NodeID',
render: row => (
<Link to={`${row.nomadAddr}/ui/clients/${row.NodeID}`} underline="always">
{row.ID.split('-')[0]}
</Link>
),
},
];
/**
* EntityNomadAllocationListTable is roughly based off Nomad's Allocations tab's view.
*/
export const EntityNomadAllocationListTable = () => {
// Wait on entity
const { entity } = useEntity();
// Get ref to the backend API
const [init, setInit] = useState(true);
const configApi = useApi(configApiRef);
const nomadApi = useApi(nomadApiRef);
const nomadAddr = configApi.getString('nomad.addr');
// Store results of calling API
const [allocations, setAllocations] = useState<rowType[]>([]);
const [err, setErr] = useState<Error>();
// Check that attributes are available
if (!isNomadAllocationsAvailable(entity)) {
<MissingAnnotationEmptyState
annotation={[NOMAD_JOB_ID_ANNOTATION, NOMAD_GROUP_ANNOTATION]}
/>;
}
// Get plugin attributes
const namespace =
entity.metadata.annotations?.[NOMAD_NAMESPACE_ANNOTATION] ?? 'default';
const job = entity.metadata.annotations?.[NOMAD_JOB_ID_ANNOTATION] ?? '';
const group = entity.metadata.annotations?.[NOMAD_GROUP_ANNOTATION] ?? '';
// Make filter from attributes
const filter: string[] = [];
if (job) {
filter.push(`(JobID == "${job}")`);
}
if (group) {
filter.push(`(TaskGroup matches "${group}")`);
}
// Create a query to update allocations
const query = async () => {
try {
// Make call to nomad-backend
const resp = await nomadApi.listAllocations({
namespace,
filter: filter.join(' and '),
});
// Sort results
const results = resp.allocations
.sort((a, b) => a.CreateTime - b.CreateTime)
.sort(({ ClientStatus: a }, { ClientStatus: b }) => {
if (a === 'running' || b !== 'running') {
return -1;
}
return 0;
});
setAllocations(results.map(row => ({ ...row, id: row.ID, nomadAddr })));
setErr(undefined);
} catch (e) {
setAllocations([]);
setErr(e);
}
};
// Start querying for allocations every 5s
useAsync(async () => {
if (init) {
setInit(false);
query();
}
const interval = setTimeout(() => {
query();
}, 5_000);
return () => clearTimeout(interval);
}, [allocations, entity]);
// Store a ref to a potential error
if (err) {
return <ResponseErrorPanel error={err} />;
}
return (
<Table<rowType>
title="Allocations"
options={{
search: true,
padding: 'dense',
sorting: true,
draggable: false,
paging: false,
debounceInterval: 500,
filterCellStyle: { padding: '0 16px 0 20px' },
}}
columns={columns}
data={allocations}
/>
);
};
@@ -0,0 +1,152 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { DateTime } from 'luxon';
import {
InfoCard,
MissingAnnotationEmptyState,
ResponseErrorPanel,
Table,
TableColumn,
} from '@backstage/core-components';
import { useEntity } from '@backstage/plugin-catalog-react';
import React, { useEffect, useState } from 'react';
import { Version, nomadApiRef } from '../../api';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import {
NOMAD_JOB_ID_ANNOTATION,
NOMAD_NAMESPACE_ANNOTATION,
isNomadJobIDAvailable,
} from '../../Router';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import { Chip } from '@material-ui/core';
type rowType = Version & { nomadAddr: string };
const columns: TableColumn<rowType>[] = [
{
title: 'Version',
field: 'Version',
render: row => row.Version,
},
{
title: 'Stable',
field: 'Stable',
render: row => <Chip label={`${row.Stable}`} />,
},
{
title: 'Submitted',
field: 'SubmitTime',
render: row =>
DateTime.fromMillis(row.SubmitTime / 1000000).toLocaleString(
DateTime.DATETIME_MED_WITH_SECONDS,
),
},
];
/**
* EntityNomadJobVersionListCard is roughly based on the Nomad UI's versions tab.
*/
export const EntityNomadJobVersionListCard = () => {
// Wait on entity
const { entity } = useEntity();
// Get ref to the backend API
const configApi = useApi(configApiRef);
const nomadApi = useApi(nomadApiRef);
const nomadAddr = configApi.getString('nomad.addr');
// Store results of calling API
const [init, setInit] = useState(true);
const [versions, setVersions] = useState<rowType[]>([]);
const [err, setErr] = useState<Error>();
// Get plugin attributes
const namespace =
entity.metadata.annotations?.[NOMAD_NAMESPACE_ANNOTATION] ?? 'default';
const jobID = entity.metadata.annotations?.[NOMAD_JOB_ID_ANNOTATION] ?? '';
// Start querying for allocations every 10s
useEffect(() => {
// Create a query to update allocations
const query = async () => {
try {
// Make call to nomad-backend
const resp = await nomadApi.listJobVersions({ namespace, jobID });
setVersions(
resp.versions.map(row => ({ ...row, id: row.ID, nomadAddr })),
);
setErr(undefined);
} catch (e) {
setVersions([]);
setErr(e);
}
};
if (init) {
setInit(false);
query();
}
const interval = setTimeout(() => {
query();
}, 10_000);
return () => clearTimeout(interval);
}, [init, jobID, namespace, nomadAddr, nomadApi]);
// Store a ref to a potential error
if (err) {
return <ResponseErrorPanel error={err} />;
}
if (!entity) return null;
// Check that job ID is set
if (!isNomadJobIDAvailable(entity)) {
return (
<InfoCard title="Job Versions">
<MissingAnnotationEmptyState annotation={NOMAD_JOB_ID_ANNOTATION} />
</InfoCard>
);
}
return (
<Table<rowType>
title="Job versions"
actions={[
{
icon: () => <OpenInNewIcon />,
tooltip: 'Open in Nomad UI',
isFreeAction: true,
onClick: () => window.open(`${nomadAddr}/ui/jobs/${jobID}/versions`),
},
]}
options={{
search: false,
padding: 'dense',
sorting: true,
draggable: false,
paging: false,
debounceInterval: 500,
filterCellStyle: { padding: '0 16px 0 20px' },
}}
columns={columns}
data={versions}
/>
);
};
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2023 The Backstage Authors
*
* 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 { EntityNomadJobVersionListCard } from './EntityNomadJobVersionListCard/EntityNomadJobVersionListCard';
import { EntityNomadAllocationListTable } from './EntityNomadAllocationListTable/EntityNomadAllocationListTable';
export { EntityNomadJobVersionListCard, EntityNomadAllocationListTable };
+21
View File
@@ -0,0 +1,21 @@
/*
* Copyright 2023 The Backstage Authors
*
* 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.
*/
export {
nomadPlugin,
EntityNomadJobVersionListCard,
EntityNomadAllocationListTable,
} from './plugin';
export { isNomadAllocationsAvailable, isNomadJobIDAvailable } from './Router';
+34
View File
@@ -0,0 +1,34 @@
/*
* Copyright 2023 The Backstage Authors
*
* 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 {
nomadPlugin,
entityContentRouteRef,
EntityNomadContent,
} from './plugin';
describe('nomad', () => {
it('should export plugin', () => {
expect(nomadPlugin).toBeDefined();
});
it('should export Nomad entity ref', () => {
expect(entityContentRouteRef).toBeDefined();
});
it('should export Nomad entity', () => {
expect(EntityNomadContent).toBeDefined();
});
});
+87
View File
@@ -0,0 +1,87 @@
/*
* Copyright 2023 The Backstage Authors
*
* 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 {
discoveryApiRef,
fetchApiRef,
createApiFactory,
createPlugin,
createRouteRef,
createRoutableExtension,
createComponentExtension,
} from '@backstage/core-plugin-api';
import { rootRouteRef } from './routes';
import { NomadHttpApi, nomadApiRef } from './api';
export const entityContentRouteRef = createRouteRef({
id: 'nomad:entity-content',
});
/** @public */
export const nomadPlugin = createPlugin({
id: 'nomad',
apis: [
createApiFactory({
api: nomadApiRef,
deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef },
factory: ({ discoveryApi, fetchApi }) =>
NomadHttpApi.create(discoveryApi, fetchApi),
}),
],
routes: {
root: rootRouteRef,
entityContent: entityContentRouteRef,
},
});
/** @public */
export const EntityNomadContent = nomadPlugin.provide(
createRoutableExtension({
name: 'EntityNomadContent',
component: () => import('./Router').then(m => m.EmbeddedRouter),
mountPoint: entityContentRouteRef,
}),
);
/**
* Card used to show the list of Nomad job versions.
*
* @public
*/
export const EntityNomadJobVersionListCard = nomadPlugin.provide(
createComponentExtension({
name: 'EntityNomadJobVersionListCard',
component: {
lazy: () =>
import('./components').then(m => m.EntityNomadJobVersionListCard),
},
}),
);
/**
* Table used to show the list of Nomad allocations for a job and/or task-group.
*
* @public
*/
export const EntityNomadAllocationListTable = nomadPlugin.provide(
createComponentExtension({
name: 'EntityNomadAllocationListTable',
component: {
lazy: () =>
import('./components').then(m => m.EntityNomadAllocationListTable),
},
}),
);
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2023 The Backstage Authors
*
* 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 { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
id: 'nomad',
});
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2023 The Backstage Authors
*
* 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 '@testing-library/jest-dom';
import 'cross-fetch/polyfill';