Merge pull request #1068 from chanwit/gitops-profiles

New plugin for creating GitOps-managed Kubernetes right from Backstage.
This commit is contained in:
Patrik Oldsberg
2020-06-11 15:03:27 +02:00
committed by GitHub
29 changed files with 1474 additions and 0 deletions
+1
View File
@@ -8,6 +8,7 @@
"@backstage/plugin-catalog": "^0.1.1-alpha.7",
"@backstage/plugin-circleci": "^0.1.1-alpha.7",
"@backstage/plugin-explore": "^0.1.1-alpha.7",
"@backstage/plugin-gitops-profiles": "^0.1.1-alpha.7",
"@backstage/plugin-home-page": "^0.1.1-alpha.7",
"@backstage/plugin-lighthouse": "^0.1.1-alpha.7",
"@backstage/plugin-register-component": "^0.1.1-alpha.7",
+4
View File
@@ -44,6 +44,8 @@ import { techRadarApiRef, TechRadar } from '@backstage/plugin-tech-radar';
import { CircleCIApi, circleCIApiRef } from '@backstage/plugin-circleci';
import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog';
import { gitOpsApiRef, GitOpsRestApi } from '@backstage/plugin-gitops-profiles';
const builder = ApiRegistry.builder();
const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
@@ -97,4 +99,6 @@ builder.add(
}),
);
builder.add(gitOpsApiRef, new GitOpsRestApi('http://localhost:3008'));
export default builder.build() as ApiHolder;
+1
View File
@@ -23,3 +23,4 @@ export { plugin as Explore } from '@backstage/plugin-explore';
export { plugin as Circleci } from '@backstage/plugin-circleci';
export { plugin as RegisterComponent } from '@backstage/plugin-register-component';
export { plugin as Sentry } from '@backstage/plugin-sentry';
export { plugin as GitopsProfiles } from '@backstage/plugin-gitops-profiles';
@@ -80,6 +80,14 @@ const toolsCards = [
'https://camo.githubusercontent.com/517398c3fbe0687d3d4dcbe05da82970b882e75a/68747470733a2f2f64337676366c703535716a6171632e636c6f756466726f6e742e6e65742f6974656d732f33413061324e314c3346324f304c3377326e316a2f477261706869514c382e706e673f582d436c6f75644170702d56697369746f722d49643d3433363432',
tags: ['graphql', 'dev'],
},
{
title: 'GitOps Clusters',
description:
'Create GitOps-managed clusters with Backstage. Currently supports EKS flavors and profiles like Machine Learning Ops (MLOps)',
url: '/gitops-clusters',
image: 'https://miro.medium.com/max/801/1*R28u8gj-hVdDFISoYqPhrQ.png',
tags: ['gitops', 'dev'],
},
];
const ExplorePluginPage: FC<{}> = () => {
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+26
View File
@@ -0,0 +1,26 @@
# gitops-profiles
Welcome to the gitops-profiles plugin!
This plugin is for creating GitOps-managed Kubernetes clusters. Currently, it supports provisioning EKS clusters on GitHub via GitHub Actions.
_This plugin was created through the Backstage CLI_
## Plugin Development
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/gitops-clusters](http://localhost:3000/gitops-profiles).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory.
## Use GitOps-API backend with Backstage
The backend of this plugin is written in Golang and its source code is available [here](https://github.com/chanwit/gitops-api) as a separate GitHub repository.
The binary of this plugin is available as a ready-to-use Docker image, [https://hub.docker.com/chanwit/gitops-api](https://hub.docker.com/chanwit/gitops-api).
To start using GitOps with Backstage, you have to start the backend using the following command:
```bash
$ docker run -d --init -p 3008:8080 chanwit/gitops-api
```
Please note that this plugin requires the backend to run on port 3008.
+20
View File
@@ -0,0 +1,20 @@
/*
* 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 { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@backstage/plugin-gitops-profiles",
"version": "0.1.1-alpha.7",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^14.2.0",
"react-router-dom": "^5.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist/**/*.{js,d.ts}"
]
}
+170
View File
@@ -0,0 +1,170 @@
/*
* 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 { createApiRef } from '@backstage/core-api';
export interface CloneFromTemplateRequest {
templateRepository: string;
secrets: {
awsAccessKeyId: string;
awsSecretAccessKey: string;
};
targetOrg: string;
targetRepo: string;
gitHubUser: string;
gitHubToken: string;
}
export interface ApplyProfileRequest {
targetOrg: string;
targetRepo: string;
gitHubUser: string;
gitHubToken: string;
profiles: string[];
}
export interface ChangeClusterStateRequest {
targetOrg: string;
targetRepo: string;
gitHubUser: string;
gitHubToken: string;
clusterState: 'present' | 'absent'; // /api/cluster/state
}
export interface PollLogRequest {
targetOrg: string;
targetRepo: string;
gitHubUser: string;
gitHubToken: string;
}
export interface Status {
status: string; // queued, in_progress, or completed
message: string;
conclusion: string; // success, failure, neutral, cancelled, skipped, timed_out, or action_required
}
export interface StatusResponse {
result: Status[];
link: string;
status: string;
}
export interface ClusterStatus {
name: string;
link: string;
status: string;
conclusion: string;
runStatus: Status[];
}
export interface ListClusterStatusesResponse {
result: ClusterStatus[];
}
export interface ListClusterRequest {
gitHubUser: string;
gitHubToken: string;
}
export class FetchError extends Error {
get name(): string {
return this.constructor.name;
}
static async forResponse(resp: Response): Promise<FetchError> {
return new FetchError(
`Request failed with status code ${
resp.status
}.\nReason: ${await resp.text()}`,
);
}
}
export type GitOpsApi = {
url: string;
fetchLog(req: PollLogRequest): Promise<StatusResponse>;
changeClusterState(req: ChangeClusterStateRequest): Promise<any>;
cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise<any>;
applyProfiles(req: ApplyProfileRequest): Promise<any>;
listClusters(req: ListClusterRequest): Promise<ListClusterStatusesResponse>;
};
export const gitOpsApiRef = createApiRef<GitOpsApi>({
id: 'plugin.gitops.service',
description: 'Used by the GitOps profiles plugin to make requests',
});
export class GitOpsRestApi implements GitOpsApi {
constructor(public url: string = '') {}
private async fetch<T = any>(path: string, init?: RequestInit): Promise<T> {
const resp = await fetch(`${this.url}${path}`, init);
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',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async changeClusterState(req: ChangeClusterStateRequest): Promise<any> {
return await this.fetch<any>('/api/cluster/state', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise<any> {
return await this.fetch<any>('/api/cluster/clone-from-template', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async applyProfiles(req: ApplyProfileRequest): Promise<any> {
return await this.fetch<any>('/api/cluster/profiles', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async listClusters(
req: ListClusterRequest,
): Promise<ListClusterStatusesResponse> {
return await this.fetch<ListClusterStatusesResponse>('/api/clusters', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
}
@@ -0,0 +1,95 @@
/*
* 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, { FC } from 'react';
import {
Content,
ContentHeader,
Header,
SupportButton,
Page,
pageTheme,
Progress,
HeaderLabel,
useApi,
} from '@backstage/core';
import ClusterTable from '../ClusterTable/ClusterTable';
import { Button, Typography } from '@material-ui/core';
import { useAsync, useLocalStorage } from 'react-use';
import { gitOpsApiRef, ListClusterStatusesResponse } from '../../api';
const ClusterList: FC<{}> = () => {
const [loginInfo] = useLocalStorage<{
token: string;
username: string;
name: string;
}>('githubLoginDetails');
const api = useApi(gitOpsApiRef);
const { loading, error, value } = useAsync<ListClusterStatusesResponse>(
() => {
return api.listClusters({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
});
},
);
let content: JSX.Element;
if (loading) {
content = (
<Content>
<Progress />
</Content>
);
} else if (error) {
content = (
<Content>
<Typography variant="h4" color="error">
Failed to load cluster, {String(error)}
</Typography>
</Content>
);
} else {
content = (
<Content>
<ContentHeader title="Clusters">
<Button
variant="contained"
color="primary"
href="/gitops-cluster-create"
>
Create GitOps-managed Cluster
</Button>
<SupportButton>All clusters</SupportButton>
</ContentHeader>
<ClusterTable components={value!.result} />
</Content>
);
}
return (
<Page theme={pageTheme.home}>
<Header title="GitOps-managed Clusters">
<HeaderLabel label="Welcome" value={loginInfo.name} />
</Header>
{content}
</Page>
);
};
export default ClusterList;
@@ -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 { default } from './ClusterList';
@@ -0,0 +1,103 @@
/*
* 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, { FC, useEffect, useState } from 'react';
import {
Content,
Header,
Page,
pageTheme,
Table,
Progress,
HeaderLabel,
useApi,
} 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<{ owner: string; repo: string }>();
const [loginInfo] = useLocalStorage<{
token: string;
username: string;
name: string;
}>('githubLoginDetails');
const [pollingLog, setPollingLog] = useState(true);
const [runStatus, setRunStatus] = useState<Status[]>([]);
const [runLink, setRunLink] = useState<string>('');
const [showProgress, setShowProgress] = useState(true);
const api = useApi(gitOpsApiRef);
const columns = [
{ field: 'status', title: 'Status' },
{ field: 'message', title: 'Message' },
];
useEffect(() => {
if (pollingLog) {
const interval = setInterval(async () => {
const resp = await api.fetchLog({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
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]);
return (
<Page theme={pageTheme.home}>
<Header title={`Cluster ${params.owner}/${params.repo}`}>
<HeaderLabel label="Welcome" value={loginInfo.name} />
</Header>
<Content>
<Progress hidden={!showProgress} />
<Table
options={{ search: false, paging: false, toolbar: false }}
data={transformRunStatus(runStatus)}
columns={columns}
/>
<Link
hidden={runLink === ''}
rel="noopener noreferrer"
href={`${runLink}?check_suite_focus=true`}
target="_blank"
>
Details
</Link>
</Content>
</Page>
);
};
export default ClusterPage;
@@ -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 { default } from './ClusterPage';
@@ -0,0 +1,69 @@
/*
* 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, { FC } from 'react';
import { Table, TableColumn } from '@backstage/core';
import { Link } from '@material-ui/core';
import { ClusterStatus } from '../../api';
import { transformStatus } from '../ProfileCatalog/ProfileCatalog';
const columns: TableColumn[] = [
{
title: 'Cluster Name',
field: 'name',
highlight: true,
render: (componentData: any) => (
<Link href={`/gitops-cluster/${componentData.name}`}>
{componentData.name}
</Link>
),
},
{
title: 'Status',
field: 'status',
render: (componentData: any) => (
<>
{transformStatus({
status: componentData.status,
conclusion: componentData.conclusion,
message: componentData.status,
})}
</>
),
},
{
title: 'Conclusion',
field: 'Conclusion',
render: (componentData: any) => (
<>
{transformStatus({
status: componentData.status,
conclusion: componentData.conclusion,
message: componentData.conclusion,
})}
</>
),
},
];
type ClusterTableProps = {
components: ClusterStatus[];
};
const ClusterTable: FC<ClusterTableProps> = ({ components }) => {
return (
<Table columns={columns} options={{ paging: false }} data={components} />
);
};
export default ClusterTable;
@@ -0,0 +1,102 @@
/*
* 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, { FC } from 'react';
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
import Card from '@material-ui/core/Card';
import CardHeader from '@material-ui/core/CardHeader';
import CardContent from '@material-ui/core/CardContent';
import CardActions from '@material-ui/core/CardActions';
import Avatar from '@material-ui/core/Avatar';
import IconButton from '@material-ui/core/IconButton';
import Typography from '@material-ui/core/Typography';
import { red } from '@material-ui/core/colors';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
maxWidth: 345,
},
media: {
height: 0,
paddingTop: '56.25%', // 16:9
},
expand: {
transform: 'rotate(0deg)',
marginLeft: 'auto',
transition: theme.transitions.create('transform', {
duration: theme.transitions.duration.shortest,
}),
},
expandOpen: {
transform: 'rotate(180deg)',
},
avatar: {
fontSize: '1.0rem',
backgroundColor: red[500],
},
}),
);
interface Props {
platformName: string;
title: string;
repository: string;
description: string;
index: number;
onClick: (i: number, repo: string) => void;
activeIndex: number;
}
const ClusterTemplateCard: FC<Props> = props => {
const classes = useStyles();
const handleSelect = () => {
props.onClick(props.index, props.repository);
};
return (
<Card className={classes.root}>
<CardHeader
avatar={
<Avatar aria-label="recipe" className={classes.avatar}>
{props.platformName}
</Avatar>
}
action={<IconButton aria-label="settings" />}
title={props.title}
subheader={props.repository}
/>
<CardContent>
<Typography variant="body2" color="textSecondary" component="p">
{props.description}
</Typography>
</CardContent>
<CardActions disableSpacing>
<IconButton aria-label="select" onClick={handleSelect}>
{props.activeIndex === props.index ? (
<CheckBoxIcon color="primary" />
) : (
<CheckBoxOutlineBlankIcon />
)}
</IconButton>
</CardActions>
</Card>
);
};
export default ClusterTemplateCard;
@@ -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 { default } from './ClusterTemplateCard';
@@ -0,0 +1,58 @@
/*
* 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, { FC } from 'react';
import { Grid } from '@material-ui/core';
import ClusterTemplateCard from '../ClusterTemplateCard';
interface Props {
template: {
platformName: string;
title: string;
repository: string;
description: string;
}[];
}
const ClusterTemplateCardList: FC<Props> = props => {
const [activeIndex, setActiveIndex] = React.useState(-1);
const handleClicked = (index: number, repository: string) => {
setActiveIndex(index);
window.localStorage.setItem('gitops-template-repo', repository);
};
return (
<Grid container xl={12} spacing={4}>
{props.template.map((value, index) => (
<Grid item xl={2} key={index}>
<ClusterTemplateCard
activeIndex={activeIndex}
onClick={handleClicked}
index={index}
key={index}
platformName={value.platformName}
title={value.title}
repository={value.repository}
description={value.description}
/>
</Grid>
))}
</Grid>
);
};
export default ClusterTemplateCardList;
@@ -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 { default } from './ClusterTemplateCardList';
@@ -0,0 +1,109 @@
/*
* 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, { FC, useState } from 'react';
import {
Avatar,
Card,
CardActions,
CardContent,
CardHeader,
createStyles,
IconButton,
Theme,
Typography,
} from '@material-ui/core';
import { green } from '@material-ui/core/colors';
import { makeStyles } from '@material-ui/core/styles';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
maxWidth: 345,
},
media: {
height: 0,
paddingTop: '56.25%', // 16:9
},
expand: {
transform: 'rotate(0deg)',
marginLeft: 'auto',
transition: theme.transitions.create('transform', {
duration: theme.transitions.duration.shortest,
}),
},
expandOpen: {
transform: 'rotate(180deg)',
},
avatar: {
backgroundColor: green[500],
},
}),
);
interface Props {
shortName: string;
title: string;
repository: string;
description: string;
index: number;
onClick: (i: number, repository: string) => void;
selections: Set<number>;
}
const ProfileCard: FC<Props> = props => {
const [selection, setSelection] = useState(false);
const handleSelect = () => {
props.onClick(props.index, props.repository);
setSelection(props.selections.has(props.index));
};
const classes = useStyles();
return (
<Card className={classes.root}>
<CardHeader
avatar={
<Avatar aria-label="recipe" className={classes.avatar}>
{props.shortName}
</Avatar>
}
action={<IconButton aria-label="settings" />}
title={props.title}
subheader={props.repository.replace('https://github.com/', '')}
/>
<CardContent>
<Typography variant="body2" color="textSecondary" component="p">
{props.description}
</Typography>
</CardContent>
<CardActions disableSpacing>
<IconButton aria-label="select" onClick={handleSelect}>
{selection ? (
<CheckBoxIcon color="primary" />
) : (
<CheckBoxOutlineBlankIcon />
)}
</IconButton>
</CardActions>
</Card>
);
};
export default ProfileCard;
@@ -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 { default } from './ProfileCard';
@@ -0,0 +1,71 @@
/*
* 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, { FC, useState } from 'react';
import { Grid } from '@material-ui/core';
import ProfileCard from '../ProfileCard';
interface Props {
profileTemplates: {
shortName: string;
title: string;
repository: string;
description: string;
}[];
}
const ProfileCardList: FC<Props> = props => {
const [selections, setSelections] = useState<Set<number>>(new Set<number>());
const [profiles, setProfiles] = useState<Set<string>>(new Set<string>());
const handleClicked = (index: number, repository: string) => {
if (selections.has(index)) {
selections.delete(index);
profiles.delete(repository);
} else {
selections.add(index);
profiles.add(repository);
}
setSelections(selections);
setProfiles(profiles);
window.localStorage.setItem(
'gitops-profiles',
JSON.stringify(Array.from(profiles)),
);
};
return (
<Grid container xl={12} spacing={4}>
{props.profileTemplates.map((value, index) => (
<Grid item xl={2} key={index}>
<ProfileCard
shortName={value.shortName}
selections={selections}
onClick={handleClicked}
key={index}
index={index}
title={value.title}
repository={value.repository}
description={value.description}
/>
</Grid>
))}
</Grid>
);
};
export default ProfileCardList;
@@ -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 { default } from './ProfileCardList';
@@ -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 React from 'react';
import { render } from '@testing-library/react';
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-api';
import { gitOpsApiRef, GitOpsRestApi } from '../../api';
describe('ProfileCatalog', () => {
it('should render', () => {
const apis = ApiRegistry.from([
[gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')],
]);
mockFetch.mockResponse(() => new Promise(() => {}));
const rendered = render(
<ThemeProvider theme={lightTheme}>
<ApiProvider apis={apis}>
<ProfileCatalog />
</ApiProvider>
</ThemeProvider>,
);
expect(
rendered.getByText('Create GitOps-managed Cluster'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,335 @@
/*
* 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, { FC, useEffect, useState } from 'react';
import {
Header,
Page,
pageTheme,
Content,
ContentHeader,
HeaderLabel,
SupportButton,
SimpleStepper,
SimpleStepperStep,
InfoCard,
Progress,
Table,
StatusWarning,
StatusOK,
StatusRunning,
StatusError,
StatusPending,
StatusAborted,
useApi,
} from '@backstage/core';
import { TextField, List, ListItem, Link } from '@material-ui/core';
import ClusterTemplateCardList from '../ClusterTemplateCardList';
import ProfileCardList from '../ProfileCardList';
import { useLocalStorage } from 'react-use';
import { gitOpsApiRef, Status } from '../../api';
// OK = (completed, success)
// Error = (?,failure)
// Aborted = (?,cancelled)
// Error = (?,timed_out)
// Warning = (?, skipped)
// Running = (queued, ?)
// Running = (in_progress,?)
export const transformStatus = (value: Status): JSX.Element => {
let status: JSX.Element = <StatusRunning>Unknown</StatusRunning>;
if (value.status === 'completed' && value.conclusion === 'success') {
status = <StatusOK>Success</StatusOK>;
} else if (value.conclusion === 'failure') {
status = <StatusError>Failure</StatusError>;
} else if (value.conclusion === 'cancelled') {
status = <StatusAborted>Cancelled</StatusAborted>;
} else if (value.conclusion === 'timed_out') {
status = <StatusError>Timed out</StatusError>;
} else if (value.conclusion === 'skipped') {
status = <StatusWarning>Skipped</StatusWarning>;
} else if (value.status === 'queued') {
status = <StatusPending>Queued</StatusPending>;
} else if (value.status === 'in_progress') {
status = <StatusRunning>In Progress</StatusRunning>;
}
return status;
};
export const transformRunStatus = (x: Status[]) => {
return x.map(value => {
return {
status: transformStatus(value),
message: value.message,
};
});
};
const ProfileCatalog: FC<{}> = () => {
// TODO: get data from REST API
const [clusterTemplates] = React.useState([
{
platformName: '15m',
title: 'EKS 2 workers',
repository: 'chanwit/eks-cluster-template',
description: 'EKS with Kubernetes 1.16 / 2 nodes of m5.xlarge (15 mins)',
},
{
platformName: '15m',
title: 'EKS 1 worker',
repository: 'chanwit/template-2',
description: 'EKS with Kubernetes 1.16 / 1 node of m5.xlarge (15 mins)',
},
]);
const [profileTemplates] = React.useState([
{
shortName: 'ml',
title: 'MLOps',
repository: 'https://github.com/weaveworks/mlops-profile',
description: 'Kubeflow-based Machine Learning pipeline',
},
{
shortName: 'ai',
title: 'COVID ML',
repository: 'https://github.com/weaveworks/covid-ml-profile',
description: 'Fk-covid Application profile',
},
]);
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 [gitHubRepo, setGitHubRepo] = useState('new-cluster');
const [awsAccessKeyId, setAwsAccessKeyId] = useState(String);
const [awsSecretAccessKey, setAwsSecretAccessKey] = useState(String);
const [runStatus, setRunStatus] = useState<Status[]>([]);
const [runLink, setRunLink] = useState<string>('');
const api = useApi(gitOpsApiRef);
useEffect(() => {
if (pollingLog) {
const interval = setInterval(async () => {
const resp = await api.fetchLog({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
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]);
const showFailureMessage = (msg: string) => {
setRunStatus(
runStatus.concat([
{
status: 'completed',
message: msg,
conclusion: 'failure',
},
]),
);
};
const showSuccessMessage = (msg: string) => {
setRunStatus(
runStatus.concat([
{
status: 'completed',
message: msg,
conclusion: 'success',
},
]),
);
};
const doCreateCluster = async () => {
setShowProgress(true);
setRunStatus([]);
const cloneResponse = await api.cloneClusterFromTemplate({
templateRepository: templateRepo,
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
secrets: {
awsAccessKeyId: awsAccessKeyId,
awsSecretAccessKey: awsSecretAccessKey,
},
});
if (cloneResponse.error === undefined) {
showSuccessMessage('Forked new cluster repo');
} else {
setShowProgress(false);
showFailureMessage(cloneResponse.error);
}
const applyProfileResp = await api.applyProfiles({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
profiles: gitopsProfiles,
});
if (applyProfileResp.error === undefined) {
showSuccessMessage('Applied profiles to the repo');
} else {
setShowProgress(false);
showFailureMessage(applyProfileResp.error);
}
const clusterStateResp = await api.changeClusterState({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
clusterState: 'present',
});
if (clusterStateResp.error === undefined) {
// cluster creation start, so start pulling log
setPollingLog(true);
showSuccessMessage('Changed desired cluster state to present');
} else {
setPollingLog(false);
setShowProgress(false);
showFailureMessage(clusterStateResp.error);
}
};
const columns = [
{ field: 'status', title: 'Status' },
{ field: 'message', title: 'Message' },
];
return (
<Page theme={pageTheme.tool}>
<Header
title="Create GitOps-managed Cluster"
subtitle="Kubernetes cluster with ready-to-use profiles"
>
<HeaderLabel label="Welcome" value={loginInfo.name} />
</Header>
<Content>
<ContentHeader title="Create Cluster">
<SupportButton>A description of your plugin goes here.</SupportButton>
</ContentHeader>
<SimpleStepper>
<SimpleStepperStep title="Choose Cluster Template">
<ClusterTemplateCardList template={clusterTemplates} />
</SimpleStepperStep>
<SimpleStepperStep title="Select GitOps Profile">
<ProfileCardList profileTemplates={profileTemplates} />
</SimpleStepperStep>
<SimpleStepperStep
title="Create Cluster"
actions={{ nextText: 'Create', onNext: () => doCreateCluster() }}
>
<InfoCard>
<List>
<ListItem>
<TextField
name="github-org-tf"
label="GitHub Organization"
defaultValue={gitHubOrg}
required
onChange={e => {
setGitHubOrg(e.target.value);
}}
/>
</ListItem>
<ListItem>
<TextField
name="github-repo-tf"
label="New Repository"
defaultValue={gitHubRepo}
required
onChange={e => {
setGitHubRepo(e.target.value);
}}
/>
</ListItem>
<ListItem>
<TextField
name="aws-access-key-id-tf"
label="Access Key ID"
required
type="password"
onChange={e => {
setAwsAccessKeyId(e.target.value);
}}
/>
</ListItem>
<ListItem>
<TextField
name="aws-secret-access-key-tf"
label="Secret Access Key"
required
type="password"
onChange={e => {
setAwsSecretAccessKey(e.target.value);
}}
/>
</ListItem>
</List>
</InfoCard>
</SimpleStepperStep>
</SimpleStepper>
<div>
<Progress hidden={!showProgress} />
<Table
options={{ search: false, paging: false, toolbar: false }}
data={transformRunStatus(runStatus)}
columns={columns}
/>
<Link
hidden={runLink === ''}
rel="noopener noreferrer"
href={`${runLink}?check_suite_focus=true`}
target="_blank"
>
Details
</Link>
</div>
</Content>
</Page>
);
};
export default ProfileCatalog;
@@ -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 { default, transformRunStatus } from './ProfileCatalog';
+18
View File
@@ -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 { plugin } from './plugin';
export * from './api';
@@ -0,0 +1,23 @@
/*
* 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 { plugin } from './plugin';
describe('gitops-profiles', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createPlugin } from '@backstage/core';
import ProfileCatalog from './components/ProfileCatalog';
import ClusterPage from './components/ClusterPage';
import ClusterList from './components/ClusterList';
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);
},
});
+18
View File
@@ -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.
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();