diff --git a/plugins/octopus-deploy/.eslintrc.js b/plugins/octopus-deploy/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/octopus-deploy/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/octopus-deploy/README.md b/plugins/octopus-deploy/README.md new file mode 100644 index 0000000000..26f8021283 --- /dev/null +++ b/plugins/octopus-deploy/README.md @@ -0,0 +1,11 @@ +# Octopus Deploy Plugin + +Welcome to the octopus-deploy plugin! + +## Features + +- Display the deployment status of the most recent releases for a project in Octopus Deploy straight from the Backstage catalog + +## Getting started + + diff --git a/plugins/octopus-deploy/dev/index.tsx b/plugins/octopus-deploy/dev/index.tsx new file mode 100644 index 0000000000..503cbe6d12 --- /dev/null +++ b/plugins/octopus-deploy/dev/index.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { octopusDeployPlugin, OctopusDeployPage } from '../src/plugin'; + +createDevApp() + .registerPlugin(octopusDeployPlugin) + .addPage({ + element: , + title: 'Root Page', + path: '/octopus-deploy' + }) + .render(); diff --git a/plugins/octopus-deploy/package.json b/plugins/octopus-deploy/package.json new file mode 100644 index 0000000000..8486ed1a32 --- /dev/null +++ b/plugins/octopus-deploy/package.json @@ -0,0 +1,52 @@ +{ + "name": "@backstage/plugin-octopus-deploy", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/core-components": "^0.12.3", + "@backstage/core-plugin-api": "^1.3.0", + "@backstage/theme": "^0.2.16", + "@material-ui/core": "^4.9.13", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.57", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.22.1", + "@backstage/core-app-api": "^1.4.0", + "@backstage/dev-utils": "^1.0.11", + "@backstage/test-utils": "^1.2.4", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^14.0.0", + "@types/node": "*", + "msw": "^0.49.0", + "cross-fetch": "^3.1.5" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/octopus-deploy/src/api/index.ts b/plugins/octopus-deploy/src/api/index.ts new file mode 100644 index 0000000000..b066f700db --- /dev/null +++ b/plugins/octopus-deploy/src/api/index.ts @@ -0,0 +1,88 @@ +import { createApiRef, DiscoveryApi, IdentityApi } from "@backstage/core-plugin-api"; + +export type OctopusProgression = { + Environments: OctopusEnvironment[]; + Releases: OctopusReleaseProgression[]; +} + +export type OctopusEnvironment = { + Id: string, + Name: string +} + +export type OctopusReleaseProgression = { + Release: OctopusRelease + Deployments: { [key: string]: OctopusDeployment[] } +} + +export type OctopusRelease = { + Id: string + Version: string +} + +export type OctopusDeployment = { + State: string +} + +export const octopusDeployApiRef = createApiRef({ + id: 'plugin.octopusdeploy.service' +}); + +const DEFAULT_PROXY_PATH_BASE = '/octopus-deploy'; + +type Options = { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi + /** + * Path to use for requests via the proxy, defaults to /octopus-deploy + */ + proxyPathBase?: string +} + +export interface OctopusDeployApi { + getReleaseProgression(projectId: string, releaseHistoryCount: number): Promise; +} + +export class OctopusDeployClient implements OctopusDeployApi { + private readonly discoveryApi: DiscoveryApi; + private readonly identityApi: IdentityApi; + private readonly proxyPathBase: string; + + constructor(options: Options) { + this.discoveryApi = options.discoveryApi; + this.identityApi = options.identityApi; + this.proxyPathBase = options.proxyPathBase ?? DEFAULT_PROXY_PATH_BASE; + } + + async getReleaseProgression(projectId: string, releaseHistoryCount: number): Promise { + const url = await this.getApiUrl(projectId, releaseHistoryCount); + + const { token: idToken } = await this.identityApi.getCredentials(); + const response = await fetch(url, { + headers: idToken ? { Authorization: `Bearer ${idToken}` } : {}, + }); + + let responseJson; + + try { + responseJson = await response.json(); + } catch (e) { + responseJson = { releases: [] }; + } + + if (response.status !== 200) { + throw new Error( + `Error communicating with Octopus Deploy: ${ + responseJson?.error?.title || response.statusText + }`, + ); + } + + return responseJson; + } + + private async getApiUrl(projectId: string, releaseHistoryCount: number) { + const proxyUrl = await this.discoveryApi.getBaseUrl('proxy'); + return `${proxyUrl}${this.proxyPathBase}/projects/${projectId}/progression?releaseHistoryCount=${releaseHistoryCount}`; + } +} \ No newline at end of file diff --git a/plugins/octopus-deploy/src/components/EntityPageOctopusDeploy/EntityPageOctopusDeploy.tsx b/plugins/octopus-deploy/src/components/EntityPageOctopusDeploy/EntityPageOctopusDeploy.tsx new file mode 100644 index 0000000000..d9b6a001c2 --- /dev/null +++ b/plugins/octopus-deploy/src/components/EntityPageOctopusDeploy/EntityPageOctopusDeploy.tsx @@ -0,0 +1,15 @@ +import { useEntity } from '@backstage/plugin-catalog-react'; +import { useReleases } from '../../hooks/useReleases'; +import { getAnnotationFromEntity } from '../../utils/getAnnotationFromEntity'; +import React from 'react'; +import { ReleaseTable } from '../ReleaseTable'; + +export const EntityPageOctopusDeploy = (props: { defaultLimit?: number }) => { + const { entity } = useEntity(); + + const projectId = getAnnotationFromEntity(entity); + + const { environments, releases, loading, error } = useReleases(projectId, props.defaultLimit ?? 3); + + return ; +} \ No newline at end of file diff --git a/plugins/octopus-deploy/src/components/EntityPageOctopusDeploy/index.ts b/plugins/octopus-deploy/src/components/EntityPageOctopusDeploy/index.ts new file mode 100644 index 0000000000..ceed04684b --- /dev/null +++ b/plugins/octopus-deploy/src/components/EntityPageOctopusDeploy/index.ts @@ -0,0 +1 @@ +export { EntityPageOctopusDeploy } from './EntityPageOctopusDeploy'; \ No newline at end of file diff --git a/plugins/octopus-deploy/src/components/ReleaseTable/ReleaseTable.tsx b/plugins/octopus-deploy/src/components/ReleaseTable/ReleaseTable.tsx new file mode 100644 index 0000000000..21322057d3 --- /dev/null +++ b/plugins/octopus-deploy/src/components/ReleaseTable/ReleaseTable.tsx @@ -0,0 +1,123 @@ +import { Box, Typography } from "@material-ui/core"; +import { + OctopusEnvironment, + OctopusReleaseProgression +} from '../../api'; + +import React from 'react'; +import { ResponseErrorPanel, StatusAborted, StatusError, StatusOK, StatusPending, StatusRunning, StatusWarning, Table, TableColumn } from "@backstage/core-components"; + +type ReleaseTableProps = { + environments?: OctopusEnvironment[]; + releases?: OctopusReleaseProgression[]; + loading: boolean; + error?: Error +}; + +export const getDeploymentStatusComponent = (state: string | undefined) => { + switch (state) { + case "Success": + return ( + + Success + + ); + case "Queued": + return ( + + Queued + + ); + case "Executing": + return ( + + Executing + + ); + case "Failed": + return ( + + Failed + + ); + case "Cancelling": + return ( + + Cancelling + + ); + case "Canceled": + return ( + + Canceled + + ); + case "TimedOut": + return ( + + Timed Out + + ); + default: + return ( + + Unknown + + ); + } +} + +export const ReleaseTable = ({environments, releases, loading, error}: ReleaseTableProps) => { + if (error) { + return ( +
+ +
+ ); + } + + const columns: TableColumn[] = [ + { + title: 'Version', + field: 'Release.Version', + highlight: false, + width: 'auto' + }, + ...environments?.map(env => ({ + title: env.Name, + width: 'auto', + render: (row: Partial) => { + const deploymentsForEnvironment = row.Deployments[env.Id]; + if (deploymentsForEnvironment) { + return ( + + + {getDeploymentStatusComponent(deploymentsForEnvironment[0].State)} + + + ) + } + } + //field: `Deployments.${env.Id}[0].State` // TODO: We'll probably need to deal with multi-tenant deployments here + })) ?? [] + ] + + return ( + + Octopus Deploy - Releases ({releases ? releases.length : 0}) + + } + data={releases ?? []} + /> + ) +} \ No newline at end of file diff --git a/plugins/octopus-deploy/src/components/ReleaseTable/index.ts b/plugins/octopus-deploy/src/components/ReleaseTable/index.ts new file mode 100644 index 0000000000..bd0b5bb6b1 --- /dev/null +++ b/plugins/octopus-deploy/src/components/ReleaseTable/index.ts @@ -0,0 +1 @@ +export { ReleaseTable } from './ReleaseTable'; \ No newline at end of file diff --git a/plugins/octopus-deploy/src/constants.ts b/plugins/octopus-deploy/src/constants.ts new file mode 100644 index 0000000000..cec3804d9d --- /dev/null +++ b/plugins/octopus-deploy/src/constants.ts @@ -0,0 +1 @@ +export const OCTOPUS_DEPLOY_PROJECT_ID_ANNOTATION = 'octopus.com/project-id'; \ No newline at end of file diff --git a/plugins/octopus-deploy/src/hooks/useReleases.ts b/plugins/octopus-deploy/src/hooks/useReleases.ts new file mode 100644 index 0000000000..ce38bfb286 --- /dev/null +++ b/plugins/octopus-deploy/src/hooks/useReleases.ts @@ -0,0 +1,26 @@ +import { useApi } from "@backstage/core-plugin-api"; +import useAsync from "react-use/lib/useAsync"; +import { octopusDeployApiRef, OctopusEnvironment, OctopusReleaseProgression } from "../api"; + +export function useReleases( + projectId: string, + releaseHistoryCount: number +): { + environments?: OctopusEnvironment[], + releases?: OctopusReleaseProgression[] + loading: boolean; + error?: Error; +} { + const api = useApi(octopusDeployApiRef); + + const { value, loading, error } = useAsync(() => { + return api.getReleaseProgression(projectId, releaseHistoryCount); + }, [ api, projectId, releaseHistoryCount ]); + + return { + environments: value?.Environments, + releases: value?.Releases, + loading, + error + }; +} \ No newline at end of file diff --git a/plugins/octopus-deploy/src/index.ts b/plugins/octopus-deploy/src/index.ts new file mode 100644 index 0000000000..9573438e87 --- /dev/null +++ b/plugins/octopus-deploy/src/index.ts @@ -0,0 +1,7 @@ +export { + octopusDeployPlugin, + EntityOctopusDeployContent, + isOctopusDeployAvailable + } from './plugin'; + + export * from './api'; diff --git a/plugins/octopus-deploy/src/plugin.test.ts b/plugins/octopus-deploy/src/plugin.test.ts new file mode 100644 index 0000000000..ec8e3f260d --- /dev/null +++ b/plugins/octopus-deploy/src/plugin.test.ts @@ -0,0 +1,7 @@ +import { octopusDeployPlugin } from './plugin'; + +describe('octopus-deploy', () => { + it('should export plugin', () => { + expect(octopusDeployPlugin).toBeDefined(); + }); +}); diff --git a/plugins/octopus-deploy/src/plugin.ts b/plugins/octopus-deploy/src/plugin.ts new file mode 100644 index 0000000000..c73f4b3aed --- /dev/null +++ b/plugins/octopus-deploy/src/plugin.ts @@ -0,0 +1,49 @@ +import { + OCTOPUS_DEPLOY_PROJECT_ID_ANNOTATION +} from './constants'; +import { + octopusDeployEntityContentRouteRef +} from './routes'; + +import { OctopusDeployClient, octopusDeployApiRef } from './api'; + +import { createApiFactory, createPlugin, createRoutableExtension, discoveryApiRef, identityApiRef } from '@backstage/core-plugin-api'; + +import { Entity } from '@backstage/catalog-model'; + +/** @public */ +export const isOctopusDeployAvailable = (entity: Entity) => + Boolean(entity.metadata.annotations?.[OCTOPUS_DEPLOY_PROJECT_ID_ANNOTATION]); + +export const octopusDeployPlugin = createPlugin({ + id: 'octopus-deploy', + apis: [ + createApiFactory({ + api: octopusDeployApiRef, + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => new OctopusDeployClient({ discoveryApi, identityApi }) + }) + ] +}); + +/* +export const OctopusDeployPage = octopusDeployPlugin.provide( + createRoutableExtension({ + name: 'OctopusDeployPage', + component: () => + import('./components/ExampleComponent').then(m => m.ExampleComponent), + mountPoint: rootRouteRef, + }), +); +*/ + +export const EntityOctopusDeployContent = octopusDeployPlugin.provide( + createRoutableExtension({ + name: 'EntityOctopusDeployContent', + component: () => + import('./components/EntityPageOctopusDeploy').then( + m => m.EntityPageOctopusDeploy + ), + mountPoint: octopusDeployEntityContentRouteRef + }) +) \ No newline at end of file diff --git a/plugins/octopus-deploy/src/routes.ts b/plugins/octopus-deploy/src/routes.ts new file mode 100644 index 0000000000..772db37cb6 --- /dev/null +++ b/plugins/octopus-deploy/src/routes.ts @@ -0,0 +1,5 @@ +import { createRouteRef } from '@backstage/core-plugin-api'; + +export const octopusDeployEntityContentRouteRef = createRouteRef({ + id: 'octopus-deploy-entity-content' +}) diff --git a/plugins/octopus-deploy/src/setupTests.ts b/plugins/octopus-deploy/src/setupTests.ts new file mode 100644 index 0000000000..48c09b5346 --- /dev/null +++ b/plugins/octopus-deploy/src/setupTests.ts @@ -0,0 +1,2 @@ +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/plugins/octopus-deploy/src/utils/getAnnotationFromEntity.ts b/plugins/octopus-deploy/src/utils/getAnnotationFromEntity.ts new file mode 100644 index 0000000000..b1a8984163 --- /dev/null +++ b/plugins/octopus-deploy/src/utils/getAnnotationFromEntity.ts @@ -0,0 +1,14 @@ +import { + OCTOPUS_DEPLOY_PROJECT_ID_ANNOTATION +} from '../constants'; + +import { Entity } from '@backstage/catalog-model'; + +export function getAnnotationFromEntity(entity: Entity): string { + const annotation = entity.metadata.annotations?.[OCTOPUS_DEPLOY_PROJECT_ID_ANNOTATION]; + if (!annotation) { + throw new Error('Value for annotation octopus.com/project-id was not found'); + } + + return annotation; +} \ No newline at end of file