cli: remove experimental onboard command
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -25,13 +25,6 @@ const configOption = [
|
||||
Array<string>(),
|
||||
] as const;
|
||||
|
||||
export function registerOnboardCommand(program: Command) {
|
||||
program
|
||||
.command('onboard', { hidden: true })
|
||||
.description('Get help setting up your Backstage App.')
|
||||
.action(lazy(() => import('./onboard').then(m => m.command)));
|
||||
}
|
||||
|
||||
export function registerRepoCommand(program: Command) {
|
||||
const command = program
|
||||
.command('repo [command]')
|
||||
@@ -328,7 +321,6 @@ export function registerCommands(program: Command) {
|
||||
registerRepoCommand(program);
|
||||
registerScriptCommand(program);
|
||||
registerMigrateCommand(program);
|
||||
registerOnboardCommand(program);
|
||||
|
||||
program
|
||||
.command('versions:bump')
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# onboard
|
||||
|
||||
## Local dev
|
||||
|
||||
# Create a new app
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
npx @backstage/create-app
|
||||
cd new-backstage-app
|
||||
|
||||
# Run the modded cli in the new app
|
||||
|
||||
../RELATIVE_PATH_TO_PROJECT/backstage/packages/cli/bin/backstage-cli onboard
|
||||
? Do you want to set up Authentication for this project? (Y/n)
|
||||
...
|
||||
|
||||
# Try the app with the new changes
|
||||
|
||||
yarn dev
|
||||
|
||||
```
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* 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 inquirer from 'inquirer';
|
||||
import { Task } from '../../../lib/tasks';
|
||||
import { github, Answers as GitHubAnswers } from './providers/github';
|
||||
import { gitlab, Answers as GitLabAnswers } from './providers/gitlab';
|
||||
|
||||
export { type GitHubAnswers, type GitLabAnswers };
|
||||
|
||||
export async function auth(): Promise<{
|
||||
provider: string;
|
||||
answers: GitHubAnswers | GitLabAnswers;
|
||||
}> {
|
||||
const answers = await inquirer.prompt<{
|
||||
provider: string;
|
||||
}>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'provider',
|
||||
message: 'Please select an authentication provider:',
|
||||
choices: ['GitHub', 'GitLab'],
|
||||
},
|
||||
]);
|
||||
|
||||
const { provider } = answers;
|
||||
|
||||
let providerAnswers;
|
||||
switch (provider) {
|
||||
case 'GitHub': {
|
||||
providerAnswers = await github();
|
||||
break;
|
||||
}
|
||||
case 'GitLab': {
|
||||
providerAnswers = await gitlab();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Provider ${provider} not implemented yet.`);
|
||||
}
|
||||
|
||||
Task.log();
|
||||
Task.log(`Done setting up ${provider} Authentication!`);
|
||||
Task.log();
|
||||
|
||||
return {
|
||||
provider,
|
||||
answers: providerAnswers,
|
||||
};
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* 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 * as fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
import * as differ from 'diff';
|
||||
import { PATCH_FOLDER } from '../files';
|
||||
import { findPaths } from '@backstage/cli-common';
|
||||
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
const { targetRoot } = findPaths(__dirname);
|
||||
|
||||
export const patch = async (patchFile: string) => {
|
||||
const patchContent = await fs.readFile(
|
||||
path.join(PATCH_FOLDER, patchFile),
|
||||
'utf8',
|
||||
);
|
||||
const targetName = patchContent.split('\n')[0].replace('--- a', '');
|
||||
const targetFile = path.join(targetRoot, targetName);
|
||||
const oldContent = await fs.readFile(targetFile, 'utf8');
|
||||
const newContent = differ.applyPatch(oldContent, patchContent);
|
||||
if (!newContent) {
|
||||
throw new Error(
|
||||
`Patch ${patchFile} was not applied correctly.
|
||||
Did you change ${targetName} manually before running this command?`,
|
||||
);
|
||||
}
|
||||
|
||||
return await fs.writeFile(targetFile, newContent, 'utf8');
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
--- a/packages/app/src/App.tsx
|
||||
+++ b/packages/app/src/App.tsx
|
||||
@@ -30 +30,5 @@ import { Root } from './components/Root';
|
||||
-import { AlertDisplay, OAuthRequestDialog } from '@backstage/core-components';
|
||||
+import {
|
||||
+ AlertDisplay,
|
||||
+ OAuthRequestDialog,
|
||||
+ SignInPage,
|
||||
+} from '@backstage/core-components';
|
||||
@@ -35,0 +40 @@ import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/
|
||||
+import { githubAuthApiRef } from '@backstage/core-plugin-api';
|
||||
@@ -38,0 +44,14 @@ const app = createApp({
|
||||
+ components: {
|
||||
+ SignInPage: props => (
|
||||
+ <SignInPage
|
||||
+ {...props}
|
||||
+ provider={{
|
||||
+ id: 'github-auth-provider',
|
||||
+ title: 'GitHub',
|
||||
+ message: 'Sign in using GitHub',
|
||||
+ apiRef: githubAuthApiRef,
|
||||
+ }}
|
||||
+ />
|
||||
+ ),
|
||||
+ },
|
||||
@@ -1,25 +0,0 @@
|
||||
--- a/packages/app/src/App.tsx
|
||||
+++ b/packages/app/src/App.tsx
|
||||
@@ -30 +30,5 @@ import { Root } from './components/Root';
|
||||
-import { AlertDisplay, OAuthRequestDialog } from '@backstage/core-components';
|
||||
+import {
|
||||
+ AlertDisplay,
|
||||
+ OAuthRequestDialog,
|
||||
+ SignInPage,
|
||||
+} from '@backstage/core-components';
|
||||
@@ -35,0 +40 @@ import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/
|
||||
+import { gitlabAuthApiRef } from '@backstage/core-plugin-api';
|
||||
@@ -38,0 +44,14 @@ const app = createApp({
|
||||
+ components: {
|
||||
+ SignInPage: props => (
|
||||
+ <SignInPage
|
||||
+ {...props}
|
||||
+ provider={{
|
||||
+ id: 'gitlab-auth-provider',
|
||||
+ title: 'GitLab',
|
||||
+ message: 'Sign in using GitLab',
|
||||
+ apiRef: gitlabAuthApiRef,
|
||||
+ }}
|
||||
+ />
|
||||
+ ),
|
||||
+ },
|
||||
@@ -1,5 +0,0 @@
|
||||
--- a/packages/backend/src/plugins/auth.ts
|
||||
+++ b/packages/backend/src/plugins/auth.ts
|
||||
@@ -38 +38 @@ export default async function createPlugin(
|
||||
- github: providers.github.create({
|
||||
+ gitlab: providers.gitlab.create({
|
||||
@@ -1,147 +0,0 @@
|
||||
/*
|
||||
* 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 { OAuthApp } from '@octokit/oauth-app';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import inquirer from 'inquirer';
|
||||
import { Task } from '../../../../lib/tasks';
|
||||
import { updateConfigFile } from '../../config';
|
||||
import { APP_CONFIG_FILE, PATCH_FOLDER } from '../../files';
|
||||
import { patch } from '../patch';
|
||||
|
||||
export type Answers = {
|
||||
clientSecret: string;
|
||||
clientId: string;
|
||||
hasEnterprise: boolean;
|
||||
enterpriseInstanceUrl?: string;
|
||||
};
|
||||
|
||||
const validateCredentials = async (clientId: string, clientSecret: string) => {
|
||||
try {
|
||||
const app = new OAuthApp({
|
||||
clientId,
|
||||
clientSecret,
|
||||
});
|
||||
await app.createToken({
|
||||
code: '%NOT-VALID-CODE%',
|
||||
});
|
||||
} catch (error) {
|
||||
// @octokit/request returns a error.response object when a request is rejected.
|
||||
// We can check it to see what kind of error we received.
|
||||
|
||||
// If error.response is successful we can double-check that the error itself was due to the bad code.
|
||||
// If that's the case then we can assume that the client id and secret exists as we otherwise would
|
||||
// have gotten a 400/404.
|
||||
if (
|
||||
error.response.status !== 200 &&
|
||||
error.response.data.error !== 'bad_verification_code'
|
||||
) {
|
||||
throw new Error(`Validating GitHub Credentials failed.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getConfig = (answers: Answers) => {
|
||||
const { clientId, clientSecret, hasEnterprise, enterpriseInstanceUrl } =
|
||||
answers;
|
||||
|
||||
return {
|
||||
auth: {
|
||||
providers: {
|
||||
github: {
|
||||
development: {
|
||||
clientId,
|
||||
clientSecret,
|
||||
...(hasEnterprise && {
|
||||
enterpriseInstanceUrl,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const github = async (): Promise<Answers> => {
|
||||
Task.log(`
|
||||
To add GitHub authentication, you must create an OAuth App from the GitHub developer settings: ${chalk.blue(
|
||||
'https://github.com/settings/developers',
|
||||
)}
|
||||
The Homepage URL should point to Backstage's frontend, while the Authorization callback URL will point to the auth backend.
|
||||
|
||||
Settings for local development:
|
||||
${chalk.cyan(`
|
||||
Homepage URL: http://localhost:3000
|
||||
Authorization callback URL: http://localhost:7007/api/auth/github/handler/frame`)}
|
||||
|
||||
You can find the full documentation page here: ${chalk.blue(
|
||||
'https://backstage.io/docs/auth/github/provider',
|
||||
)}
|
||||
`);
|
||||
|
||||
const answers = await inquirer.prompt<Answers>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'clientId',
|
||||
message: 'What is your Client Id?',
|
||||
validate: (input: string) => (input.length ? true : false),
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'clientSecret',
|
||||
message: 'What is your Client Secret?',
|
||||
validate: (input: string) => (input.length ? true : false),
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'hasEnterprise',
|
||||
message: 'Are you using GitHub Enterprise?',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'enterpriseInstanceUrl',
|
||||
message: 'What is your URL for GitHub Enterprise?',
|
||||
when: ({ hasEnterprise }) => hasEnterprise,
|
||||
validate: (input: string) => Boolean(new URL(input)),
|
||||
},
|
||||
]);
|
||||
|
||||
const { clientId, clientSecret } = answers;
|
||||
const config = getConfig(answers);
|
||||
|
||||
Task.log('Setting up GitHub Authentication for you...');
|
||||
|
||||
await Task.forItem(
|
||||
'Validating',
|
||||
'credentials',
|
||||
async () => await validateCredentials(clientId, clientSecret),
|
||||
);
|
||||
await Task.forItem(
|
||||
'Updating',
|
||||
APP_CONFIG_FILE,
|
||||
async () => await updateConfigFile(APP_CONFIG_FILE, config),
|
||||
);
|
||||
|
||||
const patches = await fs.readdir(PATCH_FOLDER);
|
||||
for (const patchFile of patches.filter(p => p.includes('github'))) {
|
||||
await Task.forItem('Patching', patchFile, async () => {
|
||||
await patch(patchFile);
|
||||
});
|
||||
}
|
||||
|
||||
return answers;
|
||||
};
|
||||
@@ -1,115 +0,0 @@
|
||||
/*
|
||||
* 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 chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import inquirer from 'inquirer';
|
||||
import { Task } from '../../../../lib/tasks';
|
||||
import { updateConfigFile } from '../../config';
|
||||
import { APP_CONFIG_FILE, PATCH_FOLDER } from '../../files';
|
||||
import { patch } from '../patch';
|
||||
|
||||
const getConfig = (answers: Answers) => {
|
||||
const { clientId, clientSecret, hasAudience, audience } = answers;
|
||||
|
||||
return {
|
||||
auth: {
|
||||
providers: {
|
||||
gitlab: {
|
||||
development: {
|
||||
clientId,
|
||||
clientSecret,
|
||||
...(hasAudience && {
|
||||
audience,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export type Answers = {
|
||||
clientSecret: string;
|
||||
clientId: string;
|
||||
hasAudience: boolean;
|
||||
audience?: string;
|
||||
};
|
||||
|
||||
export const gitlab = async (): Promise<Answers> => {
|
||||
Task.log(`
|
||||
To add GitLab authentication, you must create an Application from the GitLab Settings: ${chalk.blue(
|
||||
'https://gitlab.com/-/profile/applications',
|
||||
)}
|
||||
The Redirect URI should point to your Backstage backend auth handler.
|
||||
|
||||
Settings for local development:
|
||||
${chalk.cyan(`
|
||||
Name: Backstage (or your custom app name)
|
||||
Redirect URI: http://localhost:7007/api/auth/gitlab/handler/frame
|
||||
Scopes: read_api and read_user`)}
|
||||
|
||||
You can find the full documentation page here: ${chalk.blue(
|
||||
'https://backstage.io/docs/auth/gitlab/provider',
|
||||
)}
|
||||
`);
|
||||
|
||||
const answers = await inquirer.prompt<Answers>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'clientId',
|
||||
message: 'What is your Application Id?',
|
||||
validate: (input: string) => (input.length ? true : false),
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'clientSecret',
|
||||
message: 'What is your Application Secret?',
|
||||
validate: (input: string) => (input.length ? true : false),
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'hasAudience',
|
||||
message: 'Do you have a self-hosted instance of GitLab?',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'audience',
|
||||
message: 'What is the URL for your GitLab instance?',
|
||||
when: ({ hasAudience }) => hasAudience,
|
||||
validate: (input: string) => Boolean(new URL(input)),
|
||||
},
|
||||
]);
|
||||
|
||||
const config = getConfig(answers);
|
||||
|
||||
Task.log('Setting up GitLab Authentication for you...');
|
||||
|
||||
await Task.forItem(
|
||||
'Updating',
|
||||
APP_CONFIG_FILE,
|
||||
async () => await updateConfigFile(APP_CONFIG_FILE, config),
|
||||
);
|
||||
|
||||
const patches = await fs.readdir(PATCH_FOLDER);
|
||||
for (const patchFile of patches.filter(p => p.includes('gitlab'))) {
|
||||
await Task.forItem('Patching', patchFile, async () => {
|
||||
await patch(patchFile);
|
||||
});
|
||||
}
|
||||
|
||||
return answers;
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
* 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 chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
import { Task } from '../../lib/tasks';
|
||||
import { auth } from './auth';
|
||||
import { integrations } from './integrations';
|
||||
import { discover } from './discovery';
|
||||
|
||||
export async function command(): Promise<void> {
|
||||
const answers = await inquirer.prompt<{
|
||||
shouldSetupAuth: boolean;
|
||||
shouldSetupScaffolder: boolean;
|
||||
shouldDiscoverEntities: boolean;
|
||||
}>([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'shouldSetupAuth',
|
||||
message: 'Do you want to set up Authentication for this project?',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'shouldSetupScaffolder',
|
||||
message: 'Do you want to use Software Templates in this project?',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'shouldDiscoverEntities',
|
||||
message:
|
||||
'Do you want to discover entities and add them to the Software Catalog?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const { shouldSetupAuth, shouldSetupScaffolder, shouldDiscoverEntities } =
|
||||
answers;
|
||||
|
||||
if (!shouldSetupAuth && !shouldSetupScaffolder && !shouldDiscoverEntities) {
|
||||
Task.log(
|
||||
chalk.yellow(
|
||||
'If you change your mind, feel free to re-run this command.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let providerInfo;
|
||||
if (shouldSetupAuth) {
|
||||
providerInfo = await auth();
|
||||
}
|
||||
|
||||
if (shouldSetupScaffolder) {
|
||||
await integrations(providerInfo);
|
||||
}
|
||||
|
||||
if (shouldDiscoverEntities) {
|
||||
await discover(providerInfo);
|
||||
}
|
||||
|
||||
Task.log();
|
||||
Task.log(
|
||||
`You can now start your app with ${chalk.inverse(
|
||||
chalk.italic('yarn dev'),
|
||||
)}`,
|
||||
);
|
||||
Task.log();
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/*
|
||||
* 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 * as fs from 'fs-extra';
|
||||
import yaml from 'yaml';
|
||||
|
||||
const readYaml = async (file: string) => {
|
||||
return yaml.parse(await fs.readFile(file, 'utf8'));
|
||||
};
|
||||
|
||||
export const updateConfigFile = async <T>(file: string, config: T) => {
|
||||
const staticContent =
|
||||
'# Backstage override configuration for your local development environment \n';
|
||||
|
||||
const content = fs.existsSync(file)
|
||||
? yaml.stringify(
|
||||
{ ...(await readYaml(file)), ...config },
|
||||
{
|
||||
indent: 2,
|
||||
},
|
||||
)
|
||||
: staticContent.concat(
|
||||
yaml.stringify(
|
||||
{ ...config },
|
||||
{
|
||||
indent: 2,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return await fs.writeFile(file, content, 'utf8');
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
/*
|
||||
* 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 chalk from 'chalk';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Analyzer } from './analyzers/types';
|
||||
import { Provider } from './providers/types';
|
||||
import { DefaultAnalysisOutputs } from './analyzers/DefaultAnalysisOutputs';
|
||||
import { Task } from '../../../lib/tasks';
|
||||
|
||||
export class Discovery {
|
||||
readonly #providers: Provider[] = [];
|
||||
readonly #analyzers: Analyzer[] = [];
|
||||
|
||||
addProvider(provider: Provider) {
|
||||
this.#providers.push(provider);
|
||||
}
|
||||
|
||||
addAnalyzer(analyzer: Analyzer) {
|
||||
this.#analyzers.push(analyzer);
|
||||
}
|
||||
|
||||
async run(url: string): Promise<{ entities: Entity[] }> {
|
||||
Task.log(`Running discovery for ${chalk.cyan(url)}`);
|
||||
const result: Entity[] = [];
|
||||
|
||||
for (const provider of this.#providers) {
|
||||
const repositories = await provider.discover(url);
|
||||
if (repositories && repositories.length) {
|
||||
Task.log(
|
||||
`Discovered ${chalk.cyan(
|
||||
repositories.length,
|
||||
)} repositories for ${chalk.cyan(provider.name())}`,
|
||||
);
|
||||
|
||||
for (const repository of repositories) {
|
||||
await Task.forItem('Analyzing', repository.name, async () => {
|
||||
const output = new DefaultAnalysisOutputs();
|
||||
for (const analyzer of this.#analyzers) {
|
||||
await analyzer.analyzeRepository({ repository, output });
|
||||
}
|
||||
|
||||
output
|
||||
.list()
|
||||
.filter(entry => entry.type === 'entity')
|
||||
.forEach(({ entity }) => result.push(entity));
|
||||
});
|
||||
}
|
||||
|
||||
Task.log(`Produced ${chalk.cyan(result.length || 'no')} entities`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entities: result,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
* 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 { ComponentEntity } from '@backstage/catalog-model';
|
||||
import { AnalysisOutputs, Analyzer } from './types';
|
||||
import { Repository } from '../providers/types';
|
||||
|
||||
/**
|
||||
* Naive analyzer that produces a single entity that represents the repository
|
||||
* as a whole.
|
||||
*/
|
||||
export class BasicRepositoryAnalyzer implements Analyzer {
|
||||
name(): string {
|
||||
return BasicRepositoryAnalyzer.name;
|
||||
}
|
||||
|
||||
async analyzeRepository(options: {
|
||||
repository: Repository;
|
||||
output: AnalysisOutputs;
|
||||
}): Promise<void> {
|
||||
const entity: ComponentEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: options.repository.name,
|
||||
...(options.repository.description
|
||||
? { description: options.repository.description }
|
||||
: {}),
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
lifecycle: 'production',
|
||||
owner: 'user:guest',
|
||||
},
|
||||
};
|
||||
|
||||
options.output.produce({
|
||||
type: 'entity',
|
||||
path: '/',
|
||||
entity: entity,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
* 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 { AnalysisOutput, AnalysisOutputs } from './types';
|
||||
|
||||
export class DefaultAnalysisOutputs implements AnalysisOutputs {
|
||||
readonly #outputs = new Map<string, AnalysisOutput>();
|
||||
|
||||
produce(output: AnalysisOutput) {
|
||||
this.#outputs.set(output.entity.metadata.name, output);
|
||||
}
|
||||
|
||||
list() {
|
||||
return Array.from(this.#outputs).map(([_, output]) => output);
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
* 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 {
|
||||
ANNOTATION_SOURCE_LOCATION,
|
||||
ComponentEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import z from 'zod';
|
||||
import { AnalysisOutputs, Analyzer } from './types';
|
||||
import { Repository, RepositoryFile } from '../providers/types';
|
||||
|
||||
export class PackageJsonAnalyzer implements Analyzer {
|
||||
name(): string {
|
||||
return PackageJsonAnalyzer.name;
|
||||
}
|
||||
|
||||
async analyzeRepository(options: {
|
||||
repository: Repository;
|
||||
output: AnalysisOutputs;
|
||||
}): Promise<void> {
|
||||
const packageJson = await options.repository.file('package.json');
|
||||
if (!packageJson) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await readPackageJson(packageJson);
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = sanitizeName(content?.name) ?? options.repository.name;
|
||||
|
||||
const entity: ComponentEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name,
|
||||
...(options.repository.description
|
||||
? { description: options.repository.description }
|
||||
: {}),
|
||||
tags: ['javascript'],
|
||||
annotations: {
|
||||
[ANNOTATION_SOURCE_LOCATION]: `url:${options.repository.url}`,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
type: 'website',
|
||||
lifecycle: 'production',
|
||||
owner: 'user:guest',
|
||||
},
|
||||
};
|
||||
|
||||
const decorate = options.output
|
||||
.list()
|
||||
.find(entry => entry.entity.metadata.name === name);
|
||||
|
||||
if (decorate) {
|
||||
decorate.entity.spec = {
|
||||
...decorate.entity.spec,
|
||||
type: 'website',
|
||||
};
|
||||
|
||||
decorate.entity.metadata.tags = [
|
||||
...(decorate.entity.metadata.tags ?? []),
|
||||
'javascript',
|
||||
];
|
||||
|
||||
decorate.entity.metadata.annotations = {
|
||||
...decorate.entity.metadata.annotations,
|
||||
[ANNOTATION_SOURCE_LOCATION]: `url:${options.repository.url}`,
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
options.output.produce({
|
||||
type: 'entity',
|
||||
path: '/',
|
||||
entity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const packageSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Makes sure that a name retrieved from a package.json file
|
||||
* is reasonable and conforms to the catalog naming format.
|
||||
*
|
||||
* Read more about the naming format here:
|
||||
* ADR002: Default Software Catalog File Format
|
||||
* https://backstage.io/docs/architecture-decisions/adrs-adr002/
|
||||
*/
|
||||
function sanitizeName(name?: string) {
|
||||
return name && name !== 'root'
|
||||
? name.replace(/[^a-z0-9A-Z]/g, '_').substring(0, 62)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function readPackageJson(
|
||||
file: RepositoryFile,
|
||||
): Promise<z.infer<typeof packageSchema> | undefined> {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const result = packageSchema.safeParse(JSON.parse(text));
|
||||
if (!result.success) {
|
||||
return undefined;
|
||||
}
|
||||
return { name: result.data.name };
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/*
|
||||
* 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 { Entity } from '@backstage/catalog-model';
|
||||
import { Repository } from '../providers/types';
|
||||
|
||||
export type AnalysisOutput = {
|
||||
type: 'entity';
|
||||
path: string;
|
||||
entity: Entity;
|
||||
};
|
||||
|
||||
export interface AnalysisOutputs {
|
||||
produce(output: AnalysisOutput): void;
|
||||
list(): AnalysisOutput[];
|
||||
}
|
||||
|
||||
export interface Analyzer {
|
||||
name(): string;
|
||||
analyzeRepository(options: {
|
||||
repository: Repository;
|
||||
output: AnalysisOutputs;
|
||||
}): Promise<void>;
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import yaml from 'yaml';
|
||||
import inquirer from 'inquirer';
|
||||
import chalk from 'chalk';
|
||||
import { loadCliConfig } from '../../../lib/config';
|
||||
import { updateConfigFile } from '../config';
|
||||
import { APP_CONFIG_FILE, DISCOVERED_ENTITIES_FILE } from '../files';
|
||||
import { Discovery } from './Discovery';
|
||||
import { BasicRepositoryAnalyzer } from './analyzers/BasicRepositoryAnalyzer';
|
||||
import { PackageJsonAnalyzer } from './analyzers/PackageJsonAnalyzer';
|
||||
import { GithubDiscoveryProvider } from './providers/github/GithubDiscoveryProvider';
|
||||
import { GitlabDiscoveryProvider } from './providers/gitlab/GitlabDiscoveryProvider';
|
||||
import { GitHubAnswers, GitLabAnswers } from '../auth';
|
||||
import { Task } from '../../../lib/tasks';
|
||||
|
||||
export async function discover(providerInfo?: {
|
||||
provider: string;
|
||||
answers: GitHubAnswers | GitLabAnswers;
|
||||
}) {
|
||||
Task.log(`
|
||||
Would you like to scan for - and create - Software Catalog entities?
|
||||
|
||||
You will need to select which SCM (Source Code Management) provider you are using,
|
||||
and then which repository or organization you want to scan.
|
||||
|
||||
This will generate a new file in the root of your app containing discovered entities,
|
||||
which will be included in the Software Catalog when you start up Backstage next time.
|
||||
|
||||
Note that this command requires an access token, which can be either added through the integration config or
|
||||
provided as an environment variable.
|
||||
`);
|
||||
|
||||
const answers = await inquirer.prompt<{
|
||||
shouldContinue: boolean;
|
||||
provider: string;
|
||||
url: string;
|
||||
}>([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'shouldContinue',
|
||||
message: 'Do you want to continue?',
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
name: 'provider',
|
||||
message: 'Please select which SCM provider you want to use:',
|
||||
choices: ['GitHub', 'GitLab'],
|
||||
default: providerInfo?.provider,
|
||||
when: ({ shouldContinue }) => shouldContinue,
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'url',
|
||||
message: `Which repository do you want to scan?`,
|
||||
when: ({ shouldContinue }) => shouldContinue,
|
||||
filter: (input, { provider }) => {
|
||||
if (provider === 'GitLab') {
|
||||
return `https://gitlab.com/${input}`;
|
||||
}
|
||||
if (provider === 'GitHub') {
|
||||
return `https://github.com/${input}`;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
if (!answers.shouldContinue) {
|
||||
Task.log(
|
||||
chalk.yellow(
|
||||
'If you change your mind, feel free to re-run this command.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { fullConfig: config } = await loadCliConfig({ args: [] });
|
||||
|
||||
const discovery = new Discovery();
|
||||
|
||||
if (answers.provider === 'GitHub') {
|
||||
discovery.addProvider(GithubDiscoveryProvider.fromConfig(config));
|
||||
}
|
||||
if (answers.provider === 'GitLab') {
|
||||
discovery.addProvider(GitlabDiscoveryProvider.fromConfig(config));
|
||||
}
|
||||
|
||||
discovery.addAnalyzer(new BasicRepositoryAnalyzer());
|
||||
discovery.addAnalyzer(new PackageJsonAnalyzer());
|
||||
|
||||
const { entities } = await discovery.run(answers.url);
|
||||
|
||||
if (!entities.length) {
|
||||
Task.log(
|
||||
chalk.yellow(`
|
||||
We could not find enough information to be able to generate any Software Catalog entities for you.
|
||||
Perhaps you can try again with a different repository?`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.forItem('Creating', DISCOVERED_ENTITIES_FILE, async () => {
|
||||
const payload: string[] = [];
|
||||
for (const entity of entities) {
|
||||
payload.push('---\n', yaml.stringify(entity));
|
||||
}
|
||||
await fs.writeFile(DISCOVERED_ENTITIES_FILE, payload.join(''));
|
||||
});
|
||||
|
||||
await Task.forItem(
|
||||
'Updating',
|
||||
APP_CONFIG_FILE,
|
||||
async () =>
|
||||
await updateConfigFile(APP_CONFIG_FILE, {
|
||||
catalog: {
|
||||
locations: [
|
||||
{
|
||||
type: 'file',
|
||||
target: DISCOVERED_ENTITIES_FILE,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
-163
@@ -1,163 +0,0 @@
|
||||
/*
|
||||
* 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 { Config } from '@backstage/config';
|
||||
import {
|
||||
DefaultGithubCredentialsProvider,
|
||||
GithubCredentialsProvider,
|
||||
ScmIntegrations,
|
||||
} from '@backstage/integration';
|
||||
import { graphql } from '@octokit/graphql';
|
||||
import {
|
||||
Repository as GraphqlRepository,
|
||||
Query as GraphqlQuery,
|
||||
} from '@octokit/graphql-schema';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { Provider, Repository } from '../types';
|
||||
import { GithubRepository } from './GithubRepository';
|
||||
|
||||
export class GithubDiscoveryProvider implements Provider {
|
||||
readonly #envToken: string | undefined;
|
||||
readonly #scmIntegrations: ScmIntegrations;
|
||||
readonly #credentialsProvider: GithubCredentialsProvider;
|
||||
|
||||
static fromConfig(config: Config): GithubDiscoveryProvider {
|
||||
const envToken = process.env.GITHUB_TOKEN || undefined;
|
||||
const scmIntegrations = ScmIntegrations.fromConfig(config);
|
||||
const credentialsProvider =
|
||||
DefaultGithubCredentialsProvider.fromIntegrations(scmIntegrations);
|
||||
return new GithubDiscoveryProvider(
|
||||
envToken,
|
||||
scmIntegrations,
|
||||
credentialsProvider,
|
||||
);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
envToken: string | undefined,
|
||||
integrations: ScmIntegrations,
|
||||
credentialsProvider: GithubCredentialsProvider,
|
||||
) {
|
||||
this.#envToken = envToken;
|
||||
this.#scmIntegrations = integrations;
|
||||
this.#credentialsProvider = credentialsProvider;
|
||||
}
|
||||
|
||||
name(): string {
|
||||
return 'GitHub';
|
||||
}
|
||||
|
||||
async discover(url: string): Promise<Repository[] | false> {
|
||||
if (!url.startsWith('https://github.com/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const scmIntegration = this.#scmIntegrations.github.byUrl(url);
|
||||
if (!scmIntegration) {
|
||||
throw new Error(`No GitHub integration found for ${url}`);
|
||||
}
|
||||
|
||||
const parsed = parseGitUrl(url);
|
||||
const { name, organization } = parsed;
|
||||
const org = organization || name; // depends on if it's a repo url or an org url...
|
||||
|
||||
const client = graphql.defaults({
|
||||
baseUrl: scmIntegration.config.apiBaseUrl,
|
||||
headers: await this.#getRequestHeaders(url),
|
||||
});
|
||||
|
||||
const { repositories } = await this.#getOrganizationRepositories(
|
||||
client,
|
||||
org,
|
||||
);
|
||||
|
||||
return repositories
|
||||
.filter(repo => repo.url.startsWith(url))
|
||||
.map(repo => new GithubRepository(client, repo, org));
|
||||
}
|
||||
|
||||
async #getRequestHeaders(url: string): Promise<Record<string, string>> {
|
||||
const credentials = await this.#credentialsProvider.getCredentials({
|
||||
url,
|
||||
});
|
||||
|
||||
if (credentials.headers) {
|
||||
return credentials.headers;
|
||||
} else if (credentials.token) {
|
||||
return { authorization: `token ${credentials.token}` };
|
||||
}
|
||||
|
||||
if (this.#envToken) {
|
||||
return { authorization: `token ${this.#envToken}` };
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'No token available for GitHub, please configure your integrations or set a GITHUB_TOKEN env variable',
|
||||
);
|
||||
}
|
||||
|
||||
async #getOrganizationRepositories(client: typeof graphql, org: string) {
|
||||
const query = `query repositories($org: String!, $cursor: String) {
|
||||
repositoryOwner(login: $org) {
|
||||
login
|
||||
repositories(first: 50, after: $cursor) {
|
||||
nodes {
|
||||
name
|
||||
url
|
||||
description
|
||||
isArchived
|
||||
isFork
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const result: GraphqlRepository[] = [];
|
||||
|
||||
let cursor: string | undefined | null = undefined;
|
||||
let hasNextPage = true;
|
||||
|
||||
while (hasNextPage) {
|
||||
const response: GraphqlQuery = await client(query, {
|
||||
org,
|
||||
cursor,
|
||||
});
|
||||
|
||||
const { repositories: connection } = response.repositoryOwner ?? {};
|
||||
|
||||
if (!connection) {
|
||||
throw new Error(`Found no repositories for ${org}`);
|
||||
}
|
||||
|
||||
for (const repository of connection.nodes ?? []) {
|
||||
if (repository && !repository.isArchived && !repository.isFork) {
|
||||
result.push(repository);
|
||||
}
|
||||
}
|
||||
|
||||
cursor = connection.pageInfo.endCursor;
|
||||
hasNextPage = connection.pageInfo.hasNextPage;
|
||||
}
|
||||
|
||||
return {
|
||||
repositories: result,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* 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 { RepositoryFile } from '../types';
|
||||
|
||||
export class GithubFile implements RepositoryFile {
|
||||
readonly #path: string;
|
||||
readonly #content: string;
|
||||
|
||||
constructor(path: string, content: string) {
|
||||
this.#path = path;
|
||||
this.#content = content;
|
||||
}
|
||||
|
||||
get path(): string {
|
||||
return this.#path;
|
||||
}
|
||||
|
||||
async text(): Promise<string> {
|
||||
return this.#content;
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* 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 { graphql } from '@octokit/graphql';
|
||||
import {
|
||||
Repository as GraphqlRepository,
|
||||
Blob as GraphqlBlob,
|
||||
} from '@octokit/graphql-schema';
|
||||
import { Repository, RepositoryFile } from '../types';
|
||||
import { GithubFile } from './GithubFile';
|
||||
|
||||
export class GithubRepository implements Repository {
|
||||
readonly #client: typeof graphql;
|
||||
readonly #repo: GraphqlRepository;
|
||||
readonly #org: string;
|
||||
|
||||
constructor(client: typeof graphql, repo: GraphqlRepository, org: string) {
|
||||
this.#client = client;
|
||||
this.#repo = repo;
|
||||
this.#org = org;
|
||||
}
|
||||
|
||||
get url(): string {
|
||||
return this.#repo.url;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.#repo.name;
|
||||
}
|
||||
|
||||
get owner(): string {
|
||||
return this.#org;
|
||||
}
|
||||
|
||||
get description(): string | undefined {
|
||||
return this.#repo.description ?? undefined;
|
||||
}
|
||||
|
||||
async file(filename: string): Promise<RepositoryFile | undefined> {
|
||||
const content = await this.#getFileContent(filename);
|
||||
if (!content || content.isBinary || !content.text) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new GithubFile(filename, content.text ?? '');
|
||||
}
|
||||
|
||||
async #getFileContent(filename: string) {
|
||||
const query = `query RepoFiles($owner: String!, $name: String!, $expr: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
object(expression: $expr) {
|
||||
...on Blob {
|
||||
text
|
||||
isBinary
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const response = await this.#client<{ repository: GraphqlRepository }>(
|
||||
query,
|
||||
{
|
||||
name: this.#repo.name,
|
||||
owner: this.#org,
|
||||
expr: `HEAD:${filename}`,
|
||||
},
|
||||
);
|
||||
|
||||
return response.repository.object as GraphqlBlob;
|
||||
}
|
||||
}
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
* 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 { Config } from '@backstage/config';
|
||||
import {
|
||||
DefaultGitlabCredentialsProvider,
|
||||
GitlabCredentialsProvider,
|
||||
ScmIntegrations,
|
||||
} from '@backstage/integration';
|
||||
import fetch from 'node-fetch';
|
||||
import { Provider } from '../types';
|
||||
import { GitlabProject, ProjectResponse } from './GitlabProject';
|
||||
|
||||
export class GitlabDiscoveryProvider implements Provider {
|
||||
readonly #envToken: string | undefined;
|
||||
readonly #scmIntegrations: ScmIntegrations;
|
||||
readonly #credentialsProvider: GitlabCredentialsProvider;
|
||||
|
||||
static fromConfig(config: Config): GitlabDiscoveryProvider {
|
||||
const envToken = process.env.GITLAB_TOKEN || undefined;
|
||||
const scmIntegrations = ScmIntegrations.fromConfig(config);
|
||||
const credentialsProvider =
|
||||
DefaultGitlabCredentialsProvider.fromIntegrations(scmIntegrations);
|
||||
|
||||
return new GitlabDiscoveryProvider(
|
||||
envToken,
|
||||
scmIntegrations,
|
||||
credentialsProvider,
|
||||
);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
envToken: string | undefined,
|
||||
integrations: ScmIntegrations,
|
||||
credentialsProvider: GitlabCredentialsProvider,
|
||||
) {
|
||||
this.#envToken = envToken;
|
||||
this.#scmIntegrations = integrations;
|
||||
this.#credentialsProvider = credentialsProvider;
|
||||
}
|
||||
|
||||
name(): string {
|
||||
return 'GitLab';
|
||||
}
|
||||
|
||||
async discover(url: string): Promise<false | GitlabProject[]> {
|
||||
const { origin, pathname } = new URL(url);
|
||||
const [, user] = pathname.split('/');
|
||||
|
||||
const scmIntegration = this.#scmIntegrations.gitlab.byUrl(origin);
|
||||
if (!scmIntegration) {
|
||||
throw new Error(`No GitLab integration found for ${origin}`);
|
||||
}
|
||||
|
||||
const headers = await this.#getRequestHeaders(origin);
|
||||
|
||||
const response = await fetch(
|
||||
`${scmIntegration.config.apiBaseUrl}/users/${user}/projects`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const projects: ProjectResponse[] = await response.json();
|
||||
|
||||
return projects.map(
|
||||
project =>
|
||||
new GitlabProject(project, scmIntegration.config.apiBaseUrl, headers),
|
||||
);
|
||||
}
|
||||
|
||||
async #getRequestHeaders(url: string): Promise<Record<string, string>> {
|
||||
const credentials = await this.#credentialsProvider.getCredentials({
|
||||
url,
|
||||
});
|
||||
|
||||
if (credentials.headers) {
|
||||
return credentials.headers;
|
||||
} else if (credentials.token) {
|
||||
return { authorization: `Bearer ${credentials.token}` };
|
||||
}
|
||||
|
||||
if (this.#envToken) {
|
||||
return { authorization: `Bearer ${this.#envToken}` };
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'No token available for GitLab, please set a GITLAB_TOKEN env variable',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* 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 { RepositoryFile } from '../types';
|
||||
|
||||
/**
|
||||
* A single file in a GitLab repository.
|
||||
*/
|
||||
export class GitlabFile implements RepositoryFile {
|
||||
readonly #path: string;
|
||||
readonly #content: string;
|
||||
|
||||
constructor(path: string, content: string) {
|
||||
this.#path = path;
|
||||
this.#content = content;
|
||||
}
|
||||
|
||||
get path(): string {
|
||||
return this.#path;
|
||||
}
|
||||
|
||||
async text(): Promise<string> {
|
||||
return this.#content;
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/*
|
||||
* 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 { GitlabFile } from './GitlabFile';
|
||||
import { Repository, RepositoryFile } from '../types';
|
||||
|
||||
export type ProjectResponse = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
owner: {
|
||||
username: string;
|
||||
};
|
||||
web_url: string;
|
||||
};
|
||||
|
||||
type BranchResponse = {
|
||||
default: boolean;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type FileContentResponse = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
export class GitlabProject implements Repository {
|
||||
constructor(
|
||||
private readonly project: ProjectResponse,
|
||||
private readonly apiBaseUrl: string,
|
||||
private readonly headers: { [name: string]: string },
|
||||
) {}
|
||||
|
||||
get url(): string {
|
||||
return this.project.web_url;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.project.name;
|
||||
}
|
||||
|
||||
get owner(): string {
|
||||
return this.project.owner.username;
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return this.project.description;
|
||||
}
|
||||
|
||||
async file(filename: string): Promise<RepositoryFile | undefined> {
|
||||
const mainBranch = await this.#getMainBranch();
|
||||
const content = await this.#getFileContent(filename, mainBranch);
|
||||
|
||||
return new GitlabFile(filename, content);
|
||||
}
|
||||
|
||||
async #getFileContent(path: string, mainBranch: string): Promise<string> {
|
||||
const response = await fetch(
|
||||
`${this.apiBaseUrl}/projects/${this.project.id}/repository/files/${path}?ref=${mainBranch}`,
|
||||
{ headers: this.headers },
|
||||
);
|
||||
const { content }: FileContentResponse = await response.json();
|
||||
|
||||
return Buffer.from(content, 'base64').toString('ascii');
|
||||
}
|
||||
|
||||
async #getMainBranch(): Promise<string> {
|
||||
const response = await fetch(
|
||||
`${this.apiBaseUrl}/projects/${this.project.id}/repository/branches`,
|
||||
{ headers: this.headers },
|
||||
);
|
||||
const branches: BranchResponse[] = await response.json();
|
||||
|
||||
return branches.find(branch => branch.default)?.name ?? 'main';
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Abstraction for a single repository.
|
||||
*/
|
||||
export interface Repository {
|
||||
url: string;
|
||||
|
||||
name: string;
|
||||
|
||||
owner: string;
|
||||
|
||||
description?: string;
|
||||
|
||||
file(filename: string): Promise<RepositoryFile | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstraction for a single repository file.
|
||||
*/
|
||||
export interface RepositoryFile {
|
||||
/**
|
||||
* The filepath of the data.
|
||||
*/
|
||||
path: string;
|
||||
|
||||
/**
|
||||
* The textual contents of the file.
|
||||
*/
|
||||
text(): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One integration that supports discovery of repositories.
|
||||
*/
|
||||
export interface Provider {
|
||||
name(): string;
|
||||
discover(url: string): Promise<Repository[] | false>;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* 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 { findPaths } from '@backstage/cli-common';
|
||||
import * as path from 'path';
|
||||
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
const { targetRoot, ownDir } = findPaths(__dirname);
|
||||
|
||||
export const APP_CONFIG_FILE = path.join(targetRoot, 'app-config.local.yaml');
|
||||
export const DISCOVERED_ENTITIES_FILE = path.join(
|
||||
targetRoot,
|
||||
'examples',
|
||||
'discovered-entities.yaml',
|
||||
);
|
||||
export const PATCH_FOLDER = path.join(
|
||||
ownDir,
|
||||
'src',
|
||||
'commands',
|
||||
'onboard',
|
||||
'auth',
|
||||
'patches',
|
||||
);
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* 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 { command } from './command';
|
||||
@@ -1,136 +0,0 @@
|
||||
/*
|
||||
* 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 chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
import { Task } from '../../../lib/tasks';
|
||||
import { updateConfigFile } from '../config';
|
||||
import { APP_CONFIG_FILE } from '../files';
|
||||
import { GitHubAnswers } from '../auth';
|
||||
|
||||
type Answers = {
|
||||
hasEnterprise: boolean;
|
||||
enterpriseInstanceUrl: string;
|
||||
apiBaseUrl: string;
|
||||
};
|
||||
|
||||
const getConfig = ({
|
||||
hasEnterprise,
|
||||
apiBaseUrl,
|
||||
host,
|
||||
token,
|
||||
}: {
|
||||
hasEnterprise: boolean;
|
||||
apiBaseUrl: string;
|
||||
host: string;
|
||||
token: string;
|
||||
}) => ({
|
||||
integrations: {
|
||||
github: [
|
||||
{
|
||||
host,
|
||||
token,
|
||||
...(hasEnterprise && {
|
||||
apiBaseUrl,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const github = async (providerAnswers?: GitHubAnswers) => {
|
||||
// TODO(tudi2d): Is GitHub Enterprise a valid setup if there is no Authentication?
|
||||
const answers = await inquirer.prompt<Answers>([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'hasEnterprise',
|
||||
message: 'Are you using GitHub Enterprise?',
|
||||
when: () => typeof providerAnswers === 'undefined',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'enterpriseInstanceUrl',
|
||||
message: 'What is your URL for GitHub Enterprise?',
|
||||
when: ({ hasEnterprise }) => hasEnterprise,
|
||||
validate: (input: string) => Boolean(new URL(input)),
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'apiBaseUrl',
|
||||
message: 'What is your GitHub Enterprise API path?',
|
||||
default: '/api/v3',
|
||||
when: ({ hasEnterprise }) =>
|
||||
hasEnterprise || providerAnswers?.hasEnterprise,
|
||||
// TODO(tudi2d): Fetch API using OAuth Token if Auth was set up
|
||||
},
|
||||
]);
|
||||
|
||||
const host = new URL(
|
||||
providerAnswers?.enterpriseInstanceUrl ??
|
||||
answers?.enterpriseInstanceUrl ??
|
||||
'http://github.com',
|
||||
);
|
||||
|
||||
Task.log(`
|
||||
To create new repositories in GitHub using Software Templates you first need to create a personal access token: ${chalk.blue(
|
||||
`${host.origin}/settings/tokens/new`,
|
||||
)}
|
||||
|
||||
Select the following scopes:
|
||||
|
||||
Reading software components:${chalk.cyan(`
|
||||
- "repo"`)}
|
||||
|
||||
Reading organization data:${chalk.cyan(`
|
||||
- "read:org"
|
||||
- "read:user"
|
||||
- "user:email"`)}
|
||||
|
||||
Publishing software templates:${chalk.cyan(`
|
||||
- "repo"
|
||||
- "workflow" (if templates include GitHub workflows)
|
||||
`)}
|
||||
|
||||
You can find the full documentation page here: ${chalk.blue(
|
||||
'https://backstage.io/docs/integrations/github/locations',
|
||||
)}
|
||||
`);
|
||||
|
||||
const { token } = await inquirer.prompt<{ token: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'token',
|
||||
message:
|
||||
'Please insert your personal access token to setup the GitHub Integration',
|
||||
// TODO(tudi2d): validate
|
||||
},
|
||||
]);
|
||||
|
||||
const config = getConfig({
|
||||
hasEnterprise: providerAnswers?.hasEnterprise ?? answers.hasEnterprise,
|
||||
apiBaseUrl: host.origin + answers.apiBaseUrl,
|
||||
host: host.hostname,
|
||||
token,
|
||||
});
|
||||
|
||||
Task.log('Setting up Software Templates using GitHub integration for you...');
|
||||
|
||||
await Task.forItem(
|
||||
'Updating',
|
||||
APP_CONFIG_FILE,
|
||||
async () => await updateConfigFile(APP_CONFIG_FILE, config),
|
||||
);
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* 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 inquirer from 'inquirer';
|
||||
import { Task } from '../../../lib/tasks';
|
||||
import { GitHubAnswers, GitLabAnswers } from '../auth';
|
||||
import { github } from './github';
|
||||
|
||||
enum Integration {
|
||||
GITHUB = 'GitHub',
|
||||
}
|
||||
|
||||
const Integrations: Integration[] = [Integration.GITHUB];
|
||||
|
||||
export async function integrations(providerInfo?: {
|
||||
provider: string;
|
||||
answers: GitHubAnswers | GitLabAnswers;
|
||||
}): Promise<void> {
|
||||
const answers = await inquirer.prompt<{
|
||||
integration?: Integration;
|
||||
shouldUsePreviousProvider: boolean;
|
||||
}>([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'shouldUsePreviousProvider',
|
||||
message: `Do you want to keep using ${providerInfo?.provider} as your provider when setting up Software Templates?`,
|
||||
when: () =>
|
||||
providerInfo?.provider &&
|
||||
Object.values(Integrations).includes(
|
||||
providerInfo!.provider as Integration,
|
||||
),
|
||||
},
|
||||
{
|
||||
// TODO(tudi2d): Let's start with one, but it should be multiple choice in the future
|
||||
type: 'list',
|
||||
name: 'integration',
|
||||
message: 'Please select an integration provider:',
|
||||
choices: Integrations,
|
||||
when: ({ shouldUsePreviousProvider }) => !shouldUsePreviousProvider,
|
||||
},
|
||||
]);
|
||||
|
||||
if (answers.shouldUsePreviousProvider) {
|
||||
answers.integration = providerInfo!.provider as Integration;
|
||||
}
|
||||
|
||||
switch (answers.integration) {
|
||||
case Integration.GITHUB: {
|
||||
const providerAnswers =
|
||||
providerInfo?.provider === 'GitHub'
|
||||
? (providerInfo!.answers as GitHubAnswers)
|
||||
: undefined;
|
||||
await github(providerAnswers);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
|
||||
Task.log();
|
||||
Task.log(`Done setting up ${answers.integration} Integration!`);
|
||||
Task.log();
|
||||
}
|
||||
Reference in New Issue
Block a user