From 9372f8e9ef2bb4933f50970845abbd1211bb4ead Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Fri, 19 Jul 2024 14:42:38 -0700 Subject: [PATCH] Add support for local-debuging instance of Radius This existing logic in the dashboard only knows how to connect to Radius when it's running inside a Kubernetes cluster. This commit makes the connection logic configurable, and adds example configuration that works with a locally installed build of Radius. Signed-off-by: Ryan Nowak --- .github/workflows/build.yaml | 6 + app-config.dashboard.yaml | 5 + app-config.local.yaml | 17 ++ plugins/plugin-radius/config.d.ts | 17 ++ plugins/plugin-radius/package.json | 6 +- plugins/plugin-radius/src/api/api.test.ts | 229 ++++++++++++---------- plugins/plugin-radius/src/api/api.ts | 205 ++++++++++--------- plugins/plugin-radius/src/plugin.ts | 16 +- 8 files changed, 299 insertions(+), 202 deletions(-) create mode 100644 plugins/plugin-radius/config.d.ts diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0a5fd8a..cd27053 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -43,6 +43,12 @@ jobs: - name: Format if: ${{ env.CI_LINT == 'true' }} run: yarn run format:check + - name: Link Configuration + if: ${{ env.CI_LINT == 'true' }} + run: | + yarn backstage-cli config:check --config app-config.dashboard.yaml + yarn backstage-cli config:check --config app-config.dev.yaml + yarn backstage-cli config:check --config app-config.local.yaml - name: Build TypeScript run: yarn run tsc - name: Build diff --git a/app-config.dashboard.yaml b/app-config.dashboard.yaml index 54fff4e..3fee673 100644 --- a/app-config.dashboard.yaml +++ b/app-config.dashboard.yaml @@ -32,3 +32,8 @@ catalog: # See https://backstage.io/docs/features/software-catalog/#adding-components-to-the-catalog for more details # on how to get entities into the catalog. locations: [] + +radius: + # Find Radius inside the Kubernetes cluster. + connection: + kind: kubernetes \ No newline at end of file diff --git a/app-config.local.yaml b/app-config.local.yaml index f7e07c6..c30fc75 100644 --- a/app-config.local.yaml +++ b/app-config.local.yaml @@ -20,9 +20,26 @@ kubernetes: clusterLocatorMethods: - type: localKubectlProxy +proxy: + endpoints: + # Proxy to a local Radius instance. + '/radius': http://localhost:9000 + +radius: + # Find Radius inside the Kubernetes cluster by default. + # connection: + # kind: kubernetes + # url: '' + # Optionally uncomment the following block to use a local debug instance of Radius. + connection: + kind: direct + + backend: # Allow the backend to make requests to the local Kubernetes cluster. reading: allow: - host: localhost:8001 - host: 127.0.0.1:8001 + - host: localhost:9000 + - host: 127.0.0.1:9000 diff --git a/plugins/plugin-radius/config.d.ts b/plugins/plugin-radius/config.d.ts new file mode 100644 index 0000000..9d28815 --- /dev/null +++ b/plugins/plugin-radius/config.d.ts @@ -0,0 +1,17 @@ +export interface Config { + /** + * Radius plugin configuration + */ + radius: { + /** + * Configuration for the connection to Radius. + */ + connection: { + /** + * Kind of connection to Radius. Use `direct` for direct connection to Radius server, or `kubernetes` for connection to Radius server running in Kubernetes. + * @visibility frontend + */ + kind: "direct" | "kubernetes"; + }; + }; +} diff --git a/plugins/plugin-radius/package.json b/plugins/plugin-radius/package.json index c747cd2..f0509de 100644 --- a/plugins/plugin-radius/package.json +++ b/plugins/plugin-radius/package.json @@ -56,6 +56,8 @@ "msw": "^1.0.0" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/plugin-radius/src/api/api.test.ts b/plugins/plugin-radius/src/api/api.test.ts index cb39457..4194c7f 100644 --- a/plugins/plugin-radius/src/api/api.test.ts +++ b/plugins/plugin-radius/src/api/api.test.ts @@ -1,131 +1,152 @@ -import { makePath, makePathForId, RadiusApiImpl } from './api'; +import { Connection, KubernetesConnection, RadiusApiImpl } from "./api"; -describe('makePath', () => { - it('makes path for scopes', () => { - const path = makePath({ scopes: [{ type: 'radius', value: 'local' }] }); - expect(path).toEqual(makePathForId('/planes/radius/local')); - }); - it('makes path for multiple scopes', () => { - const path = makePath({ - scopes: [ - { type: 'radius', value: 'local' }, - { - type: 'resourceGroups', - value: 'test-group', +describe("KubernetesConnection", () => { + it("selectCluster returns first cluster", async () => { + const connection = new KubernetesConnection({ + getClusters: async () => [ + { name: "test-cluster1", authProvider: "test" }, + { + name: "test-cluster2", + authProvider: "test", + }, + ], + proxy: async () => { + throw new Error("not implemented"); }, - ], - }); - expect(path).toEqual( - makePathForId('/planes/radius/local/resourceGroups/test-group'), - ); + }); + expect(await connection.selectCluster()).toEqual("test-cluster1"); }); - it('makes path for scope list', () => { - const path = makePath({ - scopes: [ - { type: 'radius', value: 'local' }, - { - type: 'resourceGroups', +}) + +describe("RadiusApi", () => { + const makeApi = ( + mocks?: { + connection?: Connection; + }, + ) => { + return new RadiusApiImpl( + mocks?.connection ?? { + send() { + throw new Error(`Not implemented`); }, - ], + }, + ); + }; + + describe("makePath", () => { + const api = makeApi(); + + it("makes path for scopes", () => { + const path = api.makePath({ + scopes: [{ type: "radius", value: "local" }], + }); + expect(path).toEqual(api.makePathForId("/planes/radius/local")); }); - expect(path).toEqual(makePathForId('/planes/radius/local/resourceGroups')); - }); - it('makes path for scope action', () => { - const path = makePath({ - scopes: [{ type: 'radius', value: 'local' }], - action: 'action', + it("makes path for multiple scopes", () => { + const path = api.makePath({ + scopes: [ + { type: "radius", value: "local" }, + { + type: "resourceGroups", + value: "test-group", + }, + ], + }); + expect(path).toEqual( + api.makePathForId("/planes/radius/local/resourceGroups/test-group"), + ); }); - expect(path).toEqual(makePathForId('/planes/radius/local/action')); - }); - it('makes path for resource type without name', () => { - const path = makePath({ - scopes: [{ type: 'radius', value: 'local' }], - type: 'Applications.Core/applications', + it("makes path for scope list", () => { + const path = api.makePath({ + scopes: [ + { type: "radius", value: "local" }, + { + type: "resourceGroups", + }, + ], + }); + expect(path).toEqual( + api.makePathForId("/planes/radius/local/resourceGroups"), + ); }); - expect(path).toEqual( - makePathForId( - '/planes/radius/local/providers/Applications.Core/applications', - ), - ); - }); - it('makes path for resource type with name', () => { - const path = makePath({ - scopes: [{ type: 'radius', value: 'local' }], - type: 'Applications.Core/applications', - name: 'test-app', + it("makes path for scope action", () => { + const path = api.makePath({ + scopes: [{ type: "radius", value: "local" }], + action: "action", + }); + expect(path).toEqual(api.makePathForId("/planes/radius/local/action")); }); - expect(path).toEqual( - makePathForId( - '/planes/radius/local/providers/Applications.Core/applications/test-app', - ), - ); - }); - it('makes path for resource type with name and action', () => { - const path = makePath({ - scopes: [{ type: 'radius', value: 'local' }], - type: 'Applications.Core/applications', - name: 'test-app', - action: 'restart', + it("makes path for resource type without name", () => { + const path = api.makePath({ + scopes: [{ type: "radius", value: "local" }], + type: "Applications.Core/applications", + }); + expect(path).toEqual( + api.makePathForId( + "/planes/radius/local/providers/Applications.Core/applications", + ), + ); }); - expect(path).toEqual( - makePathForId( - '/planes/radius/local/providers/Applications.Core/applications/test-app/restart', - ), - ); - }); -}); - -describe('RadiusApi', () => { - it('selectCluster returns first cluster', async () => { - const api = new RadiusApiImpl({ - getClusters: async () => [ - { name: 'test-cluster1', authProvider: 'test' }, - { - name: 'test-cluster2', - authProvider: 'test', - }, - ], - proxy: async () => { - throw new Error('not implemented'); - }, + it("makes path for resource type with name", () => { + const path = api.makePath({ + scopes: [{ type: "radius", value: "local" }], + type: "Applications.Core/applications", + name: "test-app", + }); + expect(path).toEqual( + api.makePathForId( + "/planes/radius/local/providers/Applications.Core/applications/test-app", + ), + ); + }); + it("makes path for resource type with name and action", () => { + const path = api.makePath({ + scopes: [{ type: "radius", value: "local" }], + type: "Applications.Core/applications", + name: "test-app", + action: "restart", + }); + expect(path).toEqual( + api.makePathForId( + "/planes/radius/local/providers/Applications.Core/applications/test-app/restart", + ), + ); }); - // eslint-disable-next-line dot-notation - expect(await api['selectCluster']()).toEqual('test-cluster1'); }); - it('makeRequest handles errors', async () => { - const api = new RadiusApiImpl({ - getClusters: async () => { - throw new Error('not implemented'); + + it("makeRequest handles errors", async () => { + const api = makeApi({ + connection: { + send: async () => + Promise.resolve(new Response("test", { status: 404 })), }, - proxy: async () => Promise.resolve(new Response('test', { status: 404 })), }); // eslint-disable-next-line dot-notation - await expect(api['makeRequest']('cluster', 'path')).rejects.toThrow( - 'Request failed: 404:\n\ntest', + await expect(api["makeRequest"]("path")).rejects.toThrow( + "Request failed: 404:\n\ntest", ); }); - it('makeRequest expects JSON', async () => { - const api = new RadiusApiImpl({ - getClusters: async () => { - throw new Error('not implemented'); + it("makeRequest expects JSON", async () => { + const api = makeApi({ + connection: { + send: async () => Promise.resolve(new Response("test")), }, - proxy: async () => Promise.resolve(new Response('test')), }); // eslint-disable-next-line dot-notation - await expect(api['makeRequest']('cluster', 'path')).rejects.toThrow( - 'invalid json response body at reason: Unexpected token \'e\', "test" is not valid JSON', + await expect(api["makeRequest"]("path")).rejects.toThrow( + "Request was not json: 200:\n\ntest", ); }); - it('makeRequest parses JSON', async () => { - const api = new RadiusApiImpl({ - getClusters: async () => { - throw new Error('not implemented'); + it("makeRequest parses JSON", async () => { + const api = makeApi({ + connection: { + send: async () => + Promise.resolve(new Response('{ "message": "test" }')), }, - proxy: async () => Promise.resolve(new Response('{ "message": "test" }')), }); // eslint-disable-next-line dot-notation - await expect(api['makeRequest']('cluster', 'path')).resolves.toEqual({ - message: 'test', + await expect(api["makeRequest"]("path")).resolves.toEqual({ + message: "test", }); }); }); diff --git a/plugins/plugin-radius/src/api/api.ts b/plugins/plugin-radius/src/api/api.ts index 6e07a79..c49e24c 100644 --- a/plugins/plugin-radius/src/api/api.ts +++ b/plugins/plugin-radius/src/api/api.ts @@ -1,10 +1,53 @@ -import { KubernetesApi } from '@backstage/plugin-kubernetes'; +import { KubernetesApi } from "@backstage/plugin-kubernetes"; import { ApplicationProperties, EnvironmentProperties, Resource, ResourceList, -} from '../resources'; +} from "../resources"; + +export interface Connection { + send(path: string, init?: RequestInit): Promise; +} + +export class DirectConnection implements Connection { + constructor(private readonly baseUrl: string) { + } + + send(path: string, init?: RequestInit): Promise { + const combined = `${this.baseUrl}/${path}`; + return fetch(combined, init); + } +} + +const pathPrefix = "/apis/api.ucp.dev/v1alpha3"; + +export class KubernetesConnection implements Connection { + constructor( + private readonly kubernetesApi: Pick< + KubernetesApi, + "getClusters" | "proxy" + >, + ) { + } + async send(path: string, init?: RequestInit): Promise { + const clusterName = await this.selectCluster(); + return this.kubernetesApi.proxy({ + clusterName: clusterName, + path: `${pathPrefix}/${path}`, + init: init, + }); + } + + async selectCluster(): Promise { + const clusters = await this.kubernetesApi.getClusters(); + for (const cluster of clusters) { + return cluster.name; + } + + throw new Error("No kubernetes clusters found"); + } +} export interface RadiusApi { getResourceById(opts: { @@ -23,70 +66,61 @@ export interface RadiusApi { }): Promise>; } -const pathPrefix = '/apis/api.ucp.dev/v1alpha3'; -const apiVersion = '?api-version=2023-10-01-preview'; - -export const makePathForId = (id: string) => { - return `${pathPrefix}${id}${apiVersion}`; -}; - -export const makePath = ({ - scopes, - type, - name, - action, -}: { - scopes: { type: string; value?: string }[]; - type?: string; - name?: string; - action?: string; -}) => { - const scopePart = scopes - .map(s => { - if (s.value) { - return `${s.type}/${s.value}`; - } - - return s.type; - }) - .join('/'); - const typePart = type ? `/providers/${type}` : ''; - const namePart = name ? `/${name}` : ''; - const actionPart = action ? `/${action}` : ''; - const id = `/planes/${scopePart}${typePart}${namePart}${actionPart}`; - return makePathForId(id); -}; +const apiVersion = "?api-version=2023-10-01-preview"; export class RadiusApiImpl implements RadiusApi { - constructor( - private readonly kubernetesApi: Pick< - KubernetesApi, - 'getClusters' | 'proxy' - >, - ) {} + constructor(private readonly connection: Connection) {} + + makePathForId(id: string): string { + return `${id}${apiVersion}`; + } + + makePath({ + scopes, + type, + name, + action, + }: { + scopes: { type: string; value?: string }[]; + type?: string; + name?: string; + action?: string; + }): string { + const scopePart = scopes + .map((s) => { + if (s.value) { + return `${s.type}/${s.value}`; + } + + return s.type; + }) + .join("/"); + const typePart = type ? `/providers/${type}` : ""; + const namePart = name ? `/${name}` : ""; + const actionPart = action ? `/${action}` : ""; + const id = `/planes/${scopePart}${typePart}${namePart}${actionPart}`; + return this.makePathForId(id); + } async listResources( opts?: { resourceType?: string; resourceGroup?: string } | undefined, ): Promise> { - const cluster = await this.selectCluster(); - // Fast path for listing resources of a specific type. if (opts?.resourceType) { - const path = makePath({ + const path = this.makePath({ scopes: this.makeScopes(opts), type: opts.resourceType, }); - return this.makeRequest>(cluster, path); + return this.makeRequest>(path); } // no way to list resources in all groups yet :-/. Let's do the O(n) thing for now. const groups = await this.makeRequest>>( - cluster, - makePath({ + this.makePath({ scopes: [ - { type: 'radius', value: 'local' }, + { type: "radius", value: "local" }, { - type: 'resourceGroups', + type: "resourceGroups", }, ], }), @@ -95,24 +129,24 @@ export class RadiusApiImpl implements RadiusApi { for (const group of groups.value) { // Unfortunately we don't include all of the relevant properties in tracked resources. // This is inefficient, but we'll have to do it for now. - const path = makePath({ + const path = this.makePath({ scopes: [ - { type: 'radius', value: 'local' }, + { type: "radius", value: "local" }, { - type: 'resourceGroups', + type: "resourceGroups", value: group.name, }, ], - action: 'resources', + action: "resources", }); const groupResources = await this.makeRequest< ResourceList> - >(cluster, path); + >(path); if (groupResources.value) { for (const resource of groupResources.value) { // Deployments show up in tracked resources, but the RP may not hold onto them. // There's limited value here so skip them for now. - if (resource.type === 'Microsoft.Resources/deployments') { + if (resource.type === "Microsoft.Resources/deployments") { continue; } resources.push(await this.getResourceById({ id: resource.id })); @@ -125,75 +159,60 @@ export class RadiusApiImpl implements RadiusApi { async getResourceById(opts: { id: string; }): Promise> { - const cluster = await this.selectCluster(); - - const path = makePathForId(opts.id); - const resource = await this.makeRequest>(cluster, path); + const path = this.makePathForId(opts.id); + const resource = await this.makeRequest>(path); return await this.fixupResource(resource); } async listApplications(opts?: { resourceGroup?: string; }): Promise> { - const cluster = await this.selectCluster(); - - const path = makePath({ + const path = this.makePath({ scopes: this.makeScopes(opts), - type: 'Applications.Core/applications', + type: "Applications.Core/applications", }); - return this.makeRequest>(cluster, path); + return this.makeRequest>(path); } async listEnvironments(opts?: { resourceGroup?: string; }): Promise> { - const cluster = await this.selectCluster(); - - const path = makePath({ + const path = this.makePath({ scopes: this.makeScopes(opts), - type: 'Applications.Core/environments', + type: "Applications.Core/environments", }); - return this.makeRequest>(cluster, path); - } - - private async selectCluster(): Promise { - const clusters = await this.kubernetesApi.getClusters(); - for (const cluster of clusters) { - return cluster.name; - } - - throw new Error('No kubernetes clusters found'); + return this.makeRequest>(path); } private makeScopes(opts?: { resourceGroup?: string; }): { type: string; value?: string }[] { - const scopes = [{ type: 'radius', value: 'local' }]; + const scopes = [{ type: "radius", value: "local" }]; if (opts?.resourceGroup) { - scopes.push({ type: 'resourceGroups', value: opts.resourceGroup }); + scopes.push({ type: "resourceGroups", value: opts.resourceGroup }); } return scopes; } - private async makeRequest(cluster: string, path: string): Promise { - const response = await this.kubernetesApi.proxy({ - clusterName: cluster, - path: path, - init: { - referrerPolicy: 'no-referrer', // See https://github.com/radius-project/radius/issues/6983 - mode: 'cors', - cache: 'no-cache', - method: 'GET', - }, + private async makeRequest(path: string): Promise { + const response = await this.connection.send(path, { + referrerPolicy: "no-referrer", // See https://github.com/radius-project/radius/issues/6983 + mode: "cors", + cache: "no-cache", + method: "GET", }); + const text = await response.text(); if (!response.ok) { - const text = await response.text(); throw new Error(`Request failed: ${response.status}:\n\n${text}`); } - const data = (await response.json()) as T; - return data; + try { + const data = JSON.parse(text) as T; + return data; + } catch (error) { + throw new Error(`Request was not json: ${response.status}:\n\n${text}`); + } } private async fixupResource(resource: Resource): Promise> { diff --git a/plugins/plugin-radius/src/plugin.ts b/plugins/plugin-radius/src/plugin.ts index 89c74bb..7c30a6d 100644 --- a/plugins/plugin-radius/src/plugin.ts +++ b/plugins/plugin-radius/src/plugin.ts @@ -1,4 +1,6 @@ import { + ConfigApi, + configApiRef, createApiFactory, createApiRef, createPlugin, @@ -15,7 +17,7 @@ import { } from './routes'; import { RadiusApi } from './api'; import { KubernetesApi, kubernetesApiRef } from '@backstage/plugin-kubernetes'; -import { RadiusApiImpl } from './api/api'; +import { DirectConnection, KubernetesConnection, RadiusApiImpl } from './api/api'; import { featureRadiusCatalog as featureRadiusCatalog } from './features'; export const radiusApiRef = createApiRef({ @@ -28,10 +30,18 @@ export const radiusPlugin = createPlugin({ createApiFactory({ api: radiusApiRef, deps: { + configApi: configApiRef, kubernetesApi: kubernetesApiRef, }, - factory: (deps: { kubernetesApi: KubernetesApi }) => { - return new RadiusApiImpl(deps.kubernetesApi); + factory: (deps: { configApi: ConfigApi, kubernetesApi: KubernetesApi }) => { + const connectionKind = deps.configApi.getOptionalString('radius.connection.kind') ?? 'kubernetes'; + if (connectionKind === 'kubernetes') { + return new RadiusApiImpl(new KubernetesConnection(deps.kubernetesApi)); + } else if (connectionKind === 'direct') { + return new RadiusApiImpl(new DirectConnection(`${deps.configApi.getString('backend.baseUrl')}/api/proxy/radius`)); + } + + throw new Error(`Unsupported connection kind: ${connectionKind}`); }, }), ],