Created a plugin for cloud build as CI/CD

This commit is contained in:
ebarrios
2020-09-17 17:59:05 +02:00
parent b8bf566e4f
commit d139cf6a28
32 changed files with 1799 additions and 9 deletions
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+13
View File
@@ -0,0 +1,13 @@
# cloudbuild
Welcome to the cloudbuild plugin!
_This plugin was created through the Backstage CLI_
## Getting started
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 [/cloudbuild](http://localhost:3000/cloudbuild).
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.
+19
View File
@@ -0,0 +1,19 @@
/*
* 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();
+55
View File
@@ -0,0 +1,55 @@
{
"name": "@backstage/plugin-cloudbuild",
"version": "0.1.1-alpha.21",
"main": "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/catalog-model": "^0.1.1-alpha.21",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/core-api": "^0.1.1-alpha.21",
"@backstage/plugin-catalog": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@octokit/rest": "^18.0.0",
"@octokit/types": "^5.4.1",
"moment": "^2.27.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-lazylog": "^4.5.3",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist"
]
}
@@ -0,0 +1,74 @@
/*
* 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';
import {
ActionsListWorkflowRunsForRepoResponseData,
ActionsGetWorkflowResponseData,
ActionsGetWorkflowRunResponseData,
EndpointInterface,
} from '../api/types';
export const cloudbuildApiRef = createApiRef<CloudbuildApi>({
id: 'plugin.cloudbuild.service',
description: 'Used by the Cloudbuild plugin to make requests',
});
export type CloudbuildApi = {
listWorkflowRuns: ({
token,
projectId,
}: {
token: string;
projectId: string;
}) => Promise<ActionsListWorkflowRunsForRepoResponseData>;
getWorkflow: ({
token,
projectId,
id,
}: {
token: string;
projectId: string;
id: string;
}) => Promise<ActionsGetWorkflowResponseData>;
getWorkflowRun: ({
token,
projectId,
id,
}: {
token: string;
projectId: string;
id: string;
}) => Promise<ActionsGetWorkflowRunResponseData>;
reRunWorkflow: ({
token,
projectId,
runId,
}: {
token: string;
projectId: string;
runId: string;
}) => Promise<any>;
downloadJobLogsForWorkflowRun: ({
token,
projectId,
runId,
}: {
token: string;
projectId: string;
runId: string;
}) => Promise<EndpointInterface>;
};
@@ -0,0 +1,134 @@
/*
* 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 { CloudbuildApi } from './CloudbuildApi';
import {
ActionsListWorkflowRunsForRepoResponseData,
ActionsGetWorkflowResponseData,
ActionsGetWorkflowRunResponseData,
EndpointInterface,
Build,
} from '../api/types';
export class CloudbuildClient implements CloudbuildApi {
async reRunWorkflow({
token,
projectId,
runId,
}: {
token: string;
projectId: string;
runId: string;
}): Promise<any> {
return await fetch(
`https://cloudbuild.googleapis.com/v1/projects/${projectId}/builds/${runId}:retry`,
{
headers: new Headers({
Accept: '*/*',
Authorization: `Bearer ${token}`,
}),
},
);
}
async listWorkflowRuns({
token,
projectId,
}: {
token: string;
projectId: string;
}): Promise<ActionsListWorkflowRunsForRepoResponseData> {
const workflowRuns = await fetch(
`https://cloudbuild.googleapis.com/v1/projects/${projectId}/builds`,
{
headers: new Headers({
Accept: '*/*',
Authorization: `Bearer ${token}`,
}),
},
);
const builds: Build[] = await workflowRuns.json();
const response: ActionsListWorkflowRunsForRepoResponseData = {
total_count: builds.length,
workflow_runs: builds,
};
return response;
}
async getWorkflow({
token,
projectId,
id,
}: {
token: string;
projectId: string;
id: string;
}): Promise<ActionsGetWorkflowResponseData> {
const workflow = await fetch(
`https://cloudbuild.googleapis.com/v1/projects/${projectId}/builds/${id}`,
{
headers: new Headers({
Accept: '*/*',
Authorization: `Bearer ${token}`,
}),
},
);
const build: ActionsGetWorkflowResponseData = await workflow.json();
return build;
}
async getWorkflowRun({
token,
projectId,
id,
}: {
token: string;
projectId: string;
id: string;
}): Promise<ActionsGetWorkflowRunResponseData> {
const workflow = await fetch(
`https://cloudbuild.googleapis.com/v1/projects/${projectId}/builds/${id}`,
{
headers: new Headers({
Accept: '*/*',
Authorization: `Bearer ${token}`,
}),
},
);
const build: ActionsGetWorkflowResponseData = await workflow.json();
return build;
}
async downloadJobLogsForWorkflowRun({}: // token,
// projectId,
// runId,
{
// token: string;
// projectId: string;
// runId: string;
}): Promise<EndpointInterface> {
// console.log("Token: ",token," projectId: ", projectId," runId: ",runId)
// const workflow = await new Octokit({
// auth: token,
// }).actions.downloadJobLogsForWorkflowRun({
// owner,
// repo,
// job_id: runId,
// });
return [];
}
}
+19
View File
@@ -0,0 +1,19 @@
/*
* 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 './CloudbuildApi';
export * from './CloudbuildClient';
export * from './types';
+223
View File
@@ -0,0 +1,223 @@
/*
* 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 type Step = {
// name: string;
// status: string;
// conclusion: string;
// number: number; // starts from 1
// started_at: string;
// completed_at: string;
// };
// export type Job = {
// html_url: string;
// status: string;
// conclusion: string;
// started_at: string;
// completed_at: string;
// id: string;
// name: string;
// steps: Step[];
// };
export interface Build {
id: string;
status: string;
source: Source;
createTime: string;
startTime: string;
steps: Step[];
timeout: string;
projectId: string;
logsBucket: string;
sourceProvenance: SourceProvenance;
buildTriggerId: string;
options: Options;
logUrl: string;
substitutions: Substitutions;
tags: string[];
queueTtl: string;
name: string;
finishTime: any;
results: Results;
timing: Timing2;
}
export type Jobs = {
total_count: number;
jobs: Build[];
};
// export enum BuildStatus {
// 'success',
// 'failure',
// 'pending',
// 'running',
// }
export interface ActionsListWorkflowRunsForRepoResponseData {
total_count: number;
workflow_runs: {
id: string;
status: string;
source: Source;
createTime: string;
startTime: string;
steps: Step[];
timeout: string;
projectId: string;
logsBucket: string;
sourceProvenance: SourceProvenance;
buildTriggerId: string;
options: Options;
logUrl: string;
substitutions: Substitutions;
tags: string[];
queueTtl: string;
name: string;
finishTime: any;
results: Results;
timing: Timing2;
}[];
}
export type ActionsGetWorkflowResponseData = {
id: string;
status: string;
source: Source;
createTime: string;
startTime: string;
steps: Step[];
timeout: string;
projectId: string;
logsBucket: string;
sourceProvenance: SourceProvenance;
buildTriggerId: string;
options: Options;
logUrl: string;
substitutions: Substitutions;
tags: string[];
queueTtl: string;
name: string;
finishTime: any;
results: Results;
timing: Timing2;
};
export type ActionsGetWorkflowRunResponseData = {
id: string;
status: string;
source: Source;
createTime: string;
startTime: string;
steps: Step[];
timeout: string;
projectId: string;
logsBucket: string;
sourceProvenance: SourceProvenance;
buildTriggerId: string;
options: Options;
logUrl: string;
substitutions: Substitutions;
tags: string[];
queueTtl: string;
name: string;
finishTime: any;
results: Results;
timing: Timing2;
};
export type EndpointInterface = {};
export interface Step {
name: string;
args: string[];
id: string;
waitFor: string[];
entrypoint: string;
volumes: Volume[];
dir: string;
timing: Timing;
status: string;
pullTiming: PullTiming;
}
export interface Timing2 {
BUILD: BUILD;
FETCHSOURCE: FETCHSOURCE;
}
export interface SourceProvenance {
resolvedStorageSource: {};
fileHashes: {};
}
export interface Options {
machineType: string;
substitutionOption: string;
logging: string;
dynamicSubstitutions: boolean;
}
export interface Substitutions {
COMMIT_SHA: string;
SHORT_SHA: string;
BRANCH_NAME: string;
REPO_NAME: string;
REVISION_ID: string;
}
export interface Results {
buildStepImages: string[];
buildStepOutputs: string[];
}
export interface BUILD {
startTime: string;
endTime: string;
}
export interface FETCHSOURCE {
startTime: string;
endTime: string;
}
export interface StorageSource {
bucket: string;
object: string;
}
export interface Source {
storageSource: StorageSource;
}
export interface Volume {
name: string;
path: string;
}
export interface Timing {
startTime: string;
endTime: string;
}
export interface PullTiming {
startTime: string;
endTime: string;
}
export interface ResolvedStorageSource {
bucket: string;
object: string;
generation: string;
}
@@ -0,0 +1,120 @@
/*
* 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, { useEffect } from 'react';
import { useWorkflowRuns } from '../useWorkflowRuns';
import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable';
import { Entity } from '@backstage/catalog-model';
import { WorkflowRunStatus } from '../WorkflowRunStatus';
import {
Link,
Theme,
makeStyles,
LinearProgress,
Typography,
} from '@material-ui/core';
import {
InfoCard,
StructuredMetadataTable,
errorApiRef,
useApi,
} from '@backstage/core';
import ExternalLinkIcon from '@material-ui/icons/Launch';
import { CLOUDBUILD_ANNOTATION } from '../useProjectName';
const useStyles = makeStyles<Theme>({
externalLinkIcon: {
fontSize: 'inherit',
verticalAlign: 'bottom',
},
});
const WidgetContent = ({
error,
loading,
lastRun,
branch,
}: {
error?: Error;
loading?: boolean;
lastRun: WorkflowRun;
branch: string;
}) => {
const classes = useStyles();
if (error) return <Typography>Couldn't fetch latest {branch} run</Typography>;
if (loading) return <LinearProgress />;
return (
<StructuredMetadataTable
metadata={{
status: (
<>
<WorkflowRunStatus status={lastRun.status} />
</>
),
message: lastRun.message,
url: (
<Link href={lastRun.googleUrl} target="_blank">
See more on Google{' '}
<ExternalLinkIcon className={classes.externalLinkIcon} />
</Link>
),
}}
/>
);
};
export const LatestWorkflowRunCard = ({
entity,
branch = 'master',
}: {
entity: Entity;
branch: string;
}) => {
const errorApi = useApi(errorApiRef);
const projectId = entity?.metadata.annotations?.[CLOUDBUILD_ANNOTATION] || '';
const [{ runs, loading, error }] = useWorkflowRuns({
projectId,
});
const lastRun = runs?.[0] ?? ({} as WorkflowRun);
useEffect(() => {
if (error) {
errorApi.post(error);
}
}, [error, errorApi]);
return (
<InfoCard title={`Last ${branch} build`}>
<WidgetContent
error={error}
loading={loading}
branch={branch}
lastRun={lastRun}
/>
</InfoCard>
);
};
export const LatestWorkflowsForBranchCard = ({
entity,
branch = 'master',
}: {
entity: Entity;
branch: string;
}) => (
<InfoCard title={`Last ${branch} build`}>
<WorkflowRunsTable entity={entity} />
</InfoCard>
);
@@ -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 { LatestWorkflowRunCard, LatestWorkflowsForBranchCard } from './Cards';
@@ -0,0 +1,47 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { Routes, Route } from 'react-router';
import { rootRouteRef, buildRouteRef } from '../plugin';
import { WorkflowRunDetails } from './WorkflowRunDetails';
import { WorkflowRunsTable } from './WorkflowRunsTable';
import { CLOUDBUILD_ANNOTATION } from './useProjectName';
import { WarningPanel } from '@backstage/core';
export const isPluginApplicableToEntity = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[CLOUDBUILD_ANNOTATION]) &&
entity.metadata.annotations?.[CLOUDBUILD_ANNOTATION] !== '';
export const Router = ({ entity }: { entity: Entity }) =>
// TODO(shmidt-i): move warning to a separate standardized component
!isPluginApplicableToEntity(entity) ? (
<WarningPanel title=" Cloudbuild plugin:">
<pre>{CLOUDBUILD_ANNOTATION}</pre> annotation is missing on the entity.
</WarningPanel>
) : (
<Routes>
<Route
path={`/${rootRouteRef.path}`}
element={<WorkflowRunsTable entity={entity} />}
/>
<Route
path={`/${buildRouteRef.path}`}
element={<WorkflowRunDetails entity={entity} />}
/>
)
</Routes>
);
@@ -0,0 +1,234 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { Link } from '@backstage/core';
import {
Breadcrumbs,
LinearProgress,
Link as MaterialLink,
makeStyles,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
Theme,
Typography,
} from '@material-ui/core';
import ExternalLinkIcon from '@material-ui/icons/Launch';
import React from 'react';
import { useProjectName } from '../useProjectName';
import { WorkflowRunStatus } from '../WorkflowRunStatus';
import { useWorkflowRunsDetails } from './useWorkflowRunsDetails';
const useStyles = makeStyles<Theme>(theme => ({
root: {
maxWidth: 720,
margin: theme.spacing(2),
},
title: {
padding: theme.spacing(1, 0, 2, 0),
},
table: {
padding: theme.spacing(1),
},
accordionDetails: {
padding: 0,
},
button: {
order: -1,
marginRight: 0,
marginLeft: '-20px',
},
externalLinkIcon: {
fontSize: 'inherit',
verticalAlign: 'bottom',
},
}));
// const JobsList = ({ jobs, entity }: { jobs?: Jobs; entity: Entity }) => {
// const classes = useStyles();
// return (
// <Box>
// {jobs &&
// jobs.total_count > 0 &&
// jobs.jobs.map((job: Build) => (
// <JobListItem
// job={job}
// className={
// job.status !== 'success' ? classes.failed : classes.success
// }
// entity={entity}
// />
// ))}
// </Box>
// );
// };
// const getElapsedTime = (start: string, end: string) => {
// const diff = moment(moment(end || moment()).diff(moment(start)));
// const timeElapsed = diff.format('m [minutes] s [seconds]');
// return timeElapsed;
// };
// const StepView = ({ step }: { step: Step }) => {
// return (
// <TableRow>
// <TableCell>
// <ListItemText
// primary={step.name}
// secondary={getElapsedTime(step.pullTiming.startTime, step.pullTiming.endTime)}
// />
// </TableCell>
// <TableCell>
// <WorkflowRunStatus status={step.status.toUpperCase()} />
// </TableCell>
// </TableRow>
// );
// };
// const JobListItem = ({
// job,
// className,
// entity,
// }: {
// job: Build;
// className: string;
// entity: Entity;
// }) => {
// const classes = useStyles();
// return (
// <Accordion TransitionProps={{ unmountOnExit: true }} className={className}>
// <AccordionSummary
// expandIcon={<ExpandMoreIcon />}
// aria-controls={`panel-${name}-content`}
// id={`panel-${name}-header`}
// IconButtonProps={{
// className: classes.button,
// }}
// >
// <Typography variant="button">
// {job.name} ({getElapsedTime(job.startTime, job.finishTime)})
// </Typography>
// </AccordionSummary>
// <AccordionDetails className={classes.accordionDetails}>
// <TableContainer>
// <Table>
// {job.steps.map((step: Step) => (
// <StepView step={step} />
// ))}
// </Table>
// </TableContainer>
// </AccordionDetails>
// {job.status === 'QUEUED' || job.status === 'WORKING' ? (
// <WorkflowRunLogs runId={job.id} inProgress entity={entity} />
// ) : (
// <WorkflowRunLogs runId={job.id} inProgress={false} entity={entity} />
// )}
// </Accordion>
// );
// };
export const WorkflowRunDetails = ({ entity }: { entity: Entity }) => {
// const projectName = useProjectName(entity);
const { value: projectName, loading, error } = useProjectName(entity);
const [projectId] = (projectName ?? '/').split('/');
// const [projectId] = projectName.value ? projectName.value : [];
const details = useWorkflowRunsDetails(projectId);
// const steps = useWorkflowRunJobs(projectId)
const classes = useStyles();
if (error) {
return (
<Typography variant="h6" color="error">
Failed to load build, {error.message}
</Typography>
);
} else if (loading) {
return <LinearProgress />;
}
return (
<div className={classes.root}>
<Breadcrumbs aria-label="breadcrumb">
<Link to="..">Workflow runs</Link>
<Typography>Workflow run details</Typography>
</Breadcrumbs>
<TableContainer component={Paper} className={classes.table}>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Typography noWrap>Branch</Typography>
</TableCell>
<TableCell>{details.value?.substitutions.BRANCH_NAME}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Message</Typography>
</TableCell>
<TableCell>{details.value?.substitutions.REPO_NAME}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Commit ID</Typography>
</TableCell>
<TableCell>{details.value?.substitutions.COMMIT_SHA}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Status</Typography>
</TableCell>
<TableCell>
<WorkflowRunStatus status={details.value?.status} />
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Author</Typography>
</TableCell>
<TableCell>{`${details.value?.name}`}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Links</Typography>
</TableCell>
<TableCell>
{details.value?.logUrl && (
<MaterialLink target="_blank" href={details.value.logUrl}>
Workflow runs on Google{' '}
<ExternalLinkIcon className={classes.externalLinkIcon} />
</MaterialLink>
)}
</TableCell>
</TableRow>
{/* <TableRow>
<TableCell colSpan={2}>
<Typography noWrap>Jobs</Typography>
{loading ? (
<CircularProgress />
) : (
<JobsList jobs={steps.value} entity={entity} />
)}
</TableCell>
</TableRow> */}
</TableBody>
</Table>
</TableContainer>
</div>
);
};
@@ -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 { WorkflowRunDetails } from './WorkflowRunDetails';
@@ -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 { useAsync } from 'react-use';
import { Jobs } from '../../api/types';
export const useWorkflowRunJobs = (jobsUrl?: string) => {
const jobs = useAsync(async (): Promise<Jobs> => {
if (jobsUrl === undefined) {
return {
total_count: 0,
jobs: [],
};
}
const data = await fetch(jobsUrl).then(d => d.json());
return data;
}, [jobsUrl]);
return jobs;
};
@@ -0,0 +1,38 @@
/*
* 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 { useApi, googleAuthApiRef } from '@backstage/core';
import { useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { cloudbuildApiRef } from '../../api';
export const useWorkflowRunsDetails = (projectId: string) => {
const api = useApi(cloudbuildApiRef);
const auth = useApi(googleAuthApiRef);
const { id } = useParams();
const details = useAsync(async () => {
const token = await auth.getAccessToken([
'https://www.googleapis.com/auth/cloud-platform',
]);
return projectId
? api.getWorkflowRun({
token,
projectId,
id: id,
})
: Promise.reject('No projectId provided');
}, [projectId, id]);
return details;
};
@@ -0,0 +1,169 @@
/*
* 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 {
Accordion,
AccordionSummary,
CircularProgress,
Fade,
LinearProgress,
makeStyles,
Modal,
Theme,
Tooltip,
Typography,
Zoom,
} from '@material-ui/core';
import React, { Suspense } from 'react';
import { useDownloadWorkflowRunLogs } from './useDownloadWorkflowRunLogs';
import LinePart from 'react-lazylog/build/LinePart';
import { useProjectName } from '../useProjectName';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import DescriptionIcon from '@material-ui/icons/Description';
import { Entity } from '@backstage/catalog-model';
const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog'));
const useStyles = makeStyles<Theme>(() => ({
button: {
order: -1,
marginRight: 0,
marginLeft: '-20px',
},
modal: {
display: 'flex',
alignItems: 'center',
width: '85%',
height: '85%',
justifyContent: 'center',
margin: 'auto',
},
normalLog: {
height: '75vh',
width: '100%',
},
modalLog: {
height: '100%',
width: '100%',
},
}));
const DisplayLog = ({
jobLogs,
className,
}: {
jobLogs: any;
className: string;
}) => {
return (
<Suspense fallback={<LinearProgress />}>
<div className={className}>
<LazyLog
text={jobLogs ?? 'No Values Found'}
extraLines={1}
caseInsensitive
enableSearch
formatPart={line => {
if (
line.toLocaleLowerCase().includes('error') ||
line.toLocaleLowerCase().includes('failed') ||
line.toLocaleLowerCase().includes('failure')
) {
return (
<LinePart style={{ color: 'red' }} part={{ text: line }} />
);
}
return line;
}}
/>
</div>
</Suspense>
);
};
/**
* A component for Run Logs visualization.
*/
export const WorkflowRunLogs = ({
entity,
runId,
inProgress,
}: {
entity: Entity;
runId: string;
inProgress: boolean;
}) => {
const classes = useStyles();
const projectName = useProjectName(entity);
const repo = projectName.value || '';
const jobLogs = useDownloadWorkflowRunLogs(repo, runId);
const [open, setOpen] = React.useState(false);
const handleOpen = () => {
setOpen(true);
};
const handleClose = () => {
setOpen(false);
};
return (
<Accordion TransitionProps={{ unmountOnExit: true }} disabled={inProgress}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={`panel-${name}-content`}
id={`panel-${name}-header`}
IconButtonProps={{
className: classes.button,
}}
>
<Typography variant="button">
{jobLogs.loading ? <CircularProgress /> : 'Job Log'}
</Typography>
<Tooltip title="Open Log" TransitionComponent={Zoom} arrow>
<DescriptionIcon
onClick={event => {
event.stopPropagation();
handleOpen();
}}
style={{ marginLeft: 'auto' }}
/>
</Tooltip>
<Modal
className={classes.modal}
onClick={event => event.stopPropagation()}
open={open}
onClose={handleClose}
>
<Fade in={open}>
<DisplayLog
jobLogs={jobLogs.value || undefined}
className={classes.modalLog}
/>
</Fade>
</Modal>
</AccordionSummary>
{jobLogs.value && (
<DisplayLog
jobLogs={jobLogs.value || undefined}
className={classes.normalLog}
/>
)}
</Accordion>
);
};
@@ -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 { WorkflowRunLogs } from './WorkflowRunLogs';
@@ -0,0 +1,38 @@
/*
* 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 { useApi, googleAuthApiRef } from '@backstage/core';
import { useAsync } from 'react-use';
import { cloudbuildApiRef } from '../../api';
export const useDownloadWorkflowRunLogs = (projectId: string, id: string) => {
const api = useApi(cloudbuildApiRef);
const auth = useApi(googleAuthApiRef);
const details = useAsync(async () => {
const token = await auth.getAccessToken([
'https://www.googleapis.com/auth/cloud-platform',
]);
const runId = id;
return projectId
? api.downloadJobLogsForWorkflowRun({
token,
projectId,
runId,
})
: Promise.reject('No repo/owner provided');
}, [projectId, id]);
return details;
};
@@ -0,0 +1,70 @@
/*
* 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 {
StatusPending,
StatusRunning,
StatusOK,
StatusAborted,
StatusError,
} from '@backstage/core';
import React from 'react';
export const WorkflowRunStatus = ({
status,
}: {
status: string | undefined;
}) => {
if (status === undefined) return null;
switch (status.toLowerCase()) {
case 'queued':
return (
<>
<StatusPending /> Queued
</>
);
case 'working':
return (
<>
<StatusRunning /> In progress
</>
);
case 'success':
return (
<>
<StatusOK /> Completed
</>
);
case 'cancelled':
return (
<>
<StatusAborted /> Cancelled
</>
);
case 'failure':
return (
<>
<StatusError /> Failed
</>
);
default:
return (
<>
<StatusPending /> Pending
</>
);
}
};
@@ -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 { WorkflowRunStatus } from './WorkflowRunStatus';
@@ -0,0 +1,190 @@
/*
* 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 { Link, Typography, Box, IconButton, Tooltip } from '@material-ui/core';
import RetryIcon from '@material-ui/icons/Replay';
import GoogleIcon from '@material-ui/icons/CloudCircle';
import { Link as RouterLink, generatePath } from 'react-router-dom';
import { Table, TableColumn } from '@backstage/core';
import { useWorkflowRuns } from '../useWorkflowRuns';
import { WorkflowRunStatus } from '../WorkflowRunStatus';
import SyncIcon from '@material-ui/icons/Sync';
import { useProjectName } from '../useProjectName';
import { Entity } from '@backstage/catalog-model';
import { Substitutions } from '../../api/types';
import { buildRouteRef } from '../../plugin';
export type WorkflowRun = {
id: string;
message: string;
url?: string;
googleUrl?: string;
status: string;
substitutions: Substitutions;
createTime: string;
org: string;
onReRunClick: () => void;
};
const generatedColumns: TableColumn[] = [
{
title: 'Status',
width: '150px',
render: (row: Partial<WorkflowRun>) => (
<Box display="flex" alignItems="center">
<WorkflowRunStatus status={row.status} />
</Box>
),
},
{
title: 'Build',
field: 'id',
type: 'numeric',
width: '150px',
render: (row: Partial<WorkflowRun>) => (
<Typography variant="body2" noWrap>
<p>{row.id?.substring(0, 8)}</p>
{/* <p>{row.substitutions?.COMMIT_SHA}</p> */}
</Typography>
),
},
{
title: 'Source',
field: 'source',
highlight: true,
width: '200px',
render: (row: Partial<WorkflowRun>) => (
<Link
component={RouterLink}
// to={`https://www.github.com/trivago/${row.message}`}
to={generatePath(buildRouteRef.path, { id: row.id! })}
>
{row.org}/{row.message}
</Link>
),
},
{
title: 'Ref',
render: (row: Partial<WorkflowRun>) => (
<Typography variant="body2" noWrap>
<p>{row.substitutions?.BRANCH_NAME}</p>
</Typography>
),
},
{
title: 'Commit',
render: (row: Partial<WorkflowRun>) => (
<Typography variant="body2" noWrap>
<p>{row.substitutions?.SHORT_SHA}</p>
</Typography>
),
},
{
title: 'Created',
render: (row: Partial<WorkflowRun>) => (
<Typography variant="body2" noWrap>
<p>
{row.createTime?.substring(6, 7)}/{row.createTime?.substring(8, 10)}/
{row.createTime?.substring(0, 4)}, {row.createTime?.substring(11, 16)}
</p>
</Typography>
),
},
{
title: 'Actions',
render: (row: Partial<WorkflowRun>) => (
<Tooltip title="Rerun workflow">
<IconButton onClick={row.onReRunClick}>
<RetryIcon />
</IconButton>
</Tooltip>
),
width: '10%',
},
];
type Props = {
loading: boolean;
retry: () => void;
runs?: WorkflowRun[];
projectName: string;
page: number;
onChangePage: (page: number) => void;
total: number;
pageSize: number;
onChangePageSize: (pageSize: number) => void;
};
export const WorkflowRunsTableView: FC<Props> = ({
projectName,
loading,
pageSize,
page,
retry,
runs,
onChangePage,
onChangePageSize,
total,
}) => {
return (
<Table
isLoading={loading}
options={{ paging: true, pageSize, padding: 'dense' }}
totalCount={total}
page={page}
actions={[
{
icon: () => <SyncIcon />,
tooltip: 'Reload workflow runs',
isFreeAction: true,
onClick: () => retry(),
},
]}
data={runs ?? []}
onChangePage={onChangePage}
onChangeRowsPerPage={onChangePageSize}
style={{ width: '100%' }}
title={
<Box display="flex" alignItems="center">
<GoogleIcon />
<Box mr={1} />
<Typography variant="h6">{projectName}</Typography>
</Box>
}
columns={generatedColumns}
/>
);
};
export const WorkflowRunsTable = ({ entity }: { entity: Entity }) => {
const { value: projectName, loading } = useProjectName(entity);
const [projectId] = (projectName ?? '/').split('/');
const [tableProps, { retry, setPage, setPageSize }] = useWorkflowRuns({
projectId,
});
return (
<WorkflowRunsTableView
{...tableProps}
loading={loading || tableProps.loading}
retry={retry}
onChangePageSize={setPageSize}
onChangePage={setPage}
/>
);
};
@@ -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 { WorkflowRunsTable, WorkflowRunsTableView } from './WorkflowRunsTable';
export type { WorkflowRun } from './WorkflowRunsTable';
@@ -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 { useAsync } from 'react-use';
import { Entity } from '@backstage/catalog-model';
export const CLOUDBUILD_ANNOTATION = 'google.com/cloudbuild';
export const useProjectName = (entity: Entity) => {
const { value, loading, error } = useAsync(async () => {
return entity?.metadata.annotations?.[CLOUDBUILD_ANNOTATION] ?? '';
});
return { value, loading, error };
};
@@ -0,0 +1,98 @@
/*
* 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 { useState } from 'react';
import { useAsyncRetry } from 'react-use';
import { WorkflowRun } from './WorkflowRunsTable/WorkflowRunsTable';
import { cloudbuildApiRef } from '../api/CloudbuildApi';
import { useApi, googleAuthApiRef, errorApiRef } from '@backstage/core';
import { ActionsListWorkflowRunsForRepoResponseData } from '../api/types';
export function useWorkflowRuns({ projectId }: { projectId: string }) {
const api = useApi(cloudbuildApiRef);
const auth = useApi(googleAuthApiRef);
const errorApi = useApi(errorApiRef);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(5);
const { loading, value: runs, retry, error } = useAsyncRetry<
WorkflowRun[]
>(async () => {
const token = await auth.getAccessToken([
'https://www.googleapis.com/auth/cloud-platform',
]);
return api
.listWorkflowRuns({
token,
projectId,
})
.then(
(
workflowRunsData: ActionsListWorkflowRunsForRepoResponseData,
): WorkflowRun[] => {
setTotal(workflowRunsData.total_count);
// Transformation here
return workflowRunsData.workflow_runs.builds.map(run => ({
message: run.substitutions.REPO_NAME,
id: `${run.id}`,
onReRunClick: async () => {
try {
await api.reRunWorkflow({
token,
projectId,
runId: run.id,
});
} catch (e) {
errorApi.post(e);
}
},
substitutions: run.substitutions,
source: {
branchName: run.substitutions.REPO_NAME,
commit: {
hash: run.substitutions.COMMIT_SHA,
url: run.substitutions.REPO_NAME,
},
},
status: run.status,
url: run.logUrl,
googleUrl: run.logUrl,
org: 'trivago',
createTime: run.createTime,
}));
},
);
}, [page, pageSize, projectId]);
return [
{
page,
pageSize,
loading,
runs,
projectName: `${projectId}`,
total,
error,
},
{
runs,
setPage,
setPageSize,
retry,
},
] as const;
}
+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.
*/
export { plugin } from './plugin';
export * from './api';
export { Router, isPluginApplicableToEntity } from './components/Router';
export * from './components/Cards';
export { CLOUDBUILD_ANNOTATION } from './components/useProjectName';
+22
View File
@@ -0,0 +1,22 @@
/*
* 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('cloudbuild', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
+36
View File
@@ -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 {
createPlugin,
createRouteRef,
createApiFactory,
} from '@backstage/core';
import { cloudbuildApiRef, CloudbuildClient } from './api';
export const rootRouteRef = createRouteRef({
path: '',
title: 'Google Cloudbuild',
});
export const buildRouteRef = createRouteRef({
path: ':id',
title: 'Cloudbuild Run',
});
export const plugin = createPlugin({
id: 'cloudbuild',
apis: [createApiFactory(cloudbuildApiRef, new CloudbuildClient())],
});
+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();