diff --git a/.changeset/friendly-glasses-sleep.md b/.changeset/friendly-glasses-sleep.md new file mode 100644 index 0000000000..fa890eab06 --- /dev/null +++ b/.changeset/friendly-glasses-sleep.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-nomad-backend': minor +'@backstage/plugin-nomad': minor +--- + +Initialize Nomad plugin with frontend and backend. List jobs/allocations diff --git a/packages/app/package.json b/packages/app/package.json index efa1faf6c5..75aa8e5afc 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -50,6 +50,7 @@ "@backstage/plugin-microsoft-calendar": "workspace:^", "@backstage/plugin-newrelic": "workspace:^", "@backstage/plugin-newrelic-dashboard": "workspace:^", + "@backstage/plugin-nomad": "workspace:^", "@backstage/plugin-octopus-deploy": "workspace:^", "@backstage/plugin-org": "workspace:^", "@backstage/plugin-pagerduty": "workspace:^", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index ca6d09e318..be5ae3e3bf 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -112,6 +112,12 @@ import { EntityOwnershipCard, EntityUserProfileCard, } from '@backstage/plugin-org'; +import { + EntityNomadAllocationListTable, + EntityNomadJobVersionListCard, + isNomadAllocationsAvailable, + isNomadJobIDAvailable, +} from '@backstage/plugin-nomad'; import { EntityPagerDutyCard, isPagerDutyAvailable, @@ -442,6 +448,14 @@ const overviewContent = ( + + + + + + + + ); @@ -501,6 +515,14 @@ const serviceEntityPage = ( + + + + {pullRequestsContent} diff --git a/packages/backend/package.json b/packages/backend/package.json index 91c7e2cdd5..85aafc0f1c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -54,6 +54,7 @@ "@backstage/plugin-kubernetes-backend": "workspace:^", "@backstage/plugin-lighthouse-backend": "workspace:^", "@backstage/plugin-linguist-backend": "workspace:^", + "@backstage/plugin-nomad-backend": "workspace:^", "@backstage/plugin-permission-backend": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", "@backstage/plugin-permission-node": "workspace:^", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 5d22ec92e8..54f0f98e41 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -65,6 +65,7 @@ import adr from './plugins/adr'; import lighthouse from './plugins/lighthouse'; import linguist from './plugins/linguist'; import devTools from './plugins/devtools'; +import nomad from './plugins/nomad'; import { PluginEnvironment } from './types'; import { ServerPermissionClient } from '@backstage/plugin-permission-node'; import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; @@ -172,6 +173,7 @@ async function main() { const lighthouseEnv = useHotMemoize(module, () => createEnv('lighthouse')); const linguistEnv = useHotMemoize(module, () => createEnv('linguist')); const devToolsEnv = useHotMemoize(module, () => createEnv('devtools')); + const nomadEnv = useHotMemoize(module, () => createEnv('nomad')); const apiRouter = Router(); apiRouter.use('/catalog', await catalog(catalogEnv)); @@ -198,6 +200,7 @@ async function main() { apiRouter.use('/adr', await adr(adrEnv)); apiRouter.use('/linguist', await linguist(linguistEnv)); apiRouter.use('/devtools', await devTools(devToolsEnv)); + apiRouter.use('/nomad', await nomad(nomadEnv)); apiRouter.use(notFoundHandler()); await lighthouse(lighthouseEnv); diff --git a/packages/backend/src/plugins/nomad.ts b/packages/backend/src/plugins/nomad.ts new file mode 100644 index 0000000000..111cbfbf0d --- /dev/null +++ b/packages/backend/src/plugins/nomad.ts @@ -0,0 +1,24 @@ +/* + * 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 { createRouter } from '@backstage/plugin-nomad-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin( + props: PluginEnvironment, +): Promise { + return await createRouter(props); +} diff --git a/plugins/nomad-backend/.eslintrc.js b/plugins/nomad-backend/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/nomad-backend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/nomad-backend/README.md b/plugins/nomad-backend/README.md new file mode 100644 index 0000000000..d06444809e --- /dev/null +++ b/plugins/nomad-backend/README.md @@ -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 { + 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. diff --git a/plugins/nomad-backend/api-report.md b/plugins/nomad-backend/api-report.md new file mode 100644 index 0000000000..5b56661e43 --- /dev/null +++ b/plugins/nomad-backend/api-report.md @@ -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; + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + config: Config; + // (undocumented) + logger: Logger; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/nomad-backend/package.json b/plugins/nomad-backend/package.json new file mode 100644 index 0000000000..344d120001 --- /dev/null +++ b/plugins/nomad-backend/package.json @@ -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" + ] +} diff --git a/plugins/nomad-backend/src/index.ts b/plugins/nomad-backend/src/index.ts new file mode 100644 index 0000000000..d2e8d61bad --- /dev/null +++ b/plugins/nomad-backend/src/index.ts @@ -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'; diff --git a/plugins/nomad-backend/src/run.ts b/plugins/nomad-backend/src/run.ts new file mode 100644 index 0000000000..711bdcc46c --- /dev/null +++ b/plugins/nomad-backend/src/run.ts @@ -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); +}); diff --git a/plugins/nomad-backend/src/service/router.test.ts b/plugins/nomad-backend/src/service/router.test.ts new file mode 100644 index 0000000000..11c3834ed6 --- /dev/null +++ b/plugins/nomad-backend/src/service/router.test.ts @@ -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' }); + }); + }); +}); diff --git a/plugins/nomad-backend/src/service/router.ts b/plugins/nomad-backend/src/service/router.ts new file mode 100644 index 0000000000..f9355b27d0 --- /dev/null +++ b/plugins/nomad-backend/src/service/router.ts @@ -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 { + 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; +} diff --git a/plugins/nomad-backend/src/service/standaloneServer.ts b/plugins/nomad-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..133577ffc2 --- /dev/null +++ b/plugins/nomad-backend/src/service/standaloneServer.ts @@ -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 { + 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(); diff --git a/plugins/nomad-backend/src/setupTests.ts b/plugins/nomad-backend/src/setupTests.ts new file mode 100644 index 0000000000..4b9026cde5 --- /dev/null +++ b/plugins/nomad-backend/src/setupTests.ts @@ -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 {}; diff --git a/plugins/nomad/.eslintrc.js b/plugins/nomad/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/nomad/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/nomad/README.md b/plugins/nomad/README.md new file mode 100644 index 0000000000..da14a0a3f3 --- /dev/null +++ b/plugins/nomad/README.md @@ -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 = ( + ... + + + + + + + +); +``` + +
+ +
+ +#### 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 = ( + ... + + + +) +``` + +
+ +
+ +#### 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 = +Create Index = 54 +Modify Index = 54 +Policies = [backstage] + +Roles + +``` + +In the example above, the `Secret ID` is the `token` to use in the [configuration](#configuration). diff --git a/plugins/nomad/api-report.md b/plugins/nomad/api-report.md new file mode 100644 index 0000000000..f000854374 --- /dev/null +++ b/plugins/nomad/api-report.md @@ -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 +/// + +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; + entityContent: RouteRef; + }, + {}, + {} +>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/nomad/dev/index.tsx b/plugins/nomad/dev/index.tsx new file mode 100644 index 0000000000..73eaffb8e0 --- /dev/null +++ b/plugins/nomad/dev/index.tsx @@ -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(); diff --git a/plugins/nomad/img/allocations-component.png b/plugins/nomad/img/allocations-component.png new file mode 100644 index 0000000000..fb1e9768af Binary files /dev/null and b/plugins/nomad/img/allocations-component.png differ diff --git a/plugins/nomad/img/job-versions-component.png b/plugins/nomad/img/job-versions-component.png new file mode 100644 index 0000000000..d6c9746bcb Binary files /dev/null and b/plugins/nomad/img/job-versions-component.png differ diff --git a/plugins/nomad/package.json b/plugins/nomad/package.json new file mode 100644 index 0000000000..7501aabf31 --- /dev/null +++ b/plugins/nomad/package.json @@ -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" + ] + } + } + } +} diff --git a/plugins/nomad/src/Router.tsx b/plugins/nomad/src/Router.tsx new file mode 100644 index 0000000000..d7e6db1b44 --- /dev/null +++ b/plugins/nomad/src/Router.tsx @@ -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 ( + + ); + } + + return ( + + } /> + + ); +}; diff --git a/plugins/nomad/src/api.ts b/plugins/nomad/src/api.ts new file mode 100644 index 0000000000..44aa5d48d2 --- /dev/null +++ b/plugins/nomad/src/api.ts @@ -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({ + 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; + + /** + * 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; +}; + +/** @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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/plugins/nomad/src/components/EntityNomadAllocationListTable/EntityNomadAllocationListTable.tsx b/plugins/nomad/src/components/EntityNomadAllocationListTable/EntityNomadAllocationListTable.tsx new file mode 100644 index 0000000000..6d7a79c002 --- /dev/null +++ b/plugins/nomad/src/components/EntityNomadAllocationListTable/EntityNomadAllocationListTable.tsx @@ -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[] = [ + { + title: 'ID', + field: 'ID', + render: row => ( + + {row.ID.split('-')[0]} + + ), + }, + { + title: 'Task Group', + field: 'TaskGroup', + render: row => ( + + {row.TaskGroup} + + ), + }, + { + title: 'Created', + field: 'CreateTime', + render: row => + DateTime.fromMillis(row.CreateTime / 1000000).toLocaleString( + DateTime.DATETIME_MED_WITH_SECONDS, + ), + }, + { + title: 'Status', + field: 'ClientStatus', + render: row => + ({ + pending: pending, + running: running, + failed: failed, + complete: complete, + }[row.ClientStatus] || {row.ClientStatus}), + }, + { + title: 'Version', + field: 'JobVersion', + render: row => row.JobVersion, + }, + { + title: 'Client', + field: 'NodeID', + render: row => ( + + {row.ID.split('-')[0]} + + ), + }, +]; + +/** + * 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([]); + const [err, setErr] = useState(); + + // Check that attributes are available + if (!isNomadAllocationsAvailable(entity)) { + ; + } + + // 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 ; + } + + return ( + + 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} + /> + ); +}; diff --git a/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx b/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx new file mode 100644 index 0000000000..0c1863b95e --- /dev/null +++ b/plugins/nomad/src/components/EntityNomadJobVersionListCard/EntityNomadJobVersionListCard.tsx @@ -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[] = [ + { + title: 'Version', + field: 'Version', + render: row => row.Version, + }, + { + title: 'Stable', + field: 'Stable', + render: row => , + }, + { + 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([]); + const [err, setErr] = useState(); + + // 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 ; + } + + if (!entity) return null; + + // Check that job ID is set + if (!isNomadJobIDAvailable(entity)) { + return ( + + + + ); + } + + return ( + + title="Job versions" + actions={[ + { + icon: () => , + 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} + /> + ); +}; diff --git a/plugins/nomad/src/components/index.ts b/plugins/nomad/src/components/index.ts new file mode 100644 index 0000000000..3951061b23 --- /dev/null +++ b/plugins/nomad/src/components/index.ts @@ -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 }; diff --git a/plugins/nomad/src/index.ts b/plugins/nomad/src/index.ts new file mode 100644 index 0000000000..3785a5c7b5 --- /dev/null +++ b/plugins/nomad/src/index.ts @@ -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'; diff --git a/plugins/nomad/src/plugin.test.ts b/plugins/nomad/src/plugin.test.ts new file mode 100644 index 0000000000..def0f8ab89 --- /dev/null +++ b/plugins/nomad/src/plugin.test.ts @@ -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(); + }); +}); diff --git a/plugins/nomad/src/plugin.ts b/plugins/nomad/src/plugin.ts new file mode 100644 index 0000000000..df89785941 --- /dev/null +++ b/plugins/nomad/src/plugin.ts @@ -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), + }, + }), +); diff --git a/plugins/nomad/src/routes.ts b/plugins/nomad/src/routes.ts new file mode 100644 index 0000000000..12e8caf4eb --- /dev/null +++ b/plugins/nomad/src/routes.ts @@ -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', +}); diff --git a/plugins/nomad/src/setupTests.ts b/plugins/nomad/src/setupTests.ts new file mode 100644 index 0000000000..73dd8dce47 --- /dev/null +++ b/plugins/nomad/src/setupTests.ts @@ -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'; diff --git a/yarn.lock b/yarn.lock index d0b99c2b28..3bda8a89ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7939,6 +7939,57 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-nomad-backend@workspace:^, @backstage/plugin-nomad-backend@workspace:plugins/nomad-backend": + version: 0.0.0-use.local + resolution: "@backstage/plugin-nomad-backend@workspace:plugins/nomad-backend" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/errors": "workspace:^" + "@types/express": "*" + "@types/supertest": ^2.0.8 + express: ^4.17.1 + express-promise-router: ^4.1.0 + msw: ^1.0.0 + node-fetch: ^2.6.7 + supertest: ^6.2.4 + winston: ^3.2.1 + yn: ^4.0.0 + languageName: unknown + linkType: soft + +"@backstage/plugin-nomad@workspace:^, @backstage/plugin-nomad@workspace:plugins/nomad": + version: 0.0.0-use.local + resolution: "@backstage/plugin-nomad@workspace:plugins/nomad" + dependencies: + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/dev-utils": "workspace:^" + "@backstage/plugin-catalog-react": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/theme": "workspace:^" + "@material-ui/core": ^4.9.13 + "@material-ui/icons": ^4.9.1 + "@material-ui/lab": ^4.0.0-alpha.60 + "@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 + luxon: ^3.3.0 + msw: ^1.0.0 + react-use: ^17.2.4 + peerDependencies: + react: ^16.13.1 || ^17.0.0 + react-router-dom: "*" + languageName: unknown + linkType: soft + "@backstage/plugin-octopus-deploy@workspace:^, @backstage/plugin-octopus-deploy@workspace:plugins/octopus-deploy": version: 0.0.0-use.local resolution: "@backstage/plugin-octopus-deploy@workspace:plugins/octopus-deploy" @@ -24577,6 +24628,7 @@ __metadata: "@backstage/plugin-microsoft-calendar": "workspace:^" "@backstage/plugin-newrelic": "workspace:^" "@backstage/plugin-newrelic-dashboard": "workspace:^" + "@backstage/plugin-nomad": "workspace:^" "@backstage/plugin-octopus-deploy": "workspace:^" "@backstage/plugin-org": "workspace:^" "@backstage/plugin-pagerduty": "workspace:^" @@ -24695,6 +24747,7 @@ __metadata: "@backstage/plugin-kubernetes-backend": "workspace:^" "@backstage/plugin-lighthouse-backend": "workspace:^" "@backstage/plugin-linguist-backend": "workspace:^" + "@backstage/plugin-nomad-backend": "workspace:^" "@backstage/plugin-permission-backend": "workspace:^" "@backstage/plugin-permission-common": "workspace:^" "@backstage/plugin-permission-node": "workspace:^"