Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(quay): add quay plugin #68

Merged
merged 6 commits into from
Feb 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ proxy:
'/test':
target: 'https://example.com'
changeOrigin: true
'/quay/api':
target: 'https://quay.io'
headers:
X-Requested-With: 'XMLHttpRequest'
# Uncomment the following line to access a private Quay Repository using a token
# Authorization: 'Bearer <YOUR TOKEN>'
changeOrigin: true
# Change to "false" in case of using self hosted quay instance with a self-signed certificate
secure: true

# Reference documentation http://backstage.io/docs/features/techdocs/configuration
# Note: After experimenting with basic setup, use CI/CD to generate docs
Expand Down
1 change: 1 addition & 0 deletions plugins/quay/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
64 changes: 64 additions & 0 deletions plugins/quay/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Quay plugin for Backstage

This plugin will show you information about your container images within Quay registry

## Getting started

1. Install the plugin

```bash
yarn workspace app add @fmenesesg/backstage-plugin-quay
```

2. Set the proxy to desired Quay server

```yaml
# app-config.yaml
proxy:
'/quay/api':
target: 'https://quay.io'
changeOrigin: true
headers:
X-Requested-With: 'XMLHttpRequest'
# Uncomment the following line to access a private Quay Repository using a token
# Authorization: 'Bearer <YOUR TOKEN>'
changeOrigin: true
# Change to "false" in case of using self hosted quay instance with a self-signed certificate
secure: true
```

3. Enable additional tab on the entity view page

```ts
// packages/app/src/components/catalog/EntityPage.tsx
import { QuayPage, isQuayAvailable } from '@fmenesesg/backstage-plugin-quay';

const serviceEntityPage = (
<EntityPageLayout>
// ...
<EntityLayout.Route if={isQuayAvailable} path="/quay" title="Quay">
<QuayPage />
</EntityLayout.Route>
</EntityPageLayout>
);
```

4. Annotate your entity with

