Merge branch 'master' of github.com:spotify/backstage into shmidt-i/scaffolder-flow-frontend

This commit is contained in:
Ivan Shmidt
2020-07-06 13:53:45 +02:00
151 changed files with 3135 additions and 683 deletions
+4 -2
View File
@@ -8,6 +8,7 @@
"@backstage/plugin-catalog": "^0.1.1-alpha.12",
"@backstage/plugin-circleci": "^0.1.1-alpha.12",
"@backstage/plugin-explore": "^0.1.1-alpha.12",
"@backstage/plugin-github-actions": "^0.1.1-alpha.12",
"@backstage/plugin-gitops-profiles": "^0.1.1-alpha.12",
"@backstage/plugin-graphiql": "^0.1.1-alpha.12",
"@backstage/plugin-lighthouse": "^0.1.1-alpha.12",
@@ -21,12 +22,13 @@
"@backstage/theme": "^0.1.1-alpha.12",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"history": "^5.0.0",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.21",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0",
"zen-observable": "^0.8.15"
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 890 B

After

Width:  |  Height:  |  Size: 883 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg id="favicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 337.46 428.5">
<title>
Backstage favicon
</title>
<style>
#favicon path {
fill: #121212;
}
@media (prefers-color-scheme: dark) {
#favicon path {
fill: #7df3e1;
}
}
</style>
<path d="M303,166.05a80.69,80.69,0,0,0,13.45-10.37c.79-.77,1.55-1.53,2.3-2.3a83.12,83.12,0,0,0,7.93-9.38A63.69,63.69,0,0,0,333,133.23a48.58,48.58,0,0,0,4.35-16.4c1.49-19.39-10-38.67-35.62-54.22L198.56,0,78.3,115.23,0,190.25l108.6,65.91a111.59,111.59,0,0,0,57.76,16.41c24.92,0,48.8-8.8,66.42-25.69,19.16-18.36,25.52-42.12,13.7-61.87a49.22,49.22,0,0,0-6.8-8.87A89.17,89.17,0,0,0,259,178.29h.15a85.08,85.08,0,0,0,31-5.79A80.88,80.88,0,0,0,303,166.05ZM202.45,225.86c-19.32,18.51-50.4,21.23-75.7,5.9L51.61,186.15l67.45-64.64,76.41,46.38C223,184.58,221.49,207.61,202.45,225.86Zm8.93-82.22-70.65-42.89L205.14,39,274.51,81.1c25.94,15.72,29.31,37,10.55,55A60.69,60.69,0,0,1,211.38,143.64Zm29.86,190c-19.57,18.75-46.17,29.09-74.88,29.09a123.73,123.73,0,0,1-64.1-18.2L0,282.52v24.67L108.6,373.1a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.81,66.42-25.69,12.88-12.34,20-27.13,19.68-41.49v-1.79A87.27,87.27,0,0,1,241.24,333.68Zm0-39c-19.57,18.75-46.17,29.08-74.88,29.08a123.81,123.81,0,0,1-64.1-18.19L0,243.53v24.68l108.6,65.91a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.81,66.42-25.69,12.88-12.34,20-27.13,19.68-41.5v-1.78A87.27,87.27,0,0,1,241.24,294.7Zm0-39c-19.57,18.76-46.17,29.09-74.88,29.09a123.81,123.81,0,0,1-64.1-18.19L0,204.55v24.68l108.6,65.91a111.59,111.59,0,0,0,57.76,16.41c24.92,0,48.8-8.8,66.42-25.68,12.88-12.35,20-27.13,19.68-41.5v-1.82A86.09,86.09,0,0,1,241.24,255.71Zm83.7,25.74a94.15,94.15,0,0,1-60.2,25.86h0V334a81.6,81.6,0,0,0,51.74-22.37c14-13.38,21.14-28.11,21-42.64v-2.19A94.92,94.92,0,0,1,324.94,281.45Zm-83.7,91.21c-19.57,18.76-46.17,29.09-74.88,29.09a123.73,123.73,0,0,1-64.1-18.2L0,321.5v24.68l108.6,65.9a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.8,66.42-25.69,12.88-12.34,20-27.13,19.68-41.49v-1.79A86.29,86.29,0,0,1,241.24,372.66ZM327,162.45c-.68.69-1.35,1.38-2.05,2.06a94.37,94.37,0,0,1-10.64,8.65,91.35,91.35,0,0,1-11.6,7,94.53,94.53,0,0,1-26.24,8.71,97.69,97.69,0,0,1-14.16,1.57c.5,1.61.9,3.25,1.25,4.9a53.27,53.27,0,0,1,1.14,12V217h.05a84.41,84.41,0,0,0,25.35-5.55,81,81,0,0,0,26.39-16.82c.8-.77,1.5-1.56,2.26-2.34a82.08,82.08,0,0,0,7.93-9.38A63.76,63.76,0,0,0,333,172.17a48.55,48.55,0,0,0,4.32-16.45c.09-1.23.2-2.47.19-3.7V150q-1.08,1.54-2.25,3.09A96.73,96.73,0,0,1,327,162.45Zm0,77.92c-.69.7-1.31,1.41-2,2.1a94.2,94.2,0,0,1-60.2,25.86h0l0,26.67h0a81.6,81.6,0,0,0,51.74-22.37A73.51,73.51,0,0,0,333,250.13a48.56,48.56,0,0,0,4.32-16.44c.09-1.24.2-2.47.19-3.71v-2.19c-.74,1.07-1.46,2.15-2.27,3.21A95.68,95.68,0,0,1,327,240.37Zm0-39c-.69.7-1.31,1.41-2,2.1a93.18,93.18,0,0,1-10.63,8.65,91.63,91.63,0,0,1-11.63,7,95.47,95.47,0,0,1-37.94,10.18h0V256h0a81.65,81.65,0,0,0,51.74-22.37c.8-.77,1.5-1.56,2.26-2.34a82.08,82.08,0,0,0,7.93-9.38A63.76,63.76,0,0,0,333,211.15a48.56,48.56,0,0,0,4.32-16.44c.09-1.24.2-2.48.19-3.71v-2.2c-.74,1.08-1.46,2.16-2.27,3.22A95.68,95.68,0,0,1,327,201.39Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+12 -1
View File
@@ -26,12 +26,14 @@ import {
FeatureFlags,
GoogleAuth,
GithubAuth,
OAuth2,
OktaAuth,
GitlabAuth,
oauthRequestApiRef,
OAuthRequestManager,
googleAuthApiRef,
githubAuthApiRef,
oauth2ApiRef,
oktaAuthApiRef,
gitlabAuthApiRef,
storageApiRef,
@@ -110,7 +112,16 @@ export const apis = (config: ConfigApi) => {
builder.add(
gitlabAuthApiRef,
GitlabAuth.create({
apiOrigin: 'http://localhost:7000',
apiOrigin: backendUrl,
basePath: '/auth/',
oauthRequestApi,
}),
);
builder.add(
oauth2ApiRef,
OAuth2.create({
apiOrigin: backendUrl,
basePath: '/auth/',
oauthRequestApi,
}),
+1
View File
@@ -25,3 +25,4 @@ export { plugin as Sentry } from '@backstage/plugin-sentry';
export { plugin as GitopsProfiles } from '@backstage/plugin-gitops-profiles';
export { plugin as TechDocs } from '@backstage/plugin-techdocs';
export { plugin as GraphiQL } from '@backstage/plugin-graphiql';
export { plugin as GithubActions } from '@backstage/plugin-github-actions';
@@ -15,4 +15,5 @@
*/
require('jest-fetch-mock').enableMocks();
export {};
+7 -1
View File
@@ -25,7 +25,13 @@ You should only need to do this once.
After that, go to the `packages/backend` directory and run
```bash
AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x AUTH_GITHUB_CLIENT_ID=x AUTH_GITHUB_CLIENT_SECRET=x SENTRY_TOKEN=x LOG_LEVEL=debug yarn start
AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x \
AUTH_GITHUB_CLIENT_ID=x AUTH_GITHUB_CLIENT_SECRET=x \
AUTH_OAUTH2_CLIENT_ID=x AUTH_OAUTH2_CLIENT_SECRET=x \
AUTH_OAUTH2_AUTH_URL=x AUTH_OAUTH2_TOKEN_URL=x \
SENTRY_TOKEN=x \
LOG_LEVEL=debug \
yarn start
```
Substitute `x` for actual values, or leave them as
+1
View File
@@ -36,6 +36,7 @@ module.exports = {
rules: {
'no-console': 0, // Permitted in console programs
'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()'
'import/newline-after-import': 'error',
'import/no-duplicates': 'warn',
'import/no-extraneous-dependencies': [
'error',
+1
View File
@@ -41,6 +41,7 @@ module.exports = {
},
ignorePatterns: ['.eslintrc.js', '**/dist/**'],
rules: {
'import/newline-after-import': 'error',
'import/no-duplicates': 'warn',
'import/no-extraneous-dependencies': [
'error',
+204 -31
View File
@@ -16,65 +16,238 @@
const os = require('os');
const fs = require('fs-extra');
const { resolve: resolvePath } = require('path');
const killTree = require('tree-kill');
const { resolve: resolvePath, join: joinPath } = require('path');
const Browser = require('zombie');
const {
spawnPiped,
runPlain,
handleError,
waitForPageWithText,
waitFor,
waitForExit,
print,
} = require('./helpers');
const createTestApp = require('./createTestApp');
const createTestPlugin = require('./createTestPlugin');
Browser.localhost('localhost', 3000);
async function createTempDir() {
return fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-'));
}
async function main() {
process.env.BACKSTAGE_E2E_CLI_TEST = 'true';
const rootDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-'));
print(`CLI E2E test root: ${rootDir}\n`);
const workDir = process.env.CI ? process.cwd() : await createTempDir();
print('Building dist workspace');
const workspaceDir = await buildDistWorkspace('workspace', rootDir);
process.stdout.write(`Initial directory: ${process.cwd()}\n`);
process.chdir(workDir);
process.stdout.write(`Working directory: ${process.cwd()}\n`);
print('Creating a Backstage App');
const appDir = await createApp('test-app', workspaceDir, rootDir);
await createTestApp();
const appDir = resolvePath(workDir, 'test-app');
process.chdir(appDir);
process.stdout.write(`App directory: ${appDir}\n`);
await createTestPlugin();
print('Creating a Backstage Plugin');
const pluginName = await createPlugin('test-plugin', appDir);
print('Starting the app');
const startApp = spawnPiped(['yarn', 'start']);
await testAppServe(pluginName, appDir);
print('All tests successful, removing test dir');
await fs.remove(rootDir);
}
/**
* Builds a dist workspace that contains the cli and core packages
*/
async function buildDistWorkspace(workspaceName, rootDir) {
const workspaceDir = resolvePath(rootDir, workspaceName);
await fs.ensureDir(workspaceDir);
print(`Preparing workspace`);
await runPlain([
'yarn',
'backstage-cli',
'build-workspace',
workspaceDir,
'@backstage/cli',
'@backstage/core',
'@backstage/dev-utils',
'@backstage/test-utils',
]);
print('Pinning yarn version in workspace');
await pinYarnVersion(workspaceDir);
print('Installing workspace dependencies');
await runPlain(['yarn', 'install', '--production', '--frozen-lockfile'], {
cwd: workspaceDir,
});
return workspaceDir;
}
/**
* Pin the yarn version in a directory to the one we're using in the Backstage repo
*/
async function pinYarnVersion(dir) {
const repoRoot = resolvePath(__dirname, '../../..');
const yarnRc = await fs.readFile(resolvePath(repoRoot, '.yarnrc'), 'utf8');
const yarnRcLines = yarnRc.split('\n');
const yarnPathLine = yarnRcLines.find(line => line.startsWith('yarn-path'));
const [, localYarnPath] = yarnPathLine.match(/"(.*)"/);
const yarnPath = resolvePath(repoRoot, localYarnPath);
await fs.writeFile(resolvePath(dir, '.yarnrc'), `yarn-path "${yarnPath}"\n`);
}
/**
* Creates a new app inside rootDir called test-app, using packages from the workspaceDir
*/
async function createApp(appName, workspaceDir, rootDir) {
const child = spawnPiped(
[
'node',
resolvePath(workspaceDir, 'packages/cli/bin/backstage-cli'),
'create-app',
'--skip-install',
],
{
cwd: rootDir,
},
);
try {
let stdout = '';
child.stdout.on('data', data => {
stdout = stdout + data.toString('utf8');
});
await waitFor(() => stdout.includes('Enter a name for the app'));
child.stdin.write(`${appName}\n`);
print('Waiting for app create script to be done');
await waitForExit(child);
const appDir = resolvePath(rootDir, appName);
print('Rewriting module resolutions of app to use workspace packages');
await overrideModuleResolutions(appDir, workspaceDir);
print('Pinning yarn version and registry in app');
await pinYarnVersion(appDir);
await fs.writeFile(
resolvePath(appDir, '.npmrc'),
'registry=https://registry.npmjs.org/\n',
);
print('Test app created');
for (const cmd of ['install', 'tsc', 'build', 'lint:all', 'test:all']) {
print(`Running 'yarn ${cmd}' in newly created app`);
await runPlain(['yarn', cmd], { cwd: appDir });
}
print(`Running 'yarn test:e2e:ci' in newly created app`);
await runPlain(['yarn', 'test:e2e:ci'], {
cwd: resolvePath(appDir, 'packages', 'app'),
env: {
...process.env,
APP_CONFIG_app_baseUrl: '"http://localhost:3001"',
},
});
return appDir;
} finally {
child.kill();
}
}
/**
* This points dependency resolutions into the workspace for each package that is present there
*/
async function overrideModuleResolutions(appDir, workspaceDir) {
const pkgJsonPath = resolvePath(appDir, 'package.json');
const pkgJson = await fs.readJson(pkgJsonPath);
pkgJson.resolutions = pkgJson.resolutions || {};
pkgJson.dependencies = pkgJson.dependencies || {};
const packageNames = await fs.readdir(resolvePath(workspaceDir, 'packages'));
for (const name of packageNames) {
const pkgPath = joinPath('..', 'workspace', 'packages', name);
pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
delete pkgJson.devDependencies[`@backstage/${name}`];
}
fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
}
/**
* Uses create-plugin command to create a new plugin in the app
*/
async function createPlugin(pluginName, appDir) {
const child = spawnPiped(['yarn', 'create-plugin'], {
cwd: appDir,
});
try {
let stdout = '';
child.stdout.on('data', data => {
stdout = stdout + data.toString('utf8');
});
await waitFor(() => stdout.includes('Enter an ID for the plugin'));
child.stdin.write(`${pluginName}\n`);
// await waitFor(() => stdout.includes('Enter the owner(s) of the plugin'));
// child.stdin.write('@someuser\n');
print('Waiting for plugin create script to be done');
await waitForExit(child);
const pluginDir = resolvePath(appDir, 'plugins', pluginName);
for (const cmd of [['lint'], ['test', '--no-watch']]) {
print(`Running 'yarn ${cmd.join(' ')}' in newly created plugin`);
await runPlain(['yarn', ...cmd], { cwd: pluginDir });
}
return pluginName;
} finally {
child.kill();
}
}
/**
* Start serving the newly created app and make sure that the create plugin is rendering correctly
*/
async function testAppServe(pluginName, appDir) {
const startApp = spawnPiped(['yarn', 'start'], {
cwd: appDir,
});
Browser.localhost('localhost', 3000);
let successful = false;
try {
const browser = new Browser();
await waitForPageWithText(browser, '/', 'Welcome to Backstage');
await waitForPageWithText(
browser,
'/test-plugin',
'Welcome to test-plugin!',
`/${pluginName}`,
`Welcome to ${pluginName}!`,
);
print('Both App and Plugin loaded correctly');
successful = true;
} catch (error) {
throw new Error(`App serve test failed, ${error}`);
} finally {
startApp.kill();
// Kill entire process group, otherwise we'll end up with hanging serve processes
killTree(startApp.pid);
}
await waitForExit(startApp);
print('All tests done');
process.exit(0);
try {
await waitForExit(startApp);
} catch (error) {
if (!successful) {
throw error;
}
}
}
process.on('unhandledRejection', handleError);
-44
View File
@@ -1,44 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
const { resolve: resolvePath } = require('path');
const { spawnPiped, waitFor, waitForExit, print } = require('./helpers');
async function createTestApp() {
const cliPath = resolvePath(__dirname, '../bin/backstage-cli');
print('Creating a Backstage App');
const createApp = spawnPiped(['node', cliPath, 'create-app']);
try {
let stdout = '';
createApp.stdout.on('data', data => {
stdout = stdout + data.toString('utf8');
});
await waitFor(() => stdout.includes('Enter a name for the app'));
createApp.stdin.write('test-app\n');
print('Waiting for app create script to be done');
await waitForExit(createApp);
print('Test app created');
} finally {
createApp.kill();
}
}
module.exports = createTestApp;
-44
View File
@@ -1,44 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
const { spawnPiped, waitFor, waitForExit, print } = require('./helpers');
async function createTestPlugin() {
print('Creating a Backstage Plugin');
const createPlugin = spawnPiped(['yarn', 'create-plugin']);
try {
let stdout = '';
createPlugin.stdout.on('data', data => {
stdout = stdout + data.toString('utf8');
});
await waitFor(() => stdout.includes('Enter an ID for the plugin'));
createPlugin.stdin.write('test-plugin\n');
// await waitFor(() => stdout.includes('Enter the owner(s) of the plugin'));
// createPlugin.stdin.write('@someuser\n');
print('Waiting for plugin create script to be done');
await waitForExit(createPlugin);
print('Test plugin created');
} finally {
createPlugin.kill();
}
}
module.exports = createTestPlugin;
+25 -11
View File
@@ -14,8 +14,10 @@
* limitations under the License.
*/
const childProcess = require('child_process');
const { spawn } = childProcess;
const { spawn, execFile: execFileCb } = require('child_process');
const { promisify } = require('util');
const execFile = promisify(execFileCb);
const EXPECTED_LOAD_ERRORS = /ECONNREFUSED|ECONNRESET|did not get to load all resources/;
@@ -36,24 +38,35 @@ function spawnPiped(cmd, options) {
...options,
});
child.on('error', handleError);
child.on('exit', code => {
if (code) {
print(`Child '${cmd.join(' ')}' exited with code ${code}`);
process.exit(code);
}
});
const logPrefix = cmd.map(s => s.replace(/.+\//, '')).join(' ');
child.stdout.on(
'data',
pipeWithPrefix(process.stdout, `[${cmd.join(' ')}].out: `),
pipeWithPrefix(process.stdout, `[${logPrefix}].out: `),
);
child.stderr.on(
'data',
pipeWithPrefix(process.stderr, `[${cmd.join(' ')}].err: `),
pipeWithPrefix(process.stderr, `[${logPrefix}].err: `),
);
return child;
}
async function runPlain(cmd, options) {
try {
const { stdout } = await execFile(cmd[0], cmd.slice(1), {
...options,
shell: true,
});
return stdout.trim();
} catch (error) {
if (error.stderr) {
process.stderr.write(error.stderr);
}
throw error;
}
}
function handleError(err) {
process.stdout.write(`${err.name}: ${err.stack || err.message}\n`);
if (typeof err.code === 'number') {
@@ -133,7 +146,7 @@ async function waitForPageWithText(
if (findTextAttempts <= maxFindTextAttempts) {
await browser.visit(path);
await new Promise(resolve => setTimeout(resolve, intervalMs));
continue
continue;
} else {
throw error;
}
@@ -147,6 +160,7 @@ function print(msg) {
module.exports = {
spawnPiped,
runPlain,
handleError,
waitFor,
waitForExit,
+1
View File
@@ -112,6 +112,7 @@
"@types/webpack-dev-server": "^3.10.0",
"del": "^5.1.0",
"nodemon": "^2.0.2",
"tree-kill": "^1.2.2",
"ts-node": "^8.6.2",
"zombie": "^6.1.4"
},
@@ -0,0 +1,29 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { Command } from 'commander';
import { createDistWorkspace } from '../lib/packager';
export default async (dir: string, _cmd: Command, packages: string[]) => {
if (!(await fs.pathExists(dir))) {
throw new Error(`Target workspace directory doesn't exist, '${dir}'`);
}
await createDistWorkspace(packages, {
targetDir: dir,
});
};
@@ -17,13 +17,15 @@
import fs from 'fs-extra';
import { promisify } from 'util';
import chalk from 'chalk';
import { Command } from 'commander';
import inquirer, { Answers, Question } from 'inquirer';
import { exec as execCb } from 'child_process';
import { resolve as resolvePath } from 'path';
import os from 'os';
import { Task, templatingTask, installWithLocalDeps } from '../../lib/tasks';
import { Task, templatingTask } from '../../lib/tasks';
import { paths } from '../../lib/paths';
import { version } from '../../lib/version';
const exec = promisify(execCb);
async function checkExists(rootDir: string, name: string) {
@@ -39,7 +41,7 @@ async function checkExists(rootDir: string, name: string) {
});
}
export async function createTemporaryAppFolder(tempDir: string) {
async function createTemporaryAppFolder(tempDir: string) {
await Task.forItem('creating', 'temporary directory', async () => {
try {
await fs.mkdir(tempDir);
@@ -70,16 +72,12 @@ async function buildApp(appDir: string) {
});
};
await installWithLocalDeps(appDir);
await runCmd('yarn install');
await runCmd('yarn tsc');
await runCmd('yarn build');
}
export async function moveApp(
tempDir: string,
destination: string,
id: string,
) {
async function moveApp(tempDir: string, destination: string, id: string) {
await Task.forItem('moving', id, async () => {
await fs.move(tempDir, destination).catch(error => {
throw new Error(
@@ -89,7 +87,7 @@ export async function moveApp(
});
}
export default async () => {
export default async (cmd: Command): Promise<void> => {
const questions: Question[] = [
{
type: 'input',
@@ -129,8 +127,10 @@ export default async () => {
Task.section('Moving to final location');
await moveApp(tempDir, appDir, answers.name);
Task.section('Building the app');
await buildApp(appDir);
if (!cmd.skipInstall) {
Task.section('Building the app');
await buildApp(appDir);
}
Task.log();
Task.log(
@@ -28,7 +28,8 @@ import {
} from '../../lib/codeowners';
import { paths } from '../../lib/paths';
import { version } from '../../lib/version';
import { Task, templatingTask, installWithLocalDeps } from '../../lib/tasks';
import { Task, templatingTask } from '../../lib/tasks';
const exec = promisify(execCb);
async function checkExists(rootDir: string, id: string) {
@@ -141,9 +142,7 @@ async function cleanUp(tempDir: string) {
}
async function buildPlugin(pluginFolder: string) {
await installWithLocalDeps(paths.targetRoot);
const commands = ['yarn tsc', 'yarn build'];
const commands = ['yarn install', 'yarn tsc', 'yarn build'];
for (const command of commands) {
await Task.forItem('executing', command, async () => {
process.chdir(pluginFolder);
+9
View File
@@ -25,6 +25,10 @@ const main = (argv: string[]) => {
program
.command('create-app')
.description('Creates a new app in a new directory')
.option(
'--skip-install',
'Skip the install and builds steps after creating the app',
)
.action(
lazyAction(() => import('./commands/create-app/createApp'), 'default'),
);
@@ -140,6 +144,11 @@ const main = (argv: string[]) => {
.description('Delete cache directories')
.action(lazyAction(() => import('./commands/clean/clean'), 'default'));
program
.command('build-workspace <workspace-dir> ...<packages>')
.description('Builds a temporary dist workspace from the provided packages')
.action(lazyAction(() => import('./commands/buildWorkspace'), 'default'));
program.on('command:*', () => {
console.log();
console.log(
+1 -1
View File
@@ -59,7 +59,7 @@ type Options = {
*/
export async function createDistWorkspace(
packageNames: string[],
options: Options,
options: Options = {},
) {
const targetDir =
options.targetDir ??
+1
View File
@@ -23,6 +23,7 @@ import {
import { ExitCodeError } from './errors';
import { promisify } from 'util';
import { LogFunc } from './logging';
const execFile = promisify(execFileCb);
type SpawnOptionsPartialEnv = Omit<SpawnOptions, 'env'> & {
+1 -106
View File
@@ -18,12 +18,8 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import handlebars from 'handlebars';
import ora from 'ora';
import { resolve as resolvePath, basename, dirname } from 'path';
import { basename, dirname } from 'path';
import recursive from 'recursive-readdir';
import { promisify } from 'util';
import { exec as execCb } from 'child_process';
import { paths } from './paths';
const exec = promisify(execCb);
const TASK_NAME_MAX_LENGTH = 14;
@@ -107,104 +103,3 @@ export async function templatingTask(
}
}
}
// List of local packages that we need to modify as a part of an E2E test
const PATCH_PACKAGES = [
'cli',
'config',
'config-loader',
'core',
'core-api',
'dev-utils',
'test-utils',
'test-utils-core',
'theme',
];
// This runs a `yarn install` task, but with special treatment for e2e tests
export async function installWithLocalDeps(dir: string) {
// This makes us install any package inside this repo as a local file dependency.
// For example, instead of trying to fetch @backstage/core from npm, we point it
// to <repo-root>/packages/core. This makes yarn use a simple file copy to install it instead.
if (process.env.BACKSTAGE_E2E_CLI_TEST) {
Task.section('Linking packages locally for e2e tests');
const pkgJsonPath = resolvePath(dir, 'package.json');
const pkgJson = await fs.readJson(pkgJsonPath);
pkgJson.resolutions = pkgJson.resolutions || {};
pkgJson.dependencies = pkgJson.dependencies || {};
if (!pkgJson.resolutions[`@backstage/${PATCH_PACKAGES[0]}`]) {
for (const name of PATCH_PACKAGES) {
await Task.forItem(
'adding',
`@backstage/${name} link to package.json`,
async () => {
const pkgPath = paths.resolveOwnRoot('packages', name);
// Add to both resolutions and dependencies, or transitive dependencies will still be fetched from the registry.
pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
delete pkgJson.devDependencies[`@backstage/${name}`];
await fs
.writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 })
.catch(error => {
throw new Error(
`Failed to add resolutions to package.json: ${error.message}`,
);
});
},
);
}
}
}
await Task.forItem('executing', 'yarn install', async () => {
await exec('yarn install', { cwd: dir }).catch(error => {
process.stdout.write(error.stderr);
process.stdout.write(error.stdout);
throw new Error(
`Could not execute command ${chalk.cyan('yarn install')}`,
);
});
});
// This takes care of pointing all the installed packages from this repo to
// dist instead of the local src, using the field overrides in publishConfig.
// Without this we get type checking errors in the e2e test
if (process.env.BACKSTAGE_E2E_CLI_TEST) {
Task.section('Patching local dependencies for e2e tests');
for (const name of PATCH_PACKAGES) {
await Task.forItem(
'patching',
`node_modules/@backstage/${name} package.json`,
async () => {
const depJsonPath = resolvePath(
dir,
'node_modules/@backstage',
name,
'package.json',
);
const depJson = await fs.readJson(depJsonPath);
// We want dist to be used for e2e tests
for (const key of Object.keys(depJson.publishConfig)) {
if (key !== 'access') {
depJson[key] = depJson.publishConfig[key];
}
}
await fs
.writeJSON(depJsonPath, depJson, { encoding: 'utf8', spaces: 2 })
.catch(error => {
throw new Error(
`Failed to add resolutions to package.json: ${error.message}`,
);
});
},
);
}
}
}
@@ -10,11 +10,12 @@
"@backstage/core": "^{{version}}",
"@backstage/test-utils": "^{{version}}",
"@backstage/theme": "^{{version}}",
"history": "^5.0.0",
"plugin-welcome": "0.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0"
},
"devDependencies": {
@@ -26,7 +26,7 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^14.2.0",
"react-router-dom": "6.0.0-alpha.5"
"react-router-dom": "6.0.0-beta.0"
},
"devDependencies": {
"@backstage/cli": "^{{version}}",
@@ -5,7 +5,7 @@
* 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
* 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,
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+1 -1
View File
@@ -36,7 +36,7 @@
"@types/react": "^16.9",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-router-dom": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0",
"zen-observable": "^0.8.15"
},
@@ -263,3 +263,13 @@ export const gitlabAuthApiRef = createApiRef<
id: 'core.auth.gitlab',
description: 'Provides authentication towards Gitlab APIs',
});
/**
* Provides authentication for custom identity providers.
*/
export const oauth2ApiRef = createApiRef<
OAuthApi & OpenIdConnectApi & ProfileInfoApi & SessionStateApi
>({
id: 'core.auth.oauth2',
description: 'Example of how to use oauth2 custom provider',
});
@@ -14,7 +14,8 @@
* limitations under the License.
*/
export * from './google';
export * from './github';
export * from './gitlab';
export * from './google';
export * from './oauth2';
export * from './okta';
@@ -0,0 +1,164 @@
/*
* Copyright 2020 Spotify AB
*
* 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 OAuth2Icon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { Observable } from '../../../../types';
import { AuthProvider, OAuthRequestApi } from '../../../definitions';
import {
AuthRequestOptions,
BackstageIdentity,
OAuthApi,
OpenIdConnectApi,
ProfileInfo,
ProfileInfoApi,
SessionState,
SessionStateApi,
} from '../../../definitions/auth';
import { OAuth2Session } from './types';
type CreateOptions = {
apiOrigin: string;
basePath: string;
oauthRequestApi: OAuthRequestApi;
environment?: string;
provider?: AuthProvider & { id: string };
};
export type OAuth2Response = {
providerInfo: {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'oauth2',
title: 'Your Identity Provider',
icon: OAuth2Icon,
};
const SCOPE_PREFIX = '';
class OAuth2
implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi {
static create({
apiOrigin,
basePath,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
apiOrigin,
basePath,
environment,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: OAuth2Response): OAuth2Session {
return {
...res,
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: OAuth2.normalizeScopes(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
const sessionManager = new RefreshingAuthSessionManager({
connector,
defaultScopes: new Set([
'openid',
`${SCOPE_PREFIX}userinfo.email`,
`${SCOPE_PREFIX}userinfo.profile`,
]),
sessionScopes: (session: OAuth2Session) => session.providerInfo.scopes,
sessionShouldRefresh: (session: OAuth2Session) => {
const expiresInSec =
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
return expiresInSec < 60 * 5;
},
});
return new OAuth2(sessionManager);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<OAuth2Session>) {}
async getAccessToken(
scope?: string | string[],
options?: AuthRequestOptions,
) {
const normalizedScopes = OAuth2.normalizeScopes(scope);
const session = await this.sessionManager.getSession({
...options,
scopes: normalizedScopes,
});
return session?.providerInfo.accessToken ?? '';
}
async getIdToken(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.providerInfo.idToken ?? '';
}
async logout() {
await this.sessionManager.removeSession();
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
static normalizeScopes(scopes?: string | string[]): Set<string> {
if (!scopes) {
return new Set();
}
const scopeList = Array.isArray(scopes)
? scopes
: scopes.split(/[\s]/).filter(Boolean);
return new Set(scopeList);
}
}
export default OAuth2;
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { default as OAuth2 } from './OAuth2';
export * from './types';
@@ -0,0 +1,28 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type OAuth2Session = {
providerInfo: {
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
+1
View File
@@ -16,4 +16,5 @@
export * from './public';
import * as privateExports from './private';
export default privateExports;
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+2 -2
View File
@@ -47,8 +47,8 @@
"react-dom": "^16.12.0",
"react-helmet": "6.1.0",
"react-hook-form": "^5.7.2",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-sparklines": "^1.7.0",
"react-syntax-highlighter": "^12.2.1",
"react-use": "^14.2.0"
@@ -25,6 +25,7 @@ import {
WithStyles,
Theme,
} from '@material-ui/core';
const tableTitleCellStyles = (theme: Theme) =>
createStyles({
root: {
@@ -0,0 +1,66 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
render,
act,
RenderResult,
waitFor,
fireEvent,
} from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { SupportButton } from './SupportButton';
const SUPPORT_BUTTON_ID = 'support-button';
const POPOVER_ID = 'support-button-popover';
describe('<SupportButton />', () => {
it('renders without exploding', async () => {
let renderResult: RenderResult;
await act(async () => {
renderResult = render(wrapInTestApp(<SupportButton />));
});
await waitFor(() =>
expect(renderResult.getByTestId(SUPPORT_BUTTON_ID)).toBeInTheDocument(),
);
});
it('shows popover on click', async () => {
let renderResult: RenderResult;
await act(async () => {
renderResult = render(wrapInTestApp(<SupportButton />));
});
let button: HTMLElement;
await waitFor(() => {
expect(renderResult.getByTestId(SUPPORT_BUTTON_ID)).toBeInTheDocument();
button = renderResult.getByTestId(SUPPORT_BUTTON_ID);
});
await act(async () => {
fireEvent.click(button);
});
await waitFor(() => {
expect(renderResult.getByTestId(POPOVER_ID)).toBeInTheDocument();
});
});
});
@@ -87,6 +87,7 @@ export const SupportButton: FC<Props> = ({
Support
</Button>
<Popover
data-testid="support-button-popover"
open={popoverOpen}
anchorEl={anchorEl}
anchorOrigin={{
@@ -120,7 +121,8 @@ export const SupportButton: FC<Props> = ({
<GroupIcon />
</ListItemIcon>
<ListItemText
primary="Support"
disableTypography
primary={<Typography>Support</Typography>}
secondary={
<div>
{slackChannels.map((channel, i) => (
@@ -137,7 +139,8 @@ export const SupportButton: FC<Props> = ({
<GroupIcon />
</ListItemIcon>
<ListItemText
primary="Contact"
disableTypography
primary={<Typography>Contact</Typography>}
secondary={
<div>
{contactEmails.map((em, index) => (
+3
View File
@@ -44,6 +44,9 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.shortest,
}),
'& > *': {
flexShrink: 0,
},
},
drawerOpen: {
width: sidebarConfig.drawerWidthOpen,
@@ -14,25 +14,26 @@
* limitations under the License.
*/
import React, { useContext, useEffect } from 'react';
import Collapse from '@material-ui/core/Collapse';
import Star from '@material-ui/icons/Star';
import SignOutIcon from '@material-ui/icons/MeetingRoom';
import { SidebarContext } from './config';
import {
googleAuthApiRef,
githubAuthApiRef,
gitlabAuthApiRef,
googleAuthApiRef,
identityApiRef,
oauth2ApiRef,
oktaAuthApiRef,
useApi,
} from '@backstage/core-api';
import Collapse from '@material-ui/core/Collapse';
import SignOutIcon from '@material-ui/icons/MeetingRoom';
import Star from '@material-ui/icons/Star';
import React, { useContext, useEffect } from 'react';
import { SidebarContext } from './config';
import { SidebarItem } from './Items';
import {
OAuthProviderSettings,
OIDCProviderSettings,
UserProfile as SidebarUserProfile,
} from './Settings';
import { SidebarItem } from './Items';
export function SidebarUserSettings() {
const { isOpen: sidebarOpen } = useContext(SidebarContext);
@@ -68,6 +69,11 @@ export function SidebarUserSettings() {
apiRef={oktaAuthApiRef}
icon={Star}
/>
<OIDCProviderSettings
title="YourOrg"
apiRef={oauth2ApiRef}
icon={Star}
/>
<SidebarItem
icon={SignOutIcon}
text="Sign Out"
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+2 -2
View File
@@ -42,8 +42,8 @@
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.21",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5"
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0"
},
"devDependencies": {
"@types/jest": "^25.2.2",
+23 -12
View File
@@ -1,21 +1,23 @@
import {
ApiRegistry,
AlertApiForwarder,
alertApiRef,
ApiRegistry,
ErrorAlerter,
ErrorApiForwarder,
errorApiRef,
GithubAuth,
githubAuthApiRef,
GitlabAuth,
gitlabAuthApiRef,
GoogleAuth,
googleAuthApiRef,
identityApiRef,
OAuth2,
oauth2ApiRef,
oauthRequestApiRef,
OAuthRequestManager,
googleAuthApiRef,
githubAuthApiRef,
gitlabAuthApiRef,
oktaAuthApiRef,
AlertApiForwarder,
ErrorApiForwarder,
ErrorAlerter,
GoogleAuth,
GithubAuth,
GitlabAuth,
OktaAuth,
identityApiRef,
oktaAuthApiRef,
} from '@backstage/core';
const builder = ApiRegistry.builder();
@@ -72,4 +74,13 @@ builder.add(
}),
);
builder.add(
oauth2ApiRef,
OAuth2.create({
apiOrigin: 'http://localhost:7000',
basePath: '/auth/',
oauthRequestApi,
}),
);
export const apis = builder.build();
+2 -2
View File
@@ -40,8 +40,8 @@
"@types/react": "^16.9",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"zen-observable": "^0.8.15"
},
"devDependencies": {
@@ -25,6 +25,7 @@ import privateExports, {
import { RenderResult } from '@testing-library/react';
import { renderWithEffects } from '@backstage/test-utils-core';
import { createMockApiRegistry } from './mockApiRegistry';
const { PrivateAppImpl } = privateExports;
const NotFoundErrorPage = () => {