From cfd99fdc52063dc77b409d2d47772ecee85988ea Mon Sep 17 00:00:00 2001 From: Scott Guymer Date: Thu, 19 Sep 2024 16:27:09 +0200 Subject: [PATCH] Add an extension point for credentials resolution #1244 --- .../src/client/GithubClient.ts | 10 ++- .../plugins/copilot-backend/src/index.ts | 6 +- .../plugins/copilot-backend/src/plugin.ts | 24 +++++ .../copilot-backend/src/service/router.ts | 11 ++- .../src/utils/CopilotCredentialsProvider.ts | 88 +++++++++++++++++++ .../copilot-backend/src/utils/GithubUtils.ts | 69 --------------- 6 files changed, 132 insertions(+), 76 deletions(-) create mode 100644 workspaces/copilot/plugins/copilot-backend/src/utils/CopilotCredentialsProvider.ts delete mode 100644 workspaces/copilot/plugins/copilot-backend/src/utils/GithubUtils.ts diff --git a/workspaces/copilot/plugins/copilot-backend/src/client/GithubClient.ts b/workspaces/copilot/plugins/copilot-backend/src/client/GithubClient.ts index 2c7103699..079b3dc30 100644 --- a/workspaces/copilot/plugins/copilot-backend/src/client/GithubClient.ts +++ b/workspaces/copilot/plugins/copilot-backend/src/client/GithubClient.ts @@ -15,10 +15,12 @@ */ import { ResponseError } from '@backstage/errors'; -import { Config } from '@backstage/config'; import { Metric } from '@backstage-community/plugin-copilot-common'; import fetch from 'node-fetch'; -import { getGithubInfo, GithubInfo } from '../utils/GithubUtils'; +import { + CopilotCredentialsProvider, + GithubInfo, +} from '../utils/CopilotCredentialsProvider'; interface GithubApi { getCopilotUsageDataForEnterprise: () => Promise; @@ -27,8 +29,8 @@ interface GithubApi { export class GithubClient implements GithubApi { constructor(private readonly props: GithubInfo) {} - static async fromConfig(config: Config) { - const info = await getGithubInfo(config); + static async fromConfig(credentialsProvider: CopilotCredentialsProvider) { + const info = await credentialsProvider.getCredentials(); return new GithubClient(info); } diff --git a/workspaces/copilot/plugins/copilot-backend/src/index.ts b/workspaces/copilot/plugins/copilot-backend/src/index.ts index e1681ad6d..4a86de701 100644 --- a/workspaces/copilot/plugins/copilot-backend/src/index.ts +++ b/workspaces/copilot/plugins/copilot-backend/src/index.ts @@ -21,4 +21,8 @@ */ export * from './service/router'; -export { copilotPlugin as default } from './plugin'; +export { copilotPlugin as default, copilotExtensionPoint } from './plugin'; +export { + type CopilotCredentialsProvider, + type GithubInfo, +} from './utils/CopilotCredentialsProvider'; diff --git a/workspaces/copilot/plugins/copilot-backend/src/plugin.ts b/workspaces/copilot/plugins/copilot-backend/src/plugin.ts index 490f92afa..b4dbd5f50 100644 --- a/workspaces/copilot/plugins/copilot-backend/src/plugin.ts +++ b/workspaces/copilot/plugins/copilot-backend/src/plugin.ts @@ -16,8 +16,22 @@ import { coreServices, createBackendPlugin, + createExtensionPoint, } from '@backstage/backend-plugin-api'; import { createRouterFromConfig } from './service/router'; +import { + CopilotCredentialsProvider, + DefaultCopilotCredentialsProvider, +} from './utils/CopilotCredentialsProvider'; + +export interface CopilotExtensionPoint { + useCredentialsProvider(provider: CopilotCredentialsProvider): void; +} + +export const copilotExtensionPoint = + createExtensionPoint({ + id: 'copliot.credentials', + }); /** * Backend plugin for Copilot. @@ -27,6 +41,13 @@ import { createRouterFromConfig } from './service/router'; export const copilotPlugin = createBackendPlugin({ pluginId: 'copilot', register(env) { + let credentialsProvider: CopilotCredentialsProvider; + env.registerExtensionPoint(copilotExtensionPoint, { + useCredentialsProvider(provider: CopilotCredentialsProvider) { + credentialsProvider = provider; + }, + }); + env.registerInit({ deps: { httpRouter: coreServices.httpRouter, @@ -42,6 +63,9 @@ export const copilotPlugin = createBackendPlugin({ database, scheduler, config, + credentialsProvider: + credentialsProvider ?? + new DefaultCopilotCredentialsProvider({ config }), }), ); httpRouter.addAuthPolicy({ diff --git a/workspaces/copilot/plugins/copilot-backend/src/service/router.ts b/workspaces/copilot/plugins/copilot-backend/src/service/router.ts index b11c751fc..8ec9dc903 100644 --- a/workspaces/copilot/plugins/copilot-backend/src/service/router.ts +++ b/workspaces/copilot/plugins/copilot-backend/src/service/router.ts @@ -29,6 +29,7 @@ import { DatabaseHandler } from '../db/DatabaseHandler'; import Scheduler from '../task/Scheduler'; import { GithubClient } from '../client/GithubClient'; import { DateTime } from 'luxon'; +import { CopilotCredentialsProvider } from '../utils/CopilotCredentialsProvider'; /** * Options for configuring the Copilot plugin. @@ -67,6 +68,11 @@ export interface RouterOptions { * Configuration for the router. */ config: Config; + + /** + * Credentials provider for the router. + */ + credentialsProvider: CopilotCredentialsProvider; } const defaultSchedule: SchedulerServiceTaskScheduleDefinition = { @@ -106,11 +112,12 @@ async function createRouter( routerOptions: RouterOptions, pluginOptions: PluginOptions, ): Promise { - const { logger, database, scheduler, config } = routerOptions; + const { logger, database, scheduler, config, credentialsProvider } = + routerOptions; const { schedule } = pluginOptions; const db = await DatabaseHandler.create({ database }); - const api = await GithubClient.fromConfig(config); + const api = await GithubClient.fromConfig(credentialsProvider); await scheduler.scheduleTask({ id: 'copilot-metrics', diff --git a/workspaces/copilot/plugins/copilot-backend/src/utils/CopilotCredentialsProvider.ts b/workspaces/copilot/plugins/copilot-backend/src/utils/CopilotCredentialsProvider.ts new file mode 100644 index 000000000..298ed24a5 --- /dev/null +++ b/workspaces/copilot/plugins/copilot-backend/src/utils/CopilotCredentialsProvider.ts @@ -0,0 +1,88 @@ +/* + * Copyright 2024 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 { Config } from '@backstage/config'; +import { + DefaultGithubCredentialsProvider, + GithubCredentials, + ScmIntegrations, +} from '@backstage/integration'; + +export type GithubInfo = { + credentials: GithubCredentials; + apiBaseUrl: string; + enterprise: string; +}; + +export interface CopilotCredentialsProvider { + getCredentials(): Promise; +} + +export class DefaultCopilotCredentialsProvider + implements CopilotCredentialsProvider +{ + private readonly host: string; + private readonly enterprise: string; + private readonly integrations: ScmIntegrations; + private readonly credentialsProvider: DefaultGithubCredentialsProvider; + + constructor(options: { config: Config }) { + const { config } = options; + + this.integrations = ScmIntegrations.fromConfig(config); + this.credentialsProvider = + DefaultGithubCredentialsProvider.fromIntegrations(this.integrations); + + this.host = config.getString('copilot.host'); + this.enterprise = config.getString('copilot.enterprise'); + + if (!this.host) { + throw new Error('The host configuration is missing from the config.'); + } + + if (!this.enterprise) { + throw new Error( + 'The enterprise configuration is missing from the config.', + ); + } + } + + async getCredentials(): Promise { + const githubConfig = this.integrations.github.byHost(this.host)?.config; + + if (!githubConfig) { + throw new Error( + `GitHub configuration for host "${this.host}" is missing or incomplete.`, + ); + } + + const apiBaseUrl = githubConfig.apiBaseUrl ?? 'https://api.github.com'; + + const credentials = await this.credentialsProvider.getCredentials({ + url: apiBaseUrl, + }); + + if (!credentials.headers) { + throw new Error('Failed to retrieve credentials headers.'); + } + + return { + apiBaseUrl, + credentials, + enterprise: this.enterprise, + }; + } +} diff --git a/workspaces/copilot/plugins/copilot-backend/src/utils/GithubUtils.ts b/workspaces/copilot/plugins/copilot-backend/src/utils/GithubUtils.ts deleted file mode 100644 index 9620f397e..000000000 --- a/workspaces/copilot/plugins/copilot-backend/src/utils/GithubUtils.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2024 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 { Config } from '@backstage/config'; -import { - DefaultGithubCredentialsProvider, - GithubCredentials, - ScmIntegrations, -} from '@backstage/integration'; - -export type GithubInfo = { - credentials: GithubCredentials; - apiBaseUrl: string; - enterprise: string; -}; - -export const getGithubInfo = async (config: Config): Promise => { - const integrations = ScmIntegrations.fromConfig(config); - const credentialsProvider = - DefaultGithubCredentialsProvider.fromIntegrations(integrations); - - const host = config.getString('copilot.host'); - const enterprise = config.getString('copilot.enterprise'); - - if (!host) { - throw new Error('The host configuration is missing from the config.'); - } - - if (!enterprise) { - throw new Error('The enterprise configuration is missing from the config.'); - } - - const githubConfig = integrations.github.byHost(host)?.config; - - if (!githubConfig) { - throw new Error( - `GitHub configuration for host "${host}" is missing or incomplete.`, - ); - } - - const apiBaseUrl = githubConfig.apiBaseUrl ?? 'https://api.github.com'; - - const credentials = await credentialsProvider.getCredentials({ - url: apiBaseUrl, - }); - - if (!credentials.headers) { - throw new Error('Failed to retrieve credentials headers.'); - } - - return { - apiBaseUrl, - credentials, - enterprise, - }; -};