```yaml
metadata:
annotations:
'quay.io/repository-slug': `<ORGANIZATION>/<REPOSITORY>',
```

## Development

In [Backstage plugin terminology](https://backstage.io/docs/local-dev/cli-build-system#package-roles), this is a `frontend-plugin`. However it requires backend proxy to be available at all times. Development environment therefore requires you to run a backend instance as well. You can start a live dev session from the repository root using following commands concurrently:

```
yarn start-backend
```

```
yarn workspace @janus-idp/backstage-plugin-quay run start
```
10 changes: 10 additions & 0 deletions plugins/quay/config.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export interface Config {
/** Configurations for the Quay plugin */
quay?: {
/**
* The base url of the Quay instance.
* @visibility frontend
*/
proxyPath?: string;
};
}
35 changes: 35 additions & 0 deletions plugins/quay/dev/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { createDevApp } from '@backstage/dev-utils';
import { quayPlugin, QuayPage } from '../src/plugin';

const mockEntity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'backstage',
description: 'backstage.io',
annotations: {
'quay.io/repository-slug': 'operate-first/service-catalog',
},
},
spec: {
lifecycle: 'production',
type: 'service',
owner: 'user:guest',
},
};

createDevApp()
.registerPlugin(quayPlugin)
.addPage({
element: (
<EntityProvider entity={mockEntity}>
<QuayPage />
</EntityProvider>
),
title: 'Root Page',
path: '/quay',
})
.render();
56 changes: 56 additions & 0 deletions plugins/quay/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"name": "@janus-idp/backstage-plugin-quay",
"version": "1.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "frontend-plugin"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"prepare": ""
},
"dependencies": {
"@backstage/catalog-model": "1.1.5",
"@backstage/core-components": "^0.12.1",
"@backstage/core-plugin-api": "^1.2.0",
"@backstage/plugin-catalog-react": "1.2.4",
"@backstage/theme": "^0.2.16",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"react-use": "^17.2.4"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "0.21.1",
"@backstage/core-app-api": "1.2.0",
"@backstage/dev-utils": "1.0.8",
"@backstage/test-utils": "1.2.2",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "13.4.0",
"@testing-library/user-event": "14.4.3",
"@types/node": "18.11.13",
"cross-fetch": "3.1.5",
"msw": "0.49.1"
},
"files": [
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts"
}
124 changes: 124 additions & 0 deletions plugins/quay/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import {
DiscoveryApi,
ConfigApi,
createApiRef,
} from '@backstage/core-plugin-api';
import {
TagsResponse,
LabelsResponse,
ManifestByDigestResponse,
SecurityDetailsResponse,
} from '../types';

const DEFAULT_PROXY_PATH = '/quay/api';

export interface QuayApiV1 {
getTags(
org: string,
repo: string,
page?: number,
limit?: number,
): Promise<TagsResponse>;
getLabels(org: string, repo: string, digest: string): Promise<LabelsResponse>;
getManifestByDigest(
org: string,
repo: string,
digest: string,
): Promise<ManifestByDigestResponse>;
getSecurityDetails(
org: string,
repo: string,
digest: string,
): Promise<SecurityDetailsResponse>;
}

export const quayApiRef = createApiRef<QuayApiV1>({
id: 'plugin.quay.service',
});

export type Options = {
discoveryApi: DiscoveryApi;
configApi: ConfigApi;
};

export class QuayApiClient implements QuayApiV1 {
// @ts-ignore
private readonly discoveryApi: DiscoveryApi;

private readonly configApi: ConfigApi;

constructor(options: Options) {
this.discoveryApi = options.discoveryApi;
this.configApi = options.configApi;
}

private async getBaseUrl() {
const proxyPath =
this.configApi.getOptionalString('quay.proxyPath') || DEFAULT_PROXY_PATH;
return `${await this.discoveryApi.getBaseUrl('proxy')}${proxyPath}`;
}

private async fetcher(url: string) {
const response = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error(
`failed to fetch data, status ${response.status}: ${response.statusText}`,
);
}
return await response.json();
}

private encodeGetParams(params: Record<string, any>) {
return Object.keys(params)
.filter(key => params[key] !== undefined)
.map(
k =>
`${encodeURIComponent(k)}=${encodeURIComponent(params[k] as string)}`,
)
.join('&');
}

async getTags(org: string, repo: string, page?: number, limit?: number) {
const proxyUrl = await this.getBaseUrl();

const params = this.encodeGetParams({
limit,
page,
onlyActiveTags: true,
});

return (await this.fetcher(
`${proxyUrl}/api/v1/repository/${org}/${repo}/tag/?${params}`,
)) as TagsResponse;
}

async getLabels(org: string, repo: string, digest: string) {
const proxyUrl = await this.getBaseUrl();

return (await this.fetcher(
`${proxyUrl}/api/v1/repository/${org}/${repo}/manifest/${digest}/labels`,
)) as LabelsResponse;
}

async getManifestByDigest(org: string, repo: string, digest: string) {
const proxyUrl = await this.getBaseUrl();

return (await this.fetcher(
`${proxyUrl}/api/v1/repository/${org}/${repo}/manifest/${digest}`,
)) as ManifestByDigestResponse;
}

async getSecurityDetails(org: string, repo: string, digest: string) {
const proxyUrl = await this.getBaseUrl();

const params = this.encodeGetParams({
vulnerabilities: true,
});

return (await this.fetcher(
`${proxyUrl}/api/v1/repository/${org}/${repo}/manifest/${digest}/security?${params}`,
)) as SecurityDetailsResponse;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useEntity } from '@backstage/plugin-catalog-react';
import React from 'react';
import { QuayRepository } from '../QuayRepository';
import { useQuayAppData } from '../useQuayAppData';

export const QuayDashboardPage = () => {
const { entity } = useEntity();
const { repositorySlug } = useQuayAppData({ entity });
const info = repositorySlug.split('/');

const organization = info.shift() as 'string';
const repository = info.join('/');

return (
<QuayRepository
organization={organization}
repository={repository}
widget={false}
/>
);
};
1 change: 1 addition & 0 deletions plugins/quay/src/components/QuayDashboardPage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { QuayDashboardPage } from './QuayDashboardPage';
58 changes: 58 additions & 0 deletions plugins/quay/src/components/QuayRepository/QuayRepository.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useApi } from '@backstage/core-plugin-api';
import React, { useState } from 'react';
import { useAsync } from 'react-use';
import { Link, Progress, Table } from '@backstage/core-components';
import { columns, useStyles } from './tableHeading';
import { Tag } from '../../types';
import { quayApiRef } from '../../api';

export function QuayRepository(props: RepositoryProps) {
const quayClient = useApi(quayApiRef);
const classes = useStyles();
const [tags, setTags] = useState<Tag[]>([]);
const title = `Quay repository: ${props.organization}/${props.repository}`;

const { loading } = useAsync(async () => {
const tagsResponse = await quayClient.getTags(
props.organization,
props.repository,
);
if (tagsResponse.page === 1) {
setTags(tagsResponse.tags);
} else {
setTags(currentTags => [...currentTags, ...tagsResponse.tags]);
}
return tagsResponse;
});

if (loading) {
return <Progress />;
}

return (
<div style={{ border: '1px solid #ddd' }}>
<Table
title={title}
options={{ paging: true, padding: 'dense' }}
data={tags}
columns={columns}
emptyContent={
<div className={classes.empty}>
No data was added yet,&nbsp;
<Link to="http://backstage.io/">learn how to add data</Link>.
</div>
}
/>
</div>
);
}

QuayRepository.defaultProps = {
title: 'Docker Images',
};
interface RepositoryProps {
widget: boolean;
organization: string;
repository: string;
title: string;
}
1 change: 1 addition & 0 deletions plugins/quay/src/components/QuayRepository/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { QuayRepository } from './QuayRepository';
Loading