Merge branch 'mob/create-vcs-step' of github.com:spotify/backstage into shmidt-i/scaffolder-flow-frontend
This commit is contained in:
@@ -76,13 +76,6 @@
|
||||
"pathRewrite": {
|
||||
"^/circleci/api/": "/"
|
||||
}
|
||||
},
|
||||
"/catalog/api": {
|
||||
"target": "http://localhost:7000",
|
||||
"changeOrigin": true,
|
||||
"pathRewrite": {
|
||||
"^/catalog/api/": "/catalog/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('App', () => {
|
||||
{
|
||||
data: {
|
||||
app: { title: 'Test' },
|
||||
backend: { baseUrl: 'http://localhost:7000' },
|
||||
},
|
||||
context: 'test',
|
||||
},
|
||||
|
||||
@@ -27,11 +27,13 @@ import {
|
||||
GoogleAuth,
|
||||
GithubAuth,
|
||||
OktaAuth,
|
||||
GitlabAuth,
|
||||
oauthRequestApiRef,
|
||||
OAuthRequestManager,
|
||||
googleAuthApiRef,
|
||||
githubAuthApiRef,
|
||||
oktaAuthApiRef,
|
||||
gitlabAuthApiRef,
|
||||
storageApiRef,
|
||||
WebStorage,
|
||||
} from '@backstage/core';
|
||||
@@ -51,15 +53,14 @@ import {
|
||||
graphQlBrowseApiRef,
|
||||
GraphQLEndpoints,
|
||||
} from '@backstage/plugin-graphiql';
|
||||
import {
|
||||
scaffolderApiRef,
|
||||
ScaffolderApi,
|
||||
} from '@backstage/plugin-scaffolder/src/api';
|
||||
import { scaffolderApiRef, ScaffolderApi } from '@backstage/plugin-scaffolder';
|
||||
|
||||
export const apis = (config: ConfigApi) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Creating APIs for ${config.getString('app.title')}`);
|
||||
|
||||
const backendUrl = config.getString('backend.baseUrl');
|
||||
|
||||
const builder = ApiRegistry.builder();
|
||||
|
||||
const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
|
||||
@@ -82,7 +83,7 @@ export const apis = (config: ConfigApi) => {
|
||||
builder.add(
|
||||
googleAuthApiRef,
|
||||
GoogleAuth.create({
|
||||
apiOrigin: 'http://localhost:7000',
|
||||
apiOrigin: backendUrl,
|
||||
basePath: '/auth/',
|
||||
oauthRequestApi,
|
||||
}),
|
||||
@@ -91,7 +92,7 @@ export const apis = (config: ConfigApi) => {
|
||||
const githubAuthApi = builder.add(
|
||||
githubAuthApiRef,
|
||||
GithubAuth.create({
|
||||
apiOrigin: 'http://localhost:7000',
|
||||
apiOrigin: backendUrl,
|
||||
basePath: '/auth/',
|
||||
oauthRequestApi,
|
||||
}),
|
||||
@@ -100,6 +101,15 @@ export const apis = (config: ConfigApi) => {
|
||||
builder.add(
|
||||
oktaAuthApiRef,
|
||||
OktaAuth.create({
|
||||
apiOrigin: backendUrl,
|
||||
basePath: '/auth/',
|
||||
oauthRequestApi,
|
||||
}),
|
||||
);
|
||||
|
||||
builder.add(
|
||||
gitlabAuthApiRef,
|
||||
GitlabAuth.create({
|
||||
apiOrigin: 'http://localhost:7000',
|
||||
basePath: '/auth/',
|
||||
oauthRequestApi,
|
||||
@@ -117,7 +127,7 @@ export const apis = (config: ConfigApi) => {
|
||||
builder.add(
|
||||
catalogApiRef,
|
||||
new CatalogClient({
|
||||
apiOrigin: 'http://localhost:7000',
|
||||
apiOrigin: backendUrl,
|
||||
basePath: '/catalog',
|
||||
}),
|
||||
);
|
||||
@@ -125,7 +135,7 @@ export const apis = (config: ConfigApi) => {
|
||||
builder.add(
|
||||
scaffolderApiRef,
|
||||
new ScaffolderApi({
|
||||
apiOrigin: 'http://localhost:7000',
|
||||
apiOrigin: backendUrl,
|
||||
basePath: '/scaffolder/v1',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -242,11 +242,24 @@ export const githubAuthApiRef = createApiRef<
|
||||
*/
|
||||
export const oktaAuthApiRef = createApiRef<
|
||||
OAuthApi &
|
||||
OpenIdConnectApi &
|
||||
ProfileInfoApi &
|
||||
BackstageIdentityApi &
|
||||
SessionStateApi
|
||||
OpenIdConnectApi &
|
||||
ProfileInfoApi &
|
||||
BackstageIdentityApi &
|
||||
SessionStateApi
|
||||
>({
|
||||
id: 'core.auth.okta',
|
||||
description: 'Provides authentication towards Okta APIs',
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides authentication towards Gitlab APIs.
|
||||
*
|
||||
* See https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#limiting-scopes-of-a-personal-access-token
|
||||
* for a full list of supported scopes.
|
||||
*/
|
||||
export const gitlabAuthApiRef = createApiRef<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi
|
||||
>({
|
||||
id: 'core.auth.gitlab',
|
||||
description: 'Provides authentication towards Gitlab APIs',
|
||||
});
|
||||
|
||||
@@ -27,7 +27,10 @@ import {
|
||||
} from '../../../definitions/auth';
|
||||
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
|
||||
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
|
||||
import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager';
|
||||
import {
|
||||
AuthSessionStore,
|
||||
StaticAuthSessionManager,
|
||||
} from '../../../../lib/AuthSessionManager';
|
||||
import { Observable } from '../../../../types';
|
||||
|
||||
type CreateOptions = {
|
||||
@@ -91,7 +94,13 @@ class GithubAuth implements OAuthApi, SessionStateApi {
|
||||
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
|
||||
});
|
||||
|
||||
return new GithubAuth(sessionManager);
|
||||
const authSessionStore = new AuthSessionStore<GithubSession>({
|
||||
manager: sessionManager,
|
||||
storageKey: 'githubSession',
|
||||
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
|
||||
});
|
||||
|
||||
return new GithubAuth(authSessionStore);
|
||||
}
|
||||
|
||||
sessionState$(): Observable<SessionState> {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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 GitlabAuth from './GitlabAuth';
|
||||
|
||||
describe('GitlabAuth', () => {
|
||||
it('should get access token', async () => {
|
||||
const getSession = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ providerInfo: { accessToken: 'access-token' } });
|
||||
const gitlabAuth = new GitlabAuth({ getSession } as any);
|
||||
|
||||
expect(await gitlabAuth.getAccessToken()).toBe('access-token');
|
||||
expect(getSession).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should normalize scope', () => {
|
||||
const tests = [
|
||||
{
|
||||
arguments: ['read_user api write_repository'],
|
||||
expect: new Set(['read_user', 'api', 'write_repository']),
|
||||
},
|
||||
{
|
||||
arguments: ['read_repository sudo'],
|
||||
expect: new Set(['read_repository', 'sudo']),
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
expect(GitlabAuth.normalizeScope(...test.arguments)).toEqual(test.expect);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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 GitlabIcon from '@material-ui/icons/AcUnit';
|
||||
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
|
||||
import { GitlabSession } from './types';
|
||||
import {
|
||||
OAuthApi,
|
||||
SessionStateApi,
|
||||
SessionState,
|
||||
ProfileInfo,
|
||||
BackstageIdentity,
|
||||
AuthRequestOptions,
|
||||
} from '../../../definitions/auth';
|
||||
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
|
||||
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
|
||||
import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager';
|
||||
import { Observable } from '../../../../types';
|
||||
|
||||
type CreateOptions = {
|
||||
apiOrigin: string;
|
||||
basePath: string;
|
||||
|
||||
oauthRequestApi: OAuthRequestApi;
|
||||
|
||||
environment?: string;
|
||||
provider?: AuthProvider & { id: string };
|
||||
};
|
||||
|
||||
export type GitlabAuthResponse = {
|
||||
providerInfo: {
|
||||
accessToken: string;
|
||||
scope: string;
|
||||
expiresInSeconds: number;
|
||||
};
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentity;
|
||||
};
|
||||
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'gitlab',
|
||||
title: 'Gitlab',
|
||||
icon: GitlabIcon,
|
||||
};
|
||||
|
||||
class GitlabAuth implements OAuthApi, SessionStateApi {
|
||||
static create({
|
||||
apiOrigin,
|
||||
basePath,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
}: CreateOptions) {
|
||||
const connector = new DefaultAuthConnector({
|
||||
apiOrigin,
|
||||
basePath,
|
||||
environment,
|
||||
provider,
|
||||
oauthRequestApi,
|
||||
sessionTransform(res: GitlabAuthResponse): GitlabSession {
|
||||
return {
|
||||
...res,
|
||||
providerInfo: {
|
||||
accessToken: res.providerInfo.accessToken,
|
||||
scopes: GitlabAuth.normalizeScope(res.providerInfo.scope),
|
||||
expiresAt: new Date(
|
||||
Date.now() + res.providerInfo.expiresInSeconds * 1000,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = new StaticAuthSessionManager({
|
||||
connector,
|
||||
defaultScopes: new Set(['read_user']),
|
||||
sessionScopes: (session: GitlabSession) => session.providerInfo.scopes,
|
||||
});
|
||||
|
||||
return new GitlabAuth(sessionManager);
|
||||
}
|
||||
|
||||
sessionState$(): Observable<SessionState> {
|
||||
return this.sessionManager.sessionState$();
|
||||
}
|
||||
|
||||
constructor(private readonly sessionManager: SessionManager<GitlabSession>) {}
|
||||
|
||||
async getAccessToken(scope?: string, options?: AuthRequestOptions) {
|
||||
const session = await this.sessionManager.getSession({
|
||||
...options,
|
||||
scopes: GitlabAuth.normalizeScope(scope),
|
||||
});
|
||||
return session?.providerInfo.accessToken ?? '';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.sessionManager.removeSession();
|
||||
}
|
||||
|
||||
static normalizeScope(scope?: string): Set<string> {
|
||||
if (!scope) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const scopeList = Array.isArray(scope) ? scope : scope.split(' ');
|
||||
return new Set(scopeList);
|
||||
}
|
||||
}
|
||||
export default GitlabAuth;
|
||||
@@ -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 * from './types';
|
||||
export { default as GitlabAuth } from './GitlabAuth';
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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 GitlabSession = {
|
||||
providerInfo: {
|
||||
accessToken: string;
|
||||
scopes: Set<string>;
|
||||
expiresAt: Date;
|
||||
};
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentity;
|
||||
};
|
||||
@@ -16,4 +16,5 @@
|
||||
|
||||
export * from './google';
|
||||
export * from './github';
|
||||
export * from './gitlab';
|
||||
export * from './okta';
|
||||
|
||||
@@ -36,6 +36,8 @@ type Options<T> = {
|
||||
/**
|
||||
* AuthSessionStore decorates another SessionManager with a functionality
|
||||
* to store the session in local storage.
|
||||
*
|
||||
* Session is serialized to JSON with special support for following types: Set.
|
||||
*/
|
||||
export class AuthSessionStore<T> implements SessionManager<T> {
|
||||
private readonly manager: SessionManager<T>;
|
||||
@@ -90,7 +92,12 @@ export class AuthSessionStore<T> implements SessionManager<T> {
|
||||
try {
|
||||
const sessionJson = localStorage.getItem(this.storageKey);
|
||||
if (sessionJson) {
|
||||
const session = JSON.parse(sessionJson);
|
||||
const session = JSON.parse(sessionJson, (_key, value) => {
|
||||
if (value?.__type === 'Set') {
|
||||
return new Set(value.__value);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -105,7 +112,18 @@ export class AuthSessionStore<T> implements SessionManager<T> {
|
||||
if (session === undefined) {
|
||||
localStorage.removeItem(this.storageKey);
|
||||
} else {
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(session));
|
||||
localStorage.setItem(
|
||||
this.storageKey,
|
||||
JSON.stringify(session, (_key, value) => {
|
||||
if (value instanceof Set) {
|
||||
return {
|
||||
__type: 'Set',
|
||||
__value: Array.from(value),
|
||||
};
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,4 +16,5 @@
|
||||
|
||||
export { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager';
|
||||
export { StaticAuthSessionManager } from './StaticAuthSessionManager';
|
||||
export { AuthSessionStore } from './AuthSessionStore';
|
||||
export * from './types';
|
||||
|
||||
@@ -185,3 +185,38 @@ export const SubvalueTable = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DenseTable = () => {
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Column 1',
|
||||
field: 'col1',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
title: 'Column 2',
|
||||
field: 'col2',
|
||||
},
|
||||
{
|
||||
title: 'Numeric value',
|
||||
field: 'number',
|
||||
type: 'numeric',
|
||||
},
|
||||
{
|
||||
title: 'A Date',
|
||||
field: 'date',
|
||||
type: 'date',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<Table
|
||||
options={{ paging: false, padding: 'dense' }}
|
||||
data={testData10}
|
||||
columns={columns}
|
||||
title="Backstage Table"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,7 +35,6 @@ import ViewColumn from '@material-ui/icons/ViewColumn';
|
||||
import MTable, {
|
||||
Column,
|
||||
MaterialTableProps,
|
||||
MTableCell,
|
||||
MTableHeader,
|
||||
MTableToolbar,
|
||||
Options,
|
||||
@@ -96,14 +95,6 @@ const tableIcons = {
|
||||
)),
|
||||
};
|
||||
|
||||
const useCellStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
root: {
|
||||
color: theme.palette.grey[500],
|
||||
padding: theme.spacing(0, 2, 0, 2.5),
|
||||
height: '56px',
|
||||
},
|
||||
}));
|
||||
|
||||
const useHeaderStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
header: {
|
||||
padding: theme.spacing(1, 2, 1, 2.5),
|
||||
@@ -169,7 +160,6 @@ export function Table<T extends object = {}>({
|
||||
subtitle,
|
||||
...props
|
||||
}: TableProps<T>) {
|
||||
const cellClasses = useCellStyles();
|
||||
const headerClasses = useHeaderStyles();
|
||||
const toolbarClasses = useToolbarStyles();
|
||||
const theme = useTheme<BackstageTheme>();
|
||||
@@ -185,9 +175,6 @@ export function Table<T extends object = {}>({
|
||||
return (
|
||||
<MTable<T>
|
||||
components={{
|
||||
Cell: cellProps => (
|
||||
<MTableCell className={cellClasses.root} {...cellProps} />
|
||||
),
|
||||
Header: headerProps => (
|
||||
<MTableHeader classes={headerClasses} {...headerProps} />
|
||||
),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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 { ItemCard } from '.';
|
||||
import { Grid } from '@material-ui/core';
|
||||
|
||||
export default {
|
||||
title: 'Item Card',
|
||||
component: ItemCard,
|
||||
};
|
||||
|
||||
export const Default = () => (
|
||||
<Grid container spacing={4}>
|
||||
<Grid item xs={6} sm={4} md={2}>
|
||||
<ItemCard
|
||||
title="Item Card"
|
||||
description="This is the description of an Item Card"
|
||||
label="Button"
|
||||
type="Pretitle"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={4} md={2}>
|
||||
<ItemCard
|
||||
title="Item Card"
|
||||
description="This is the description of an Item Card"
|
||||
label="Button"
|
||||
type="Pretitle"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
export const Tags = () => (
|
||||
<Grid container spacing={4}>
|
||||
<Grid item xs={6} sm={4} md={2}>
|
||||
<ItemCard
|
||||
title="Item Card"
|
||||
description="This is a Item Card"
|
||||
tags={['one tag', 'two tag']}
|
||||
label="Button"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={4} md={2}>
|
||||
<ItemCard
|
||||
title="Item Card"
|
||||
description="This is a Item Card"
|
||||
tags={['one tag', 'two tag']}
|
||||
label="Button"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
+2
-2
@@ -37,7 +37,7 @@ const useStyles = makeStyles(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
type DocsCardProps = {
|
||||
type ItemCardProps = {
|
||||
description: string;
|
||||
tags?: string[];
|
||||
title: string;
|
||||
@@ -45,7 +45,7 @@ type DocsCardProps = {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
export const DocsCard: FC<DocsCardProps> = ({
|
||||
export const ItemCard: FC<ItemCardProps> = ({
|
||||
description,
|
||||
tags,
|
||||
title,
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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 { ItemCard } from './ItemCard';
|
||||
@@ -22,6 +22,7 @@ import { SidebarContext } from './config';
|
||||
import {
|
||||
googleAuthApiRef,
|
||||
githubAuthApiRef,
|
||||
gitlabAuthApiRef,
|
||||
identityApiRef,
|
||||
oktaAuthApiRef,
|
||||
useApi,
|
||||
@@ -57,6 +58,11 @@ export function SidebarUserSettings() {
|
||||
apiRef={githubAuthApiRef}
|
||||
icon={Star}
|
||||
/>
|
||||
<OAuthProviderSettings
|
||||
title="Gitlab"
|
||||
apiRef={gitlabAuthApiRef}
|
||||
icon={Star}
|
||||
/>
|
||||
<OIDCProviderSettings
|
||||
title="Okta"
|
||||
apiRef={oktaAuthApiRef}
|
||||
|
||||
@@ -26,3 +26,4 @@ export * from './Sidebar';
|
||||
export * from './SignInPage';
|
||||
export * from './TabbedCard';
|
||||
export * from './HeaderTabs';
|
||||
export * from './ItemCard';
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
googleAuthApiRef,
|
||||
GithubAuth,
|
||||
githubAuthApiRef,
|
||||
GitlabAuth,
|
||||
gitlabAuthApiRef,
|
||||
} from '@backstage/core';
|
||||
|
||||
// TODO(rugvip): We should likely figure out how to reuse all of these between apps
|
||||
@@ -75,3 +77,14 @@ export const githubAuthApiFactory = createApiFactory({
|
||||
oauthRequestApi,
|
||||
}),
|
||||
});
|
||||
|
||||
export const gitlabAuthApiFactory = createApiFactory({
|
||||
implements: gitlabAuthApiRef,
|
||||
deps: { oauthRequestApi: oauthRequestApiRef },
|
||||
factory: ({ oauthRequestApi }) =>
|
||||
GitlabAuth.create({
|
||||
apiOrigin: 'http://localhost:7000',
|
||||
basePath: '/auth/',
|
||||
oauthRequestApi,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
OAuthRequestManager,
|
||||
googleAuthApiRef,
|
||||
githubAuthApiRef,
|
||||
gitlabAuthApiRef,
|
||||
oktaAuthApiRef,
|
||||
AlertApiForwarder,
|
||||
ErrorApiForwarder,
|
||||
ErrorAlerter,
|
||||
GoogleAuth,
|
||||
GithubAuth,
|
||||
GitlabAuth,
|
||||
OktaAuth,
|
||||
identityApiRef,
|
||||
} from '@backstage/core';
|
||||
@@ -52,6 +54,15 @@ builder.add(
|
||||
}),
|
||||
);
|
||||
|
||||
builder.add(
|
||||
gitlabAuthApiRef,
|
||||
GitlabAuth.create({
|
||||
apiOrigin: 'http://localhost:7000',
|
||||
basePath: '/auth/',
|
||||
oauthRequestApi,
|
||||
}),
|
||||
);
|
||||
|
||||
builder.add(
|
||||
oktaAuthApiRef,
|
||||
OktaAuth.create({
|
||||
|
||||
@@ -118,9 +118,12 @@ export function createThemeOverrides(theme: BackstageTheme): Overrides {
|
||||
verticalAlign: 'middle',
|
||||
lineHeight: '1',
|
||||
margin: 0,
|
||||
padding: '8px',
|
||||
padding: theme.spacing(3, 2, 3, 2.5),
|
||||
borderBottom: 0,
|
||||
},
|
||||
sizeSmall: {
|
||||
padding: theme.spacing(1, 2, 1, 2.5),
|
||||
},
|
||||
head: {
|
||||
wordBreak: 'break-word',
|
||||
overflow: 'hidden',
|
||||
|
||||
@@ -7,17 +7,46 @@ This is the backend part of the auth plugin.
|
||||
It responds to auth requests from the frontend, and fulfills them by delegating
|
||||
to the appropriate provider in the backend.
|
||||
|
||||
## Requirements
|
||||
|
||||
Needs AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET set in the environment for the backend to startup
|
||||
|
||||
## Local development
|
||||
|
||||
export AUTH_GOOGLE_CLIENT_ID=<INSERT_CLIENT_ID_HERE>
|
||||
read -r AUTH_GOOGLE_CLIENT_SECRET
|
||||
<COPY_PASTE_CLIENT_SECRET_HERE>
|
||||
export AUTH_GOOGLE_CLIENT_SECRET
|
||||
run `yarn start` in packages/backend folder
|
||||
Choose your OAuth Providers, replace `x` with actual value and then start backend:
|
||||
Example for Google Oauth Provider at root directory:
|
||||
|
||||
```bash
|
||||
export AUTH_GOOGLE_CLIENT_ID=x
|
||||
export AUTH_GOOGLE_CLIENT_SECRET=x
|
||||
yarn --cwd packages/backend start
|
||||
```
|
||||
|
||||
### Google
|
||||
|
||||
```bash
|
||||
export AUTH_GOOGLE_CLIENT_ID=x
|
||||
export AUTH_GOOGLE_CLIENT_SECRET=x
|
||||
```
|
||||
|
||||
### Github
|
||||
|
||||
```bash
|
||||
export AUTH_GITHUB_CLIENT_ID=x
|
||||
export AUTH_GITHUB_CLIENT_SECRET=x
|
||||
```
|
||||
|
||||
### Gitlab
|
||||
|
||||
```bash
|
||||
export GITLAB_BASE_URL=x # default is https://gitlab.com
|
||||
export AUTH_GITLAB_CLIENT_ID=x
|
||||
export AUTH_GITLAB_CLIENT_SECRET=x
|
||||
```
|
||||
|
||||
### Okta
|
||||
|
||||
```bash
|
||||
export AUTH_OKTA_AUDIENCE=x
|
||||
export AUTH_OKTA_CLIENT_ID=x
|
||||
export AUTH_OKTA_CLIENT_SECRET=x
|
||||
```
|
||||
|
||||
### SAML
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"morgan": "^1.10.0",
|
||||
"passport": "^0.4.1",
|
||||
"passport-github2": "^0.1.12",
|
||||
"passport-gitlab2": "^5.0.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-oauth2": "^1.5.0",
|
||||
"passport-okta-oauth": "^0.0.1",
|
||||
|
||||
@@ -33,6 +33,7 @@ export type Options = {
|
||||
providerId: string;
|
||||
secure: boolean;
|
||||
disableRefresh?: boolean;
|
||||
persistScopes?: boolean;
|
||||
baseUrl: string;
|
||||
appOrigin: string;
|
||||
tokenIssuer: TokenIssuer;
|
||||
@@ -105,6 +106,10 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
throw new InputError('missing scope parameter');
|
||||
}
|
||||
|
||||
if (this.options.persistScopes) {
|
||||
this.setScopesCookie(res, scope);
|
||||
}
|
||||
|
||||
const nonce = crypto.randomBytes(16).toString('base64');
|
||||
// set a nonce cookie before redirecting to oauth provider
|
||||
this.setNonceCookie(res, nonce);
|
||||
@@ -137,6 +142,14 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
req,
|
||||
);
|
||||
|
||||
if (this.options.persistScopes) {
|
||||
const grantedScopes = this.getScopesFromCookie(
|
||||
req,
|
||||
this.options.providerId,
|
||||
);
|
||||
response.providerInfo.scope = grantedScopes;
|
||||
}
|
||||
|
||||
if (!this.options.disableRefresh) {
|
||||
// throw error if missing refresh token
|
||||
if (!refreshToken) {
|
||||
@@ -241,6 +254,21 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
});
|
||||
};
|
||||
|
||||
private setScopesCookie = (res: express.Response, scope: string) => {
|
||||
res.cookie(`${this.options.providerId}-scope`, scope, {
|
||||
maxAge: TEN_MINUTES_MS,
|
||||
secure: this.options.secure,
|
||||
sameSite: 'none',
|
||||
domain: this.domain,
|
||||
path: `${this.basePath}/${this.options.providerId}/handler`,
|
||||
httpOnly: true,
|
||||
});
|
||||
};
|
||||
|
||||
private getScopesFromCookie = (req: express.Request, providerId: string) => {
|
||||
return req.cookies[`${providerId}-scope`];
|
||||
};
|
||||
|
||||
private setRefreshTokenCookie = (
|
||||
res: express.Response,
|
||||
refreshToken: string,
|
||||
|
||||
@@ -137,7 +137,7 @@ export const executeRefreshTokenStrategy = async (
|
||||
params: any,
|
||||
) => {
|
||||
if (err) {
|
||||
reject(new Error(`Failed to refresh access token ${err}`));
|
||||
reject(new Error(`Failed to refresh access token ${err.toString()}`));
|
||||
}
|
||||
if (!accessToken) {
|
||||
reject(
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import Router from 'express-promise-router';
|
||||
import { createGithubProvider } from './github';
|
||||
import { createGoogleProvider } from './google';
|
||||
import { createGitlabProvider } from './gitlab';
|
||||
import { createSamlProvider } from './saml';
|
||||
import { createOktaProvider } from './okta';
|
||||
import { AuthProviderFactory, AuthProviderConfig } from './types';
|
||||
@@ -26,6 +27,7 @@ import { TokenIssuer } from '../identity';
|
||||
const factories: { [providerId: string]: AuthProviderFactory } = {
|
||||
google: createGoogleProvider,
|
||||
github: createGithubProvider,
|
||||
gitlab: createGitlabProvider,
|
||||
saml: createSamlProvider,
|
||||
okta: createOktaProvider,
|
||||
};
|
||||
|
||||
@@ -115,6 +115,7 @@ export function createGithubProvider(
|
||||
|
||||
envProviders[env] = new OAuthProvider(new GithubAuthProvider(opts), {
|
||||
disableRefresh: true,
|
||||
persistScopes: true,
|
||||
providerId: 'github',
|
||||
secure,
|
||||
baseUrl,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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 { createGitlabProvider } from './provider';
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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 { GitlabAuthProvider } from './provider';
|
||||
|
||||
describe('GitlabAuthProvider', () => {
|
||||
it('should transform to type OAuthResponse', () => {
|
||||
const tests = [
|
||||
{
|
||||
arguments: {
|
||||
accessToken: '19xasczxcm9n7gacn9jdgm19me',
|
||||
rawProfile: {
|
||||
id: 'uid-123',
|
||||
username: 'jimmymarkum',
|
||||
provider: 'gitlab',
|
||||
displayName: 'Jimmy Markum',
|
||||
emails: [
|
||||
{
|
||||
value: 'jimmymarkum@gmail.com',
|
||||
},
|
||||
],
|
||||
avatarUrl:
|
||||
'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg',
|
||||
},
|
||||
params: {
|
||||
scope: 'user_read write_repository',
|
||||
expires_in: 100,
|
||||
},
|
||||
},
|
||||
expect: {
|
||||
providerInfo: {
|
||||
accessToken: '19xasczxcm9n7gacn9jdgm19me',
|
||||
expiresInSeconds: 100,
|
||||
scope: 'user_read write_repository',
|
||||
},
|
||||
profile: {
|
||||
email: 'jimmymarkum@gmail.com',
|
||||
displayName: 'Jimmy Markum',
|
||||
picture:
|
||||
'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
arguments: {
|
||||
accessToken:
|
||||
'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe',
|
||||
rawProfile: {
|
||||
id: 'ipd12039',
|
||||
username: 'daveboyle',
|
||||
provider: 'gitlab',
|
||||
displayName: 'Dave Boyle',
|
||||
emails: [
|
||||
{
|
||||
value: 'daveboyle@gitlab.org',
|
||||
},
|
||||
],
|
||||
},
|
||||
params: {
|
||||
scope: 'read_repository',
|
||||
},
|
||||
},
|
||||
expect: {
|
||||
providerInfo: {
|
||||
accessToken:
|
||||
'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe',
|
||||
scope: 'read_repository',
|
||||
},
|
||||
profile: {
|
||||
displayName: 'Dave Boyle',
|
||||
email: 'daveboyle@gitlab.org',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
expect(
|
||||
GitlabAuthProvider.transformOAuthResponse(
|
||||
test.arguments.accessToken,
|
||||
test.arguments.rawProfile,
|
||||
test.arguments.params,
|
||||
),
|
||||
).toEqual(test.expect);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* 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 express from 'express';
|
||||
import { Strategy as GitlabStrategy } from 'passport-gitlab2';
|
||||
import {
|
||||
executeFrameHandlerStrategy,
|
||||
executeRedirectStrategy,
|
||||
makeProfileInfo,
|
||||
} from '../../lib/PassportStrategyHelper';
|
||||
import {
|
||||
OAuthProviderHandlers,
|
||||
AuthProviderConfig,
|
||||
RedirectInfo,
|
||||
EnvironmentProviderConfig,
|
||||
OAuthProviderOptions,
|
||||
OAuthProviderConfig,
|
||||
OAuthResponse,
|
||||
PassportDoneCallback,
|
||||
} from '../types';
|
||||
import { OAuthProvider } from '../../lib/OAuthProvider';
|
||||
import {
|
||||
EnvironmentHandlers,
|
||||
EnvironmentHandler,
|
||||
} from '../../lib/EnvironmentHandler';
|
||||
import { Logger } from 'winston';
|
||||
import { TokenIssuer } from '../../identity';
|
||||
import passport from 'passport';
|
||||
|
||||
export class GitlabAuthProvider implements OAuthProviderHandlers {
|
||||
private readonly _strategy: GitlabStrategy;
|
||||
|
||||
static transformPassportProfile(rawProfile: any): passport.Profile {
|
||||
const profile: passport.Profile = {
|
||||
id: rawProfile.id,
|
||||
username: rawProfile.username,
|
||||
provider: rawProfile.provider,
|
||||
displayName: rawProfile.displayName,
|
||||
};
|
||||
|
||||
if (rawProfile.emails && rawProfile.emails.length > 0) {
|
||||
profile.emails = rawProfile.emails;
|
||||
}
|
||||
if (rawProfile.avatarUrl) {
|
||||
profile.photos = [{ value: rawProfile.avatarUrl }];
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
static transformOAuthResponse(
|
||||
accessToken: string,
|
||||
rawProfile: any,
|
||||
params: any = {},
|
||||
): OAuthResponse {
|
||||
const passportProfile = GitlabAuthProvider.transformPassportProfile(
|
||||
rawProfile,
|
||||
);
|
||||
|
||||
const profile = makeProfileInfo(passportProfile, params.id_token);
|
||||
const providerInfo = {
|
||||
accessToken,
|
||||
scope: params.scope,
|
||||
expiresInSeconds: params.expires_in,
|
||||
idToken: params.id_token,
|
||||
};
|
||||
|
||||
if (params.expires_in) {
|
||||
providerInfo.expiresInSeconds = params.expires_in;
|
||||
}
|
||||
if (params.id_token) {
|
||||
providerInfo.idToken = params.id_token;
|
||||
}
|
||||
return {
|
||||
providerInfo,
|
||||
profile,
|
||||
};
|
||||
}
|
||||
|
||||
constructor(options: OAuthProviderOptions) {
|
||||
this._strategy = new GitlabStrategy(
|
||||
{ ...options },
|
||||
(
|
||||
accessToken: any,
|
||||
_: any,
|
||||
params: any,
|
||||
rawProfile: any,
|
||||
done: PassportDoneCallback<OAuthResponse>,
|
||||
) => {
|
||||
const oauthResponse = GitlabAuthProvider.transformOAuthResponse(
|
||||
accessToken,
|
||||
rawProfile,
|
||||
params,
|
||||
);
|
||||
done(undefined, oauthResponse);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async start(
|
||||
req: express.Request,
|
||||
options: Record<string, string>,
|
||||
): Promise<RedirectInfo> {
|
||||
return await executeRedirectStrategy(req, this._strategy, options);
|
||||
}
|
||||
|
||||
async handler(req: express.Request): Promise<{ response: OAuthResponse }> {
|
||||
return await executeFrameHandlerStrategy<OAuthResponse>(
|
||||
req,
|
||||
this._strategy,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createGitlabProvider(
|
||||
{ baseUrl }: AuthProviderConfig,
|
||||
providerConfig: EnvironmentProviderConfig,
|
||||
logger: Logger,
|
||||
tokenIssuer: TokenIssuer,
|
||||
) {
|
||||
const envProviders: EnvironmentHandlers = {};
|
||||
|
||||
for (const [env, envConfig] of Object.entries(providerConfig)) {
|
||||
const {
|
||||
secure,
|
||||
appOrigin,
|
||||
clientId,
|
||||
clientSecret,
|
||||
audience,
|
||||
} = (envConfig as unknown) as OAuthProviderConfig;
|
||||
const callbackURLParam = `?env=${env}`;
|
||||
|
||||
const opts = {
|
||||
clientID: clientId,
|
||||
clientSecret: clientSecret,
|
||||
callbackURL: `${baseUrl}/gitlab/handler/frame${callbackURLParam}`,
|
||||
baseURL: audience,
|
||||
};
|
||||
|
||||
if (!opts.clientID || !opts.clientSecret) {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
throw new Error(
|
||||
'Failed to initialize Gitlab auth provider, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars',
|
||||
);
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
'Gitlab auth provider disabled, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars to enable',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
envProviders[env] = new OAuthProvider(new GitlabAuthProvider(opts), {
|
||||
disableRefresh: true,
|
||||
providerId: 'gitlab',
|
||||
secure,
|
||||
baseUrl,
|
||||
appOrigin,
|
||||
tokenIssuer,
|
||||
});
|
||||
}
|
||||
return new EnvironmentHandler(envProviders);
|
||||
}
|
||||
+8
-6
@@ -13,11 +13,13 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { TemplateEntityV1alpha1 } from "@backstage/catalog-model";
|
||||
import { RequiredTemplateValues } from "../templater";
|
||||
import { JsonValue } from "@backstage/config";
|
||||
|
||||
export type Storer = {
|
||||
createRemote(opts: { entity: TemplateEntityV1alpha1, values: RequiredTemplateValues & Record<string, JsonValue>}): Promise<string>;
|
||||
pushToRemote(directory: string, remote: string): Promise<void>;
|
||||
declare module 'passport-gitlab2' {
|
||||
import { Request } from 'express';
|
||||
import { StrategyCreated } from 'passport';
|
||||
|
||||
export class Strategy {
|
||||
constructor(options: any, verify: any);
|
||||
authenticate(this: StrategyCreated<this>, req: Request, options?: any): any;
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,15 @@ export async function createRouter(
|
||||
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
|
||||
},
|
||||
},
|
||||
gitlab: {
|
||||
development: {
|
||||
appOrigin: 'http://localhost:3000',
|
||||
secure: false,
|
||||
clientId: process.env.AUTH_GITLAB_CLIENT_ID!,
|
||||
clientSecret: process.env.AUTH_GITLAB_CLIENT_SECRET!,
|
||||
audience: process.env.GITLAB_BASE_URL! || 'https://gitlab.com',
|
||||
},
|
||||
},
|
||||
saml: {
|
||||
development: {
|
||||
entryPoint: 'http://localhost:7001/',
|
||||
@@ -89,8 +98,8 @@ export async function createRouter(
|
||||
clientId: process.env.AUTH_OKTA_CLIENT_ID!,
|
||||
clientSecret: process.env.AUTH_OKTA_CLIENT_SECRET!,
|
||||
audience: process.env.AUTH_OKTA_AUDIENCE,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -70,9 +70,9 @@ export class CatalogClient implements CatalogApi {
|
||||
return await this.getOptional(`/locations/${id}`);
|
||||
}
|
||||
|
||||
async getEntities(
|
||||
async getEntities<EntityType extends Entity = Entity>(
|
||||
filter?: Record<string, string | string[]>,
|
||||
): Promise<Entity[]> {
|
||||
): Promise<EntityType[]> {
|
||||
let path = `/entities`;
|
||||
if (filter) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-router-dom": "6.0.0-alpha.5",
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -79,6 +79,14 @@ export interface ListClusterRequest {
|
||||
gitHubToken: string;
|
||||
}
|
||||
|
||||
export interface GithubUserInfoRequest {
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface GithubUserInfoResponse {
|
||||
login: string;
|
||||
}
|
||||
|
||||
export class FetchError extends Error {
|
||||
get name(): string {
|
||||
return this.constructor.name;
|
||||
@@ -100,6 +108,7 @@ export type GitOpsApi = {
|
||||
cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise<any>;
|
||||
applyProfiles(req: ApplyProfileRequest): Promise<any>;
|
||||
listClusters(req: ListClusterRequest): Promise<ListClusterStatusesResponse>;
|
||||
fetchUserInfo(req: GithubUserInfoRequest): Promise<GithubUserInfoResponse>;
|
||||
};
|
||||
|
||||
export const gitOpsApiRef = createApiRef<GitOpsApi>({
|
||||
@@ -116,6 +125,19 @@ export class GitOpsRestApi implements GitOpsApi {
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
async fetchUserInfo(
|
||||
req: GithubUserInfoRequest,
|
||||
): Promise<GithubUserInfoResponse> {
|
||||
const resp = await fetch(`https://api.github.com/user`, {
|
||||
method: 'get',
|
||||
headers: new Headers({
|
||||
Authorization: `token ${req.accessToken}`,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw await FetchError.forResponse(resp);
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
async fetchLog(req: PollLogRequest): Promise<StatusResponse> {
|
||||
return await this.fetch<StatusResponse>(`/api/cluster/run-status`, {
|
||||
method: 'post',
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import React, { FC, useState } from 'react';
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
@@ -25,32 +25,30 @@ import {
|
||||
Progress,
|
||||
HeaderLabel,
|
||||
useApi,
|
||||
githubAuthApiRef,
|
||||
} from '@backstage/core';
|
||||
|
||||
import ClusterTable from '../ClusterTable/ClusterTable';
|
||||
import { Button } from '@material-ui/core';
|
||||
import { useAsync, useLocalStorage } from 'react-use';
|
||||
import { useAsync } from 'react-use';
|
||||
import { gitOpsApiRef, ListClusterStatusesResponse } from '../../api';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
|
||||
const ClusterList: FC<{}> = () => {
|
||||
const [loginInfo] = useLocalStorage<{
|
||||
token: string;
|
||||
username: string;
|
||||
name: string;
|
||||
}>('githubLoginDetails', {
|
||||
token: '',
|
||||
username: '',
|
||||
name: 'Guest',
|
||||
});
|
||||
|
||||
const api = useApi(gitOpsApiRef);
|
||||
const githubAuth = useApi(githubAuthApiRef);
|
||||
const [githubUsername, setGithubUsername] = useState(String);
|
||||
|
||||
const { loading, error, value } = useAsync<ListClusterStatusesResponse>(
|
||||
() => {
|
||||
async () => {
|
||||
const accessToken = await githubAuth.getAccessToken(['repo', 'user']);
|
||||
if (!githubUsername) {
|
||||
const userInfo = await api.fetchUserInfo({ accessToken });
|
||||
setGithubUsername(userInfo.login);
|
||||
}
|
||||
return api.listClusters({
|
||||
gitHubToken: loginInfo.token,
|
||||
gitHubUser: loginInfo.username,
|
||||
gitHubToken: accessToken,
|
||||
gitHubUser: githubUsername,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -73,9 +71,6 @@ const ClusterList: FC<{}> = () => {
|
||||
Please make sure that you start GitOps-API backend on localhost port
|
||||
3008 before using this plugin.
|
||||
</Alert>
|
||||
<Alert severity="info">
|
||||
If you're Guest, please login via GitHub first.
|
||||
</Alert>
|
||||
</div>
|
||||
</Content>
|
||||
);
|
||||
@@ -100,7 +95,7 @@ const ClusterList: FC<{}> = () => {
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header title="GitOps-managed Clusters">
|
||||
<HeaderLabel label="Welcome" value={loginInfo.name} />
|
||||
<HeaderLabel label="Welcome" value={githubUsername} />
|
||||
</Header>
|
||||
{content}
|
||||
</Page>
|
||||
|
||||
@@ -24,21 +24,16 @@ import {
|
||||
Progress,
|
||||
HeaderLabel,
|
||||
useApi,
|
||||
githubAuthApiRef,
|
||||
} from '@backstage/core';
|
||||
|
||||
import { Link } from '@material-ui/core';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { gitOpsApiRef, Status } from '../../api';
|
||||
import { transformRunStatus } from '../ProfileCatalog';
|
||||
|
||||
const ClusterPage: FC<{}> = () => {
|
||||
const params = useParams() as { owner: string; repo: string };
|
||||
const [loginInfo] = useLocalStorage<{
|
||||
token: string;
|
||||
username: string;
|
||||
name: string;
|
||||
}>('githubLoginDetails');
|
||||
|
||||
const [pollingLog, setPollingLog] = useState(true);
|
||||
const [runStatus, setRunStatus] = useState<Status[]>([]);
|
||||
@@ -46,6 +41,9 @@ const ClusterPage: FC<{}> = () => {
|
||||
const [showProgress, setShowProgress] = useState(true);
|
||||
|
||||
const api = useApi(gitOpsApiRef);
|
||||
const githubAuth = useApi(githubAuthApiRef);
|
||||
const [githubAccessToken, setGithubAccessToken] = useState(String);
|
||||
const [githubUsername, setGithubUsername] = useState(String);
|
||||
|
||||
const columns = [
|
||||
{ field: 'status', title: 'Status' },
|
||||
@@ -53,31 +51,43 @@ const ClusterPage: FC<{}> = () => {
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (pollingLog) {
|
||||
const interval = setInterval(async () => {
|
||||
const resp = await api.fetchLog({
|
||||
gitHubToken: loginInfo.token,
|
||||
gitHubUser: loginInfo.username,
|
||||
targetOrg: params.owner,
|
||||
targetRepo: params.repo,
|
||||
});
|
||||
const fetchGithubUserInfo = async () => {
|
||||
const accessToken = await githubAuth.getAccessToken(['repo', 'user']);
|
||||
const userInfo = await api.fetchUserInfo({ accessToken });
|
||||
setGithubAccessToken(accessToken);
|
||||
setGithubUsername(userInfo.login);
|
||||
};
|
||||
|
||||
setRunStatus(resp.result);
|
||||
setRunLink(resp.link);
|
||||
if (resp.status === 'completed') {
|
||||
setPollingLog(false);
|
||||
setShowProgress(false);
|
||||
}
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
if (!githubAccessToken || !githubUsername) {
|
||||
fetchGithubUserInfo();
|
||||
} else {
|
||||
if (pollingLog) {
|
||||
const interval = setInterval(async () => {
|
||||
const resp = await api.fetchLog({
|
||||
gitHubToken: githubAccessToken,
|
||||
gitHubUser: githubUsername,
|
||||
targetOrg: params.owner,
|
||||
targetRepo: params.repo,
|
||||
});
|
||||
|
||||
setRunStatus(resp.result);
|
||||
setRunLink(resp.link);
|
||||
if (resp.status === 'completed') {
|
||||
setPollingLog(false);
|
||||
setShowProgress(false);
|
||||
}
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {};
|
||||
}, [pollingLog, api, loginInfo, params]);
|
||||
}, [pollingLog, api, params, githubAuth, githubAccessToken, githubUsername]);
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header title={`Cluster ${params.owner}/${params.repo}`}>
|
||||
<HeaderLabel label="Welcome" value={loginInfo.name} />
|
||||
<HeaderLabel label="Welcome" value={githubUsername} />
|
||||
</Header>
|
||||
<Content>
|
||||
<Progress hidden={!showProgress} />
|
||||
|
||||
@@ -20,13 +20,28 @@ import mockFetch from 'jest-fetch-mock';
|
||||
import ProfileCatalog from './ProfileCatalog';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import {
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
githubAuthApiRef,
|
||||
GithubAuth,
|
||||
OAuthRequestManager,
|
||||
} from '@backstage/core';
|
||||
import { gitOpsApiRef, GitOpsRestApi } from '../../api';
|
||||
|
||||
describe('ProfileCatalog', () => {
|
||||
it('should render', () => {
|
||||
const oauthRequestApi = new OAuthRequestManager();
|
||||
const apis = ApiRegistry.from([
|
||||
[gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')],
|
||||
[
|
||||
githubAuthApiRef,
|
||||
GithubAuth.create({
|
||||
apiOrigin: 'http://localhost:7000',
|
||||
basePath: '/auth/',
|
||||
oauthRequestApi,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
mockFetch.mockResponse(() => new Promise(() => {}));
|
||||
const rendered = render(
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
StatusPending,
|
||||
StatusAborted,
|
||||
useApi,
|
||||
githubAuthApiRef,
|
||||
} from '@backstage/core';
|
||||
import { TextField, List, ListItem, Link } from '@material-ui/core';
|
||||
|
||||
@@ -111,17 +112,12 @@ const ProfileCatalog: FC<{}> = () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const [loginInfo] = useLocalStorage('githubLoginDetails', {
|
||||
name: 'Guest',
|
||||
username: '',
|
||||
token: '',
|
||||
});
|
||||
const [templateRepo] = useLocalStorage<string>('gitops-template-repo');
|
||||
const [gitopsProfiles] = useLocalStorage<string[]>('gitops-profiles');
|
||||
|
||||
const [showProgress, setShowProgress] = useState(false);
|
||||
const [pollingLog, setPollingLog] = useState(false);
|
||||
const [gitHubOrg, setGitHubOrg] = useState(loginInfo.username);
|
||||
const [gitHubOrg, setGitHubOrg] = useState(String);
|
||||
const [gitHubRepo, setGitHubRepo] = useState('new-cluster');
|
||||
const [awsAccessKeyId, setAwsAccessKeyId] = useState(String);
|
||||
const [awsSecretAccessKey, setAwsSecretAccessKey] = useState(String);
|
||||
@@ -129,28 +125,52 @@ const ProfileCatalog: FC<{}> = () => {
|
||||
const [runLink, setRunLink] = useState<string>('');
|
||||
|
||||
const api = useApi(gitOpsApiRef);
|
||||
const githubAuth = useApi(githubAuthApiRef);
|
||||
const [githubAccessToken, setGithubAccessToken] = useState(String);
|
||||
const [githubUsername, setGithubUsername] = useState(String);
|
||||
|
||||
useEffect(() => {
|
||||
if (pollingLog) {
|
||||
const interval = setInterval(async () => {
|
||||
const resp = await api.fetchLog({
|
||||
gitHubToken: loginInfo.token,
|
||||
gitHubUser: loginInfo.username,
|
||||
targetOrg: gitHubOrg,
|
||||
targetRepo: gitHubRepo,
|
||||
});
|
||||
const fetchGithubUserInfo = async () => {
|
||||
const accessToken = await githubAuth.getAccessToken(['repo', 'user']);
|
||||
const userInfo = await api.fetchUserInfo({ accessToken });
|
||||
setGithubAccessToken(accessToken);
|
||||
setGithubUsername(userInfo.login);
|
||||
setGitHubOrg(userInfo.login);
|
||||
};
|
||||
|
||||
setRunStatus(resp.result);
|
||||
setRunLink(resp.link);
|
||||
if (resp.status === 'completed') {
|
||||
setPollingLog(false);
|
||||
setShowProgress(false);
|
||||
}
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
if (!githubAccessToken || !githubUsername) {
|
||||
fetchGithubUserInfo();
|
||||
} else {
|
||||
if (pollingLog) {
|
||||
const interval = setInterval(async () => {
|
||||
const resp = await api.fetchLog({
|
||||
gitHubToken: githubAccessToken,
|
||||
gitHubUser: githubUsername,
|
||||
targetOrg: gitHubOrg,
|
||||
targetRepo: gitHubRepo,
|
||||
});
|
||||
|
||||
setRunStatus(resp.result);
|
||||
setRunLink(resp.link);
|
||||
if (resp.status === 'completed') {
|
||||
setPollingLog(false);
|
||||
setShowProgress(false);
|
||||
}
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {};
|
||||
}, [pollingLog, api, gitHubOrg, gitHubRepo, loginInfo]);
|
||||
}, [
|
||||
pollingLog,
|
||||
api,
|
||||
gitHubOrg,
|
||||
gitHubRepo,
|
||||
githubAuth,
|
||||
githubAccessToken,
|
||||
githubUsername,
|
||||
]);
|
||||
|
||||
const showFailureMessage = (msg: string) => {
|
||||
setRunStatus(
|
||||
@@ -182,8 +202,8 @@ const ProfileCatalog: FC<{}> = () => {
|
||||
|
||||
const cloneResponse = await api.cloneClusterFromTemplate({
|
||||
templateRepository: templateRepo,
|
||||
gitHubToken: loginInfo.token,
|
||||
gitHubUser: loginInfo.username,
|
||||
gitHubToken: githubAccessToken,
|
||||
gitHubUser: githubUsername,
|
||||
targetOrg: gitHubOrg,
|
||||
targetRepo: gitHubRepo,
|
||||
secrets: {
|
||||
@@ -200,8 +220,8 @@ const ProfileCatalog: FC<{}> = () => {
|
||||
}
|
||||
|
||||
const applyProfileResp = await api.applyProfiles({
|
||||
gitHubToken: loginInfo.token,
|
||||
gitHubUser: loginInfo.username,
|
||||
gitHubToken: githubAccessToken,
|
||||
gitHubUser: githubUsername,
|
||||
targetOrg: gitHubOrg,
|
||||
targetRepo: gitHubRepo,
|
||||
profiles: gitopsProfiles,
|
||||
@@ -215,8 +235,8 @@ const ProfileCatalog: FC<{}> = () => {
|
||||
}
|
||||
|
||||
const clusterStateResp = await api.changeClusterState({
|
||||
gitHubToken: loginInfo.token,
|
||||
gitHubUser: loginInfo.username,
|
||||
gitHubToken: githubAccessToken,
|
||||
gitHubUser: githubUsername,
|
||||
targetOrg: gitHubOrg,
|
||||
targetRepo: gitHubRepo,
|
||||
clusterState: 'present',
|
||||
@@ -244,7 +264,7 @@ const ProfileCatalog: FC<{}> = () => {
|
||||
title="Create GitOps-managed Cluster"
|
||||
subtitle="Kubernetes cluster with ready-to-use profiles"
|
||||
>
|
||||
<HeaderLabel label="Welcome" value={loginInfo.name} />
|
||||
<HeaderLabel label="Welcome" value={githubUsername} />
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title="Create Cluster">
|
||||
|
||||
@@ -18,12 +18,17 @@ import { createPlugin } from '@backstage/core';
|
||||
import ProfileCatalog from './components/ProfileCatalog';
|
||||
import ClusterPage from './components/ClusterPage';
|
||||
import ClusterList from './components/ClusterList';
|
||||
import {
|
||||
gitOpsClusterListRoute,
|
||||
gitOpsClusterDetailsRoute,
|
||||
gitOpsClusterCreateRoute,
|
||||
} from './routes';
|
||||
|
||||
export const plugin = createPlugin({
|
||||
id: 'gitops-profiles',
|
||||
register({ router }) {
|
||||
router.registerRoute('/gitops-clusters', ClusterList);
|
||||
router.registerRoute('/gitops-cluster/:owner/:repo', ClusterPage);
|
||||
router.registerRoute('/gitops-cluster-create', ProfileCatalog);
|
||||
router.addRoute(gitOpsClusterListRoute, ClusterList);
|
||||
router.addRoute(gitOpsClusterDetailsRoute, ClusterPage);
|
||||
router.addRoute(gitOpsClusterCreateRoute, ProfileCatalog);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 { createRouteRef } from '@backstage/core';
|
||||
|
||||
const NoIcon = () => null;
|
||||
|
||||
export const gitOpsClusterListRoute = createRouteRef({
|
||||
icon: NoIcon,
|
||||
path: '/gitops-clusters',
|
||||
title: 'GitOps Clusters',
|
||||
});
|
||||
|
||||
export const gitOpsClusterDetailsRoute = createRouteRef({
|
||||
icon: NoIcon,
|
||||
path: '/gitops-cluster/:owner/:repo',
|
||||
title: 'GitOps Cluster details',
|
||||
});
|
||||
|
||||
export const gitOpsClusterCreateRoute = createRouteRef({
|
||||
icon: NoIcon,
|
||||
path: '/gitops-cluster-create',
|
||||
title: 'GitOps Cluster create',
|
||||
});
|
||||
@@ -43,6 +43,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.12",
|
||||
"@octokit/types": "^5.0.1",
|
||||
"@types/fs-extra": "^9.0.1",
|
||||
"@types/git-url-parse": "^9.0.0",
|
||||
"@types/nodegit": "0.26.5",
|
||||
|
||||
@@ -13,6 +13,5 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './stages/templater';
|
||||
export * from './stages/prepare';
|
||||
export * from './stages';
|
||||
export * from './jobs';
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 { makeLogStream } from './logger';
|
||||
describe('Logger', () => {
|
||||
const mockMeta = { test: 'blob' };
|
||||
|
||||
it('should return empty log lines by default', async () => {
|
||||
const { log } = makeLogStream(mockMeta);
|
||||
|
||||
expect(log).toEqual([]);
|
||||
});
|
||||
it('should add lines to the log when using the logger that is returned', async () => {
|
||||
const { logger, log } = makeLogStream(mockMeta);
|
||||
|
||||
logger.info('TEST LINE');
|
||||
logger.warn('WARN LINE');
|
||||
|
||||
const [first, second] = log;
|
||||
expect(log.length).toBe(2);
|
||||
|
||||
expect(first).toContain('info');
|
||||
expect(first).toContain('TEST LINE');
|
||||
|
||||
expect(second).toContain('warn');
|
||||
expect(second).toContain('WARN LINE');
|
||||
});
|
||||
|
||||
it('should add lines from writing to the stream that is returned', async () => {
|
||||
const { stream, log } = makeLogStream(mockMeta);
|
||||
const textLine = 'SOMETHING';
|
||||
stream.write(textLine);
|
||||
|
||||
expect(log).toContain(textLine);
|
||||
});
|
||||
});
|
||||
@@ -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 * from './prepare';
|
||||
export * from './publish';
|
||||
export * from './templater';
|
||||
@@ -19,7 +19,6 @@ const mocks = {
|
||||
CheckoutOptions: jest.fn(() => {}),
|
||||
};
|
||||
jest.doMock('nodegit', () => mocks);
|
||||
// require('nodegit');
|
||||
|
||||
import { GithubPreparer } from './github';
|
||||
import {
|
||||
|
||||
+28
@@ -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.
|
||||
*/
|
||||
|
||||
export const mockGithubClient = {
|
||||
repos: {
|
||||
createInOrg: jest.fn(),
|
||||
createForAuthenticatedUser: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export class Octokit {
|
||||
constructor() {
|
||||
return mockGithubClient;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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 const mockIndex = {
|
||||
addAll: jest.fn(),
|
||||
write: jest.fn(),
|
||||
writeTree: jest.fn().mockResolvedValue('mockoid'),
|
||||
};
|
||||
|
||||
export const mockRepo = {
|
||||
refreshIndex: jest.fn().mockResolvedValue(mockIndex),
|
||||
createCommit: jest.fn(),
|
||||
};
|
||||
|
||||
export const mockRemote = {
|
||||
push: jest.fn(),
|
||||
};
|
||||
|
||||
const Repository = { init: jest.fn().mockResolvedValue(mockRepo) };
|
||||
const Remote = { create: jest.fn().mockResolvedValue(mockRemote) };
|
||||
const Signature = { now: jest.fn() };
|
||||
const Cred = {
|
||||
userpassPlaintextNew: jest.fn(),
|
||||
};
|
||||
|
||||
export { Repository, Remote, Signature, Cred };
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
jest.mock('@octokit/rest');
|
||||
jest.mock('nodegit');
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import * as NodeGit from 'nodegit';
|
||||
import { OctokitResponse, ReposCreateInOrgResponseData } from '@octokit/types';
|
||||
import { GithubPublisher } from './github';
|
||||
|
||||
const { mockGithubClient } = require('@octokit/rest') as {
|
||||
mockGithubClient: { repos: jest.Mocked<Octokit['repos']> };
|
||||
};
|
||||
|
||||
const {
|
||||
Repository,
|
||||
mockRepo,
|
||||
mockIndex,
|
||||
Signature,
|
||||
Remote,
|
||||
mockRemote,
|
||||
Cred,
|
||||
} = require('nodegit') as {
|
||||
Repository: jest.Mocked<{ init: any }>;
|
||||
Signature: jest.Mocked<{ now: any }>;
|
||||
Cred: jest.Mocked<{ userpassPlaintextNew: any }>;
|
||||
Remote: jest.Mocked<{ create: any }>;
|
||||
|
||||
mockIndex: jest.Mocked<NodeGit.Index>;
|
||||
mockRepo: jest.Mocked<NodeGit.Repository>;
|
||||
mockRemote: jest.Mocked<NodeGit.Remote>;
|
||||
};
|
||||
|
||||
describe('Github Publisher', () => {
|
||||
const publisher = new GithubPublisher({ client: new Octokit() });
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('publish: createRemoteInGithub', () => {
|
||||
it('should use octokit to create a repo in an organisation if the organisation property is set', async () => {
|
||||
mockGithubClient.repos.createInOrg.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
|
||||
await publisher.publish({
|
||||
values: {
|
||||
isOrg: true,
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
});
|
||||
|
||||
expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({
|
||||
org: 'blam',
|
||||
name: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => {
|
||||
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
|
||||
await publisher.publish({
|
||||
values: {
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
});
|
||||
|
||||
expect(
|
||||
mockGithubClient.repos.createForAuthenticatedUser,
|
||||
).toHaveBeenCalledWith({
|
||||
name: 'test',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish: createGitDirectory', () => {
|
||||
const values = {
|
||||
isOrg: true,
|
||||
storePath: 'blam/test',
|
||||
owner: 'lols',
|
||||
};
|
||||
|
||||
const mockDir = '/tmp/test/dir';
|
||||
|
||||
mockGithubClient.repos.createInOrg.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
it('should call init on the repo with the directory', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(Repository.init).toHaveBeenCalledWith(mockDir, 0);
|
||||
});
|
||||
|
||||
it('should call refresh index on the index and write the new files', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(mockRepo.refreshIndex).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call add all files and write', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(mockIndex.addAll).toHaveBeenCalled();
|
||||
expect(mockIndex.write).toHaveBeenCalled();
|
||||
expect(mockIndex.writeTree).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a commit with on head with the right name and commiter', async () => {
|
||||
const mockSignature = { mockSignature: 'bloblly' };
|
||||
Signature.now.mockReturnValue(mockSignature);
|
||||
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(Signature.now).toHaveBeenCalledTimes(2);
|
||||
expect(Signature.now).toHaveBeenCalledWith(
|
||||
'Scaffolder',
|
||||
'scaffolder@backstage.io',
|
||||
);
|
||||
|
||||
expect(mockRepo.createCommit).toHaveBeenCalledWith(
|
||||
'HEAD',
|
||||
mockSignature,
|
||||
mockSignature,
|
||||
'initial commit',
|
||||
'mockoid',
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a remote with the repo and remote', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(Remote.create).toHaveBeenCalledWith(
|
||||
mockRepo,
|
||||
'origin',
|
||||
'mockclone',
|
||||
);
|
||||
});
|
||||
|
||||
it('shoud push to the remote repo', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
const [remotes, { callbacks }] = mockRemote.push.mock
|
||||
.calls[0] as NodeGit.PushOptions[];
|
||||
|
||||
expect(remotes).toEqual(['refs/heads/master:refs/heads/master']);
|
||||
|
||||
process.env.GITHUb_ACCESS_TOKEN = 'blob';
|
||||
|
||||
callbacks?.credentials?.();
|
||||
|
||||
expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith(
|
||||
process.env.GITHUB_ACCESS_TOKEN,
|
||||
'x-oauth-basic',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+25
-16
@@ -14,38 +14,47 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Storer } from './types';
|
||||
import { Publisher } from './types';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { RequiredTemplateValues } from '../templater';
|
||||
import { Repository, Remote, Signature, Cred } from 'nodegit';
|
||||
|
||||
export class GithubStorer implements Storer {
|
||||
export class GithubPublisher implements Publisher {
|
||||
private client: Octokit;
|
||||
constructor({ client }: { client: Octokit }) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async createRemote({
|
||||
async publish({
|
||||
values,
|
||||
directory,
|
||||
}: {
|
||||
entity: TemplateEntityV1alpha1;
|
||||
values: RequiredTemplateValues & Record<string, JsonValue>;
|
||||
}) {
|
||||
const [owner, name] = values.storePath.split('/');
|
||||
directory: string;
|
||||
}): Promise<{ remoteUrl: string }> {
|
||||
const remoteUrl = await this.createRemote(values);
|
||||
await this.pushToRemote(directory, remoteUrl);
|
||||
|
||||
const {
|
||||
data: { clone_url: cloneUrl },
|
||||
} = await this.client.repos.createInOrg({
|
||||
name,
|
||||
org: owner,
|
||||
});
|
||||
|
||||
return cloneUrl;
|
||||
return { remoteUrl };
|
||||
}
|
||||
|
||||
async pushToRemote(directory: string, remote: string): Promise<void> {
|
||||
private async createRemote(
|
||||
values: RequiredTemplateValues & Record<string, JsonValue>,
|
||||
) {
|
||||
const [owner, name] = values.storePath.split('/');
|
||||
|
||||
const repoCreationPromise = values.isOrg
|
||||
? this.client.repos.createInOrg({ name, org: owner })
|
||||
: this.client.repos.createForAuthenticatedUser({ name });
|
||||
|
||||
const { data } = await repoCreationPromise;
|
||||
|
||||
return data?.clone_url;
|
||||
}
|
||||
|
||||
private async pushToRemote(directory: string, remote: string): Promise<void> {
|
||||
const repo = await Repository.init(directory, 0);
|
||||
const index = await repo.refreshIndex();
|
||||
await index.addAll();
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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 * from './github';
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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 { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { RequiredTemplateValues } from '../templater';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
|
||||
export type Publisher = {
|
||||
publish(opts: {
|
||||
entity: TemplateEntityV1alpha1;
|
||||
values: RequiredTemplateValues & Record<string, JsonValue>;
|
||||
directory: string;
|
||||
}): Promise<{ remoteUrl: string }>;
|
||||
};
|
||||
@@ -22,13 +22,13 @@ import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
GithubPublisher,
|
||||
JobProcessor,
|
||||
PreparerBuilder,
|
||||
RequiredTemplateValues,
|
||||
StageContext,
|
||||
TemplaterBase,
|
||||
} from '../scaffolder';
|
||||
import { StageContext } from '../scaffolder/jobs/types';
|
||||
import { GithubStorer } from '../scaffolder/stages/store/github';
|
||||
|
||||
export interface RouterOptions {
|
||||
preparers: PreparerBuilder;
|
||||
@@ -44,7 +44,7 @@ export async function createRouter(
|
||||
|
||||
const githubClient = new Octokit({ auth: process.env.GITHUB_ACCESS_TOKEN });
|
||||
const { preparers, templater, logger: parentLogger, dockerClient } = options;
|
||||
const githubStorer = new GithubStorer({ client: githubClient });
|
||||
const githubPulisher = new GithubPublisher({ client: githubClient });
|
||||
const logger = parentLogger.child({ plugin: 'scaffolder' });
|
||||
const jobProcessor = new JobProcessor();
|
||||
|
||||
@@ -117,26 +117,16 @@ export async function createRouter(
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Create VCS Repo',
|
||||
name: 'Publish template',
|
||||
handler: async (ctx: StageContext<{ resultDir: string }>) => {
|
||||
ctx.logger.info('Should now create the VCS repo');
|
||||
const remoteUrl = await githubStorer.createRemote({
|
||||
ctx.logger.info('Should not store the template');
|
||||
const { remoteUrl } = await githubPulisher.publish({
|
||||
values: ctx.values,
|
||||
entity: ctx.entity,
|
||||
directory: ctx.resultDir,
|
||||
});
|
||||
|
||||
return { remoteUrl };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Push to remote',
|
||||
handler: async (
|
||||
ctx: StageContext<{ resultDir: string; remoteUrl: string }>,
|
||||
) => {
|
||||
ctx.logger.info('Should now push to the remote');
|
||||
await githubStorer.pushToRemote(ctx.resultDir, ctx.remoteUrl);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ const SentryIssuesTable: FC<SentryIssuesTableProps> = ({ sentryIssues }) => {
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
options={{ paging: true, search: false, pageSize: 5 }}
|
||||
options={{ padding: 'dense', paging: true, search: false, pageSize: 5 }}
|
||||
title="Sentry issues"
|
||||
data={sentryIssues}
|
||||
/>
|
||||
|
||||
@@ -20,16 +20,17 @@ import { useAsync } from 'react-use';
|
||||
import { useLocation, useParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Grid } from '@material-ui/core';
|
||||
import { Header, Content } from '@backstage/core';
|
||||
import { Header, Content, ItemCard } from '@backstage/core';
|
||||
|
||||
import transformer, {
|
||||
addBaseUrl,
|
||||
rewriteDocLinks,
|
||||
addEventListener,
|
||||
removeMkdocsHeader,
|
||||
modifyCss,
|
||||
} from '../transformers';
|
||||
import { docStorageURL } from '../../config';
|
||||
import URLParser from '../urlParser';
|
||||
import { DocsCard } from './DocsCard';
|
||||
|
||||
const useFetch = (url: string) => {
|
||||
const state = useAsync(async () => {
|
||||
@@ -74,9 +75,17 @@ export const Reader = () => {
|
||||
componentId,
|
||||
path,
|
||||
}),
|
||||
rewriteDocLinks({
|
||||
componentId,
|
||||
rewriteDocLinks(),
|
||||
modifyCss({
|
||||
cssTransforms: {
|
||||
'.md-main__inner': [{ 'margin-top': '0' }],
|
||||
'.md-sidebar': [{ top: '0' }, { width: '20rem' }],
|
||||
'.md-typeset': [{ 'font-size': '1rem' }],
|
||||
'.md-nav': [{ 'font-size': '1rem' }],
|
||||
'.md-grid': [{ 'max-width': '80vw' }],
|
||||
},
|
||||
}),
|
||||
removeMkdocsHeader(),
|
||||
]);
|
||||
|
||||
divElement.shadowRoot.innerHTML = '';
|
||||
@@ -93,39 +102,37 @@ export const Reader = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{componentId ? (
|
||||
<div ref={shadowDomRef} />
|
||||
) : (
|
||||
<>
|
||||
<Header
|
||||
title="Documentation"
|
||||
subtitle="Documentation available in Backstage"
|
||||
/>
|
||||
<Header
|
||||
title={componentId ?? 'Documentation'}
|
||||
subtitle={componentId ?? 'Documentation available in Backstage'}
|
||||
/>
|
||||
|
||||
<Content>
|
||||
<Grid container>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<DocsCard
|
||||
onClick={() => navigate('/docs/mkdocs')}
|
||||
tags={['Developer Tool']}
|
||||
title="MkDocs"
|
||||
label="Read Docs"
|
||||
description="MkDocs is a fast, simple and downright gorgeous static site generator that's geared towards building project documentation. "
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<DocsCard
|
||||
onClick={() => navigate('/docs/backstage-microsite')}
|
||||
tags={['Service']}
|
||||
title="Backstage"
|
||||
label="Read Docs"
|
||||
description="Getting started guides, API Overview, documentation around how to Create a Plugin and more. "
|
||||
/>
|
||||
</Grid>
|
||||
<Content>
|
||||
{componentId ? (
|
||||
<div ref={shadowDomRef} />
|
||||
) : (
|
||||
<Grid container>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<ItemCard
|
||||
onClick={() => navigate('/docs/mkdocs')}
|
||||
tags={['Developer Tool']}
|
||||
title="MkDocs"
|
||||
label="Read Docs"
|
||||
description="MkDocs is a fast, simple and downright gorgeous static site generator that's geared towards building project documentation. "
|
||||
/>
|
||||
</Grid>
|
||||
</Content>
|
||||
</>
|
||||
)}
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<ItemCard
|
||||
onClick={() => navigate('/docs/backstage-microsite')}
|
||||
tags={['Service']}
|
||||
title="Backstage"
|
||||
label="Read Docs"
|
||||
description="Getting started guides, API Overview, documentation around how to Create a Plugin and more. "
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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 { createTestShadowDom, FIXTURES, getSample } from '../../test-utils';
|
||||
import { addBaseUrl } from '../transformers';
|
||||
|
||||
const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com';
|
||||
|
||||
describe('addBaseUrl', () => {
|
||||
it('contains relative paths', () => {
|
||||
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE);
|
||||
|
||||
expect(getSample(shadowDom, 'img', 'src')).toEqual([
|
||||
'img/win-py-install.png',
|
||||
'img/initial-layout.png',
|
||||
]);
|
||||
expect(getSample(shadowDom, 'link', 'href')).toEqual([
|
||||
'https://www.mkdocs.org/',
|
||||
'assets/images/favicon.png',
|
||||
]);
|
||||
expect(getSample(shadowDom, 'script', 'src')).toEqual([
|
||||
'https://www.google-analytics.com/analytics.js',
|
||||
'assets/javascripts/vendor.d710d30a.min.js',
|
||||
]);
|
||||
});
|
||||
|
||||
it('contains transformed absolute paths', () => {
|
||||
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
|
||||
transformers: [
|
||||
addBaseUrl({
|
||||
docStorageURL: DOC_STORAGE_URL,
|
||||
componentId: 'example-docs',
|
||||
path: '',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(getSample(shadowDom, 'img', 'src')).toEqual([
|
||||
'https://example-host.storage.googleapis.com/example-docs/img/win-py-install.png',
|
||||
'https://example-host.storage.googleapis.com/example-docs/img/initial-layout.png',
|
||||
]);
|
||||
expect(getSample(shadowDom, 'link', 'href')).toEqual([
|
||||
'https://www.mkdocs.org/',
|
||||
'https://example-host.storage.googleapis.com/example-docs/assets/images/favicon.png',
|
||||
]);
|
||||
expect(getSample(shadowDom, 'script', 'src')).toEqual([
|
||||
'https://www.google-analytics.com/analytics.js',
|
||||
'https://example-host.storage.googleapis.com/example-docs/assets/javascripts/vendor.d710d30a.min.js',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes path option', () => {
|
||||
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
|
||||
transformers: [
|
||||
addBaseUrl({
|
||||
docStorageURL: DOC_STORAGE_URL,
|
||||
componentId: 'example-docs',
|
||||
path: 'examplepath',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(getSample(shadowDom, 'img', 'src')).toEqual([
|
||||
'https://example-host.storage.googleapis.com/example-docs/examplepath/img/win-py-install.png',
|
||||
'https://example-host.storage.googleapis.com/example-docs/examplepath/img/initial-layout.png',
|
||||
]);
|
||||
expect(getSample(shadowDom, 'link', 'href')).toEqual([
|
||||
'https://www.mkdocs.org/',
|
||||
'https://example-host.storage.googleapis.com/example-docs/examplepath/assets/images/favicon.png',
|
||||
]);
|
||||
expect(getSample(shadowDom, 'script', 'src')).toEqual([
|
||||
'https://www.google-analytics.com/analytics.js',
|
||||
'https://example-host.storage.googleapis.com/example-docs/examplepath/assets/javascripts/vendor.d710d30a.min.js',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 { createTestShadowDom, FIXTURES } from '../../test-utils';
|
||||
import { addEventListener } from '../transformers';
|
||||
|
||||
describe('addEventListener', () => {
|
||||
it('calls onClick when a link has been clicked', () => {
|
||||
const fn = jest.fn();
|
||||
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
|
||||
transformers: [
|
||||
addEventListener({
|
||||
onClick: fn,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
shadowDom.querySelector('a')?.click();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 transform, { Transformer } from '.';
|
||||
|
||||
describe('transform', () => {
|
||||
it('calls the transformers', () => {
|
||||
const fn = jest.fn();
|
||||
const mockTransformer = (): Transformer => (dom: Element) => {
|
||||
fn(dom);
|
||||
return dom;
|
||||
};
|
||||
|
||||
transform('<html></html>', [mockTransformer()]);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(Element));
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@
|
||||
export * from './addBaseUrl';
|
||||
export * from './rewriteDocLinks';
|
||||
export * from './addEventListener';
|
||||
export * from './removeMkdocsHeader';
|
||||
export * from './modifyCss';
|
||||
|
||||
export type Transformer = (dom: Element) => Element;
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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 { createTestShadowDom } from '../../test-utils';
|
||||
import { modifyCss } from '../transformers';
|
||||
|
||||
describe('modifyCss', () => {
|
||||
it('does not modify css', () => {
|
||||
const shadowDom = createTestShadowDom(
|
||||
`<div class="md-typeset" style="font-size: 0.8em"></div>`,
|
||||
{
|
||||
transformers: [],
|
||||
},
|
||||
);
|
||||
|
||||
const { fontSize } = getComputedStyle(
|
||||
shadowDom.querySelector<HTMLElement>('.md-typeset')!,
|
||||
);
|
||||
|
||||
expect(fontSize).toBe('0.8em');
|
||||
});
|
||||
|
||||
it('does modify css', () => {
|
||||
const shadowDom = createTestShadowDom(
|
||||
`<div class="md-typeset" style="font-size: 1px"></div>`,
|
||||
{
|
||||
transformers: [
|
||||
modifyCss({
|
||||
cssTransforms: {
|
||||
'.md-typeset': [{ 'font-size': '1em' }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const { fontSize } = getComputedStyle(
|
||||
shadowDom.querySelector<HTMLElement>('.md-typeset')!,
|
||||
);
|
||||
|
||||
expect(fontSize).toBe('1em');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 type { Transformer } from './index';
|
||||
|
||||
type ModifyCssOptions = {
|
||||
// Example: { '.md-container': { 'marginTop': '10px' }}
|
||||
cssTransforms: { [key: string]: { [key: string]: string }[] };
|
||||
};
|
||||
|
||||
export const modifyCss = ({ cssTransforms }: ModifyCssOptions): Transformer => {
|
||||
return dom => {
|
||||
Object.entries(cssTransforms).forEach(([cssSelector, cssChanges]) => {
|
||||
const elementsToChange = Array.from(
|
||||
dom.querySelectorAll<HTMLElement>(cssSelector),
|
||||
);
|
||||
if (elementsToChange.length < 1) return;
|
||||
|
||||
cssChanges.forEach(changes => {
|
||||
elementsToChange.forEach((element: HTMLElement) => {
|
||||
Object.entries(changes).forEach(([cssProperty, cssValue]) => {
|
||||
element.style.setProperty(cssProperty, cssValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return dom;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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 { createTestShadowDom, FIXTURES } from '../../test-utils';
|
||||
import { removeMkdocsHeader } from '../transformers';
|
||||
|
||||
describe('removeMkdocsHeader', () => {
|
||||
it('does not remove mkdocs header', () => {
|
||||
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
|
||||
transformers: [],
|
||||
});
|
||||
|
||||
expect(shadowDom.querySelector('.md-header')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does remove mkdocs header', () => {
|
||||
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
|
||||
transformers: [removeMkdocsHeader()],
|
||||
});
|
||||
|
||||
expect(shadowDom.querySelector('.md-header')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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 type { Transformer } from './index';
|
||||
|
||||
export const removeMkdocsHeader = (): Transformer => {
|
||||
return dom => {
|
||||
// Remove the header
|
||||
dom.querySelector('.md-header')?.remove();
|
||||
|
||||
return dom;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 { createTestShadowDom, getSample } from '../../test-utils';
|
||||
import { rewriteDocLinks } from '../transformers';
|
||||
|
||||
describe('rewriteDocLinks', () => {
|
||||
it('should not do anything', () => {
|
||||
const shadowDom = createTestShadowDom(`
|
||||
<a href="http://example.org/">Test</a>
|
||||
<a href="../example">Test</a>
|
||||
<a href="example-docs">Test</a>
|
||||
<a href="example-docs/example-page">Test Sub Page</a>
|
||||
`);
|
||||
|
||||
expect(getSample(shadowDom, 'a', 'href', 6)).toEqual([
|
||||
'http://example.org/',
|
||||
'../example',
|
||||
'example-docs',
|
||||
'example-docs/example-page',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should transform a href with licalhost as baseUrl', () => {
|
||||
const shadowDom = createTestShadowDom(
|
||||
`
|
||||
<a href="http://example.org/">Test</a>
|
||||
<a href="../example">Test</a>
|
||||
<a href="example-docs">Test</a>
|
||||
<a href="example-docs/example-page">Test Sub Page</a>
|
||||
`,
|
||||
{
|
||||
transformers: [rewriteDocLinks()],
|
||||
},
|
||||
);
|
||||
|
||||
expect(getSample(shadowDom, 'a', 'href', 6)).toEqual([
|
||||
'http://example.org/',
|
||||
'http://localhost/example',
|
||||
'http://localhost/example-docs',
|
||||
'http://localhost/example-docs/example-page',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -17,9 +17,7 @@
|
||||
import URLParser from '../urlParser';
|
||||
import type { Transformer } from './index';
|
||||
|
||||
type AddBaseUrlOptions = {};
|
||||
|
||||
export const rewriteDocLinks = ({}: AddBaseUrlOptions): Transformer => {
|
||||
export const rewriteDocLinks = (): Transformer => {
|
||||
return dom => {
|
||||
const updateDom = <T extends Element>(
|
||||
list: Array<T>,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 FIXTURE_STANDARD_PAGE from './fixtures/mkdocs-index';
|
||||
import transformer from '../reader/transformers';
|
||||
import type { Transformer } from '../reader/transformers';
|
||||
|
||||
export const FIXTURES = {
|
||||
FIXTURE_STANDARD_PAGE,
|
||||
};
|
||||
|
||||
export type CreateTestShadowDomOptions = {
|
||||
transformers: Transformer[];
|
||||
};
|
||||
|
||||
export const createTestShadowDom = (
|
||||
fixture: string,
|
||||
opts: CreateTestShadowDomOptions = { transformers: [] },
|
||||
): ShadowRoot => {
|
||||
const divElement = document.createElement('div');
|
||||
divElement.attachShadow({ mode: 'open' });
|
||||
document.body.appendChild(divElement);
|
||||
|
||||
const domParser = new DOMParser().parseFromString(fixture, 'text/html');
|
||||
divElement.shadowRoot?.appendChild(domParser.documentElement);
|
||||
|
||||
if (opts.transformers) {
|
||||
transformer(divElement.shadowRoot!.children[0], opts.transformers);
|
||||
}
|
||||
|
||||
return divElement.shadowRoot!;
|
||||
};
|
||||
|
||||
export const getSample = (
|
||||
shadowDom: ShadowRoot,
|
||||
elementName: string,
|
||||
elementAttribute: string,
|
||||
sampleSize = 2,
|
||||
) => {
|
||||
const rootElement = shadowDom.children[0];
|
||||
|
||||
return Array.from(rootElement.getElementsByTagName(elementName))
|
||||
.filter(elem => {
|
||||
return elem.hasAttribute(elementAttribute);
|
||||
})
|
||||
.slice(0, sampleSize)
|
||||
.map(elem => {
|
||||
return elem.getAttribute(elementAttribute);
|
||||
});
|
||||
};
|
||||
@@ -14191,6 +14191,13 @@ passport-github2@^0.1.12:
|
||||
dependencies:
|
||||
passport-oauth2 "1.x.x"
|
||||
|
||||
passport-gitlab2@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.npmjs.org/passport-gitlab2/-/passport-gitlab2-5.0.0.tgz#ea37e5285321c026a02671e87469cac28cce9b69"
|
||||
integrity sha512-cXQMgM6JQx9wHVh7JLH30D8fplfwjsDwRz+zS0pqC8JS+4bNmc1J04NGp5g2M4yfwylH9kQRrMN98GxMw7q7cg==
|
||||
dependencies:
|
||||
passport-oauth2 "^1.4.0"
|
||||
|
||||
passport-google-oauth20@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz#0d241b2d21ebd3dc7f2b60669ec4d587e3a674ef"
|
||||
@@ -14207,7 +14214,7 @@ passport-oauth1@1.x.x:
|
||||
passport-strategy "1.x.x"
|
||||
utils-merge "1.x.x"
|
||||
|
||||
passport-oauth2@1.x.x, passport-oauth2@^1.5.0:
|
||||
passport-oauth2@1.x.x, passport-oauth2@^1.4.0, passport-oauth2@^1.5.0:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.5.0.tgz#64babbb54ac46a4dcab35e7f266ed5294e3c4108"
|
||||
integrity sha512-kqBt6vR/5VlCK8iCx1/KpY42kQ+NEHZwsSyt4Y6STiNjU+wWICG1i8ucc1FapXDGO15C5O5VZz7+7vRzrDPXXQ==
|
||||
@@ -15689,20 +15696,15 @@ react-router-dom@6.0.0-alpha.5, react-router-dom@^6.0.0-alpha.5:
|
||||
history "5.0.0-beta.9"
|
||||
prop-types "^15.7.2"
|
||||
|
||||
react-router-dom@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662"
|
||||
integrity sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA==
|
||||
react-router@6.0.0-alpha.5, react-router@^6.0.0-alpha.5:
|
||||
version "6.0.0-alpha.5"
|
||||
resolved "https://registry.npmjs.org/react-router/-/react-router-6.0.0-alpha.5.tgz#c98805e50dc0e64787aa8aa4fa6753b435f2496b"
|
||||
integrity sha512-cDj70bTUAgcfx6b5Fx1+wVlBSDVZGo8N+GUDk/yNFDCyGLfAsFlRpS3BhQqx8c49w2cCW+OrXxFhB4cbLZxWJw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.1.2"
|
||||
history "^4.9.0"
|
||||
loose-envify "^1.3.1"
|
||||
prop-types "^15.6.2"
|
||||
react-router "5.2.0"
|
||||
tiny-invariant "^1.0.2"
|
||||
tiny-warning "^1.0.0"
|
||||
history "5.0.0-beta.9"
|
||||
prop-types "^15.7.2"
|
||||
|
||||
react-router@5.2.0, react-router@^5.2.0:
|
||||
react-router@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293"
|
||||
integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==
|
||||
@@ -15718,14 +15720,6 @@ react-router@5.2.0, react-router@^5.2.0:
|
||||
tiny-invariant "^1.0.2"
|
||||
tiny-warning "^1.0.0"
|
||||
|
||||
react-router@6.0.0-alpha.5, react-router@^6.0.0-alpha.5:
|
||||
version "6.0.0-alpha.5"
|
||||
resolved "https://registry.npmjs.org/react-router/-/react-router-6.0.0-alpha.5.tgz#c98805e50dc0e64787aa8aa4fa6753b435f2496b"
|
||||
integrity sha512-cDj70bTUAgcfx6b5Fx1+wVlBSDVZGo8N+GUDk/yNFDCyGLfAsFlRpS3BhQqx8c49w2cCW+OrXxFhB4cbLZxWJw==
|
||||
dependencies:
|
||||
history "5.0.0-beta.9"
|
||||
prop-types "^15.7.2"
|
||||
|
||||
react-side-effect@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.0.tgz#1ce4a8b4445168c487ed24dab886421f74d380d3"
|
||||
|
||||
Reference in New Issue
Block a user