9 Commits

Author SHA1 Message Date
63ff8dabc0 test(end-to-end): provide CVE information for the tests to consume (#330)
Signed-off-by: Andrei Aaron <aaaron@luxoft.com>
2023-04-12 14:43:39 +03:00
f9cafd0b90 patch: homepage and header ux updates
Signed-off-by: Raul Kele <raulkeleblk@gmail.com>
2023-04-12 13:38:24 +03:00
089d79087f patch: update placeholder image logic
Signed-off-by: Raul Kele <raulkeleblk@gmail.com>
2023-04-03 11:53:32 +03:00
2f94cc30ae patch: referrers from image query
Signed-off-by: Raul Kele <raulkeleblk@gmail.com>
2023-03-22 14:12:15 +02:00
ddf1d9224b feat: cve list filtering
Signed-off-by: Raul Kele <raulkeleblk@gmail.com>
2023-03-21 12:51:16 +02:00
7471fb58a8 ci(tests): some updates to the scripts to make them work better on macos (#324)
Signed-off-by: Andrei Aaron <aaaron@luxoft.com>
2023-03-20 16:51:50 +02:00
2b3058fb14 ci(tests): add a new workflow for running integration tests against a zot server (#322)
Integration tests will use the latest zot on main
The test data consists of images:
- downloaded from dockerhub
- converted to OCI format
- having all needed annotations
- having a logo as an OCI artifact

Signed-off-by: Andrei Aaron <aaaron@luxoft.com>
2023-03-17 09:54:08 +02:00
ecff33fe01 fix: homepage incorrect data
Signed-off-by: Raul Kele <raulkeleblk@gmail.com>
2023-03-10 12:53:27 +02:00
60ca6d21d5 fix: fixed the bugs present in explore page
Signed-off-by: Raul Kele <raulkeleblk@gmail.com>
2023-03-09 16:54:36 +02:00
30 changed files with 1276 additions and 329 deletions

117
.github/workflows/end-to-end-test.yml vendored Normal file
View File

@ -0,0 +1,117 @@
on:
push:
branches:
- main
pull_request:
branches:
- main
release:
types:
- published
name: end-to-end-test
permissions:
contents: read
jobs:
build-and-test:
name: Test zui/zot integration
env:
CI: ""
REGISTRY_HOST: "localhost"
REGISTRY_PORT: "8080"
runs-on: ubuntu-latest
steps:
- name: Checkout zui repository
uses: actions/checkout@v3
with:
fetch-depth: 2
- name: Set up Node.js 16.x
uses: actions/setup-node@v3
with:
node-version: 16.x
cache: 'npm'
- name: Build zui
run: |
cd $GITHUB_WORKSPACE
make install
make build
- name: Install container image tooling
run: |
cd $GITHUB_WORKSPACE
sudo apt-get update
sudo apt-get install libgpgme-dev libassuan-dev libbtrfs-dev libdevmapper-dev pkg-config rpm snapd jq
git clone https://github.com/containers/skopeo -b v1.9.0 $GITHUB_WORKSPACE/src/github.com/containers/skopeo
cd $GITHUB_WORKSPACE/src/github.com/containers/skopeo && make bin/skopeo
chmod +x bin/skopeo
sudo mv bin/skopeo /usr/local/bin/skopeo
which skopeo
skopeo -v
curl -L https://github.com/regclient/regclient/releases/download/v0.4.7/regctl-linux-amd64 -o regctl
chmod +x regctl
sudo mv regctl /usr/local/bin/regctl
which regctl
regctl version
curl -L https://github.com/sigstore/cosign/releases/download/v1.13.0/cosign-linux-amd64 -o cosign
chmod +x cosign
sudo mv cosign /usr/local/bin/cosign
which cosign
cosign version
pushd $(mktemp -d)
curl -L https://github.com/aquasecurity/trivy/releases/download/v0.38.3/trivy_0.38.3_Linux-64bit.tar.gz -o trivy.tar.gz
tar -xzvf trivy.tar.gz
sudo mv trivy /usr/local/bin/trivy
popd
which trivy
trivy version
cd $GITHUB_WORKSPACE
- name: Install go
uses: actions/setup-go@v3
with:
go-version: 1.19.x
- name: Checkout zot repo
uses: actions/checkout@v3
with:
fetch-depth: 2
repository: project-zot/zot
ref: main
path: zot
- name: Build zot
run: |
cd $GITHUB_WORKSPACE/zot
make binary
ls -l bin/
- name: Bringup zot server
run: |
cd $GITHUB_WORKSPACE/zot
mkdir /tmp/zot
./bin/zot-linux-amd64 serve examples/config-ui.json &
while true; do x=0; curl -f http://$REGISTRY_HOST:$REGISTRY_PORT/v2/ || x=1; if [ $x -eq 0 ]; then break; fi; sleep 1; done
- name: Load image test data from cache into a local folder
id: restore-cache
uses: actions/cache@v3
with:
path: tests/data/images
key: image-config-${{ hashFiles('**/tests/data/config.yaml') }}
restore-keys: |
image-config-
- name: Load image test data into zot server
run: |
cd $GITHUB_WORKSPACE
regctl registry set --tls disabled $REGISTRY_HOST:$REGISTRY_PORT
make test-data REGISTRY_HOST=$REGISTRY_HOST REGISTRY_PORT=$REGISTRY_PORT
- name: Run integration tests
run: |
cd $GITHUB_WORKSPACE
make integration-tests REGISTRY_HOST=$REGISTRY_HOST REGISTRY_PORT=$REGISTRY_PORT

1
.gitignore vendored
View File

@ -8,6 +8,7 @@
# testing
/coverage
/tests/data/
# production
/build

View File

@ -1,3 +1,6 @@
REGISTRY_HOST ?= localhost
REGISTRY_PORT ?= 8080
.PHONY: all
all: install audit build
@ -20,3 +23,15 @@ audit:
.PHONY: run
run:
npm start
.PHONY: test-data
test-data:
./tests/scripts/load_test_data.py \
--registry $(REGISTRY_HOST):$(REGISTRY_PORT) \
--data-dir tests/data \
--config-file tests/data/config.yaml \
--metadata-file tests/data/image_metadata.json
.PHONY: integration-tests
integration-tests: # Triggering the tests TBD
cat tests/data/image_metadata.json | jq

View File

@ -11,6 +11,14 @@ jest.mock(
}
);
jest.mock(
'components/Header/Header',
() =>
function Header() {
return <div />;
}
);
it('renders the explore page component', () => {
render(
<BrowserRouter>

View File

@ -4,6 +4,7 @@ import Home from 'components/Home/Home';
import React from 'react';
import { createSearchParams } from 'react-router-dom';
import { sortByCriteria } from 'utilities/sortCriteria';
import MockThemeProvier from '__mocks__/MockThemeProvider';
// useNavigate mock
const mockedUsedNavigate = jest.fn();
@ -12,9 +13,18 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedUsedNavigate
}));
const HomeWrapper = () => {
return (
<MockThemeProvier>
<Home />
</MockThemeProvier>
);
};
const mockImageList = {
RepoListWithNewestImage: {
Results: [
GlobalSearch: {
Page: { TotalCount: 6, ItemCount: 3 },
Repos: [
{
Name: 'alpine',
Size: '2806985',
@ -65,28 +75,36 @@ const mockImageList = {
Count: 10
}
}
},
}
]
}
};
const mockImageListRecent = {
GlobalSearch: {
Page: { TotalCount: 6, ItemCount: 2 },
Repos: [
{
Name: 'centos',
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
Name: 'alpine',
Size: '2806985',
LastUpdated: '2022-08-09T17:19:53.274069586Z',
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
Description: 'w',
IsSigned: false,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'NONE',
Count: 10
MaxSeverity: 'LOW',
Count: 7
}
}
},
{
Name: 'debian',
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
Name: 'mongo',
Size: '231383863',
LastUpdated: '2022-08-02T01:30:49.193203152Z',
NewestImage: {
Tag: 'latest',
Description: '',
@ -95,25 +113,8 @@ const mockImageList = {
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'MEDIUM',
Count: 10
}
}
},
{
Name: 'mysql',
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'UNKNOWN',
Count: 10
MaxSeverity: 'HIGH',
Count: 2
}
}
}
@ -132,23 +133,26 @@ afterEach(() => {
describe('Home component', () => {
it('fetches image data and renders popular, bookmarks and recently updated', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
render(<Home />);
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } });
render(<HomeWrapper />);
await waitFor(() => expect(screen.getAllByText(/alpine/i)).toHaveLength(2));
await waitFor(() => expect(screen.getAllByText(/mongo/i)).toHaveLength(2));
await waitFor(() => expect(screen.getAllByText(/node/i)).toHaveLength(1));
});
it('renders signature icons', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
render(<Home />);
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } });
render(<HomeWrapper />);
expect(await screen.findAllByTestId('unverified-icon')).toHaveLength(2);
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(3);
});
it('renders vulnerability icons', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
render(<Home />);
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } });
render(<HomeWrapper />);
expect(await screen.findAllByTestId('low-vulnerability-icon')).toHaveLength(2);
expect(await screen.findAllByTestId('high-vulnerability-icon')).toHaveLength(2);
expect(await screen.findAllByTestId('critical-vulnerability-icon')).toHaveLength(1);
@ -157,13 +161,14 @@ describe('Home component', () => {
it("should log an error when data can't be fetched", async () => {
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<Home />);
await waitFor(() => expect(error).toBeCalledTimes(1));
render(<HomeWrapper />);
await waitFor(() => expect(error).toBeCalledTimes(2));
});
it('should redirect to explore page when clicking view all popular', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
render(<Home />);
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } });
render(<HomeWrapper />);
const viewAllButtons = await screen.findAllByText(/view all/i);
expect(viewAllButtons).toHaveLength(2);
fireEvent.click(viewAllButtons[0]);

View File

@ -11,6 +11,14 @@ jest.mock(
}
);
jest.mock(
'components/Header/Header',
() =>
function Header() {
return <div />;
}
);
it('renders the homepage component', () => {
render(
<BrowserRouter>

View File

@ -3,6 +3,7 @@ import React from 'react';
import userEvent from '@testing-library/user-event';
import RepoCard from 'components/Shared/RepoCard';
import { createSearchParams } from 'react-router-dom';
import MockThemeProvier from '__mocks__/MockThemeProvider';
// usenavigate mock
const mockedUsedNavigate = jest.fn();
@ -24,6 +25,23 @@ const mockImage = {
platforms: [{ Os: 'linux', Arch: 'amd64' }]
};
const RepoCardWrapper = (props) => {
const { image } = props;
return (
<MockThemeProvier>
<RepoCard
name={image.name}
version={image.latestVersion}
description={image.description}
vendor={image.vendor}
key={1}
lastUpdated={image.lastUpdated}
platforms={image.platforms}
/>
</MockThemeProvier>
);
};
afterEach(() => {
// restore the spy created with spyOn
jest.restoreAllMocks();
@ -31,16 +49,7 @@ afterEach(() => {
describe('Repo card component', () => {
it('navigates to repo page when clicked', async () => {
render(
<RepoCard
name={mockImage.name}
version={mockImage.latestVersion}
description={mockImage.description}
vendor={mockImage.vendor}
key={1}
lastUpdated={mockImage.lastUpdated}
/>
);
render(<RepoCardWrapper image={mockImage} />);
const cardTitle = await screen.findByText('alpine');
expect(cardTitle).toBeInTheDocument();
userEvent.click(cardTitle);
@ -48,15 +57,7 @@ describe('Repo card component', () => {
});
it('renders placeholders for missing data', async () => {
render(
<RepoCard
name={mockImage.name}
version={mockImage.latestVersion}
description={mockImage.description}
vendor={mockImage.vendor}
key={1}
/>
);
render(<RepoCardWrapper image={{ ...mockImage, lastUpdated: '' }} />);
const cardTitle = await screen.findByText('alpine');
expect(cardTitle).toBeInTheDocument();
userEvent.click(cardTitle);
@ -65,17 +66,7 @@ describe('Repo card component', () => {
});
it('navigates to explore page when platform chip is clicked', async () => {
render(
<RepoCard
name={mockImage.name}
version={mockImage.latestVersion}
description={mockImage.description}
vendor={mockImage.vendor}
key={1}
lastUpdated={mockImage.lastUpdated}
platforms={mockImage.platforms}
/>
);
render(<RepoCardWrapper image={mockImage} />);
const osChip = await screen.findByText(/linux/i);
fireEvent.click(osChip);
expect(mockedUsedNavigate).toHaveBeenCalledWith({

View File

@ -1,47 +1,42 @@
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { api } from 'api';
import ReferredBy from 'components/Tag/Tabs/ReferredBy';
import React from 'react';
const mockReferrersList = {
data: {
Referrers: [
const mockReferrersList = [
{
MediaType: 'application/vnd.oci.artifact.manifest.v1+json',
ArtifactType: 'application/vnd.example.icecream.v1',
Size: 466,
Digest: 'sha256:be7a3d01c35a2cf53c502e9dc50cdf36b15d9361c81c63bf319f1d5cbe44ab7c',
Annotations: [
{
MediaType: 'application/vnd.oci.artifact.manifest.v1+json',
ArtifactType: 'application/vnd.example.icecream.v1',
Size: 466,
Digest: 'sha256:be7a3d01c35a2cf53c502e9dc50cdf36b15d9361c81c63bf319f1d5cbe44ab7c',
Annotations: [
{
Key: 'demo',
Value: 'true'
},
{
Key: 'format',
Value: 'oci'
}
]
Key: 'demo',
Value: 'true'
},
{
MediaType: 'application/vnd.oci.artifact.manifest.v1+json',
ArtifactType: 'application/vnd.example.icecream.v1',
Size: 466,
Digest: 'sha256:d9ad22f41d9cb9797c134401416eee2a70446cee1a8eb76fc6b191f4320dade2',
Annotations: [
{
Key: 'demo',
Value: 'true'
},
{
Key: 'format',
Value: 'oci'
}
]
Key: 'format',
Value: 'oci'
}
]
},
{
MediaType: 'application/vnd.oci.artifact.manifest.v1+json',
ArtifactType: 'application/vnd.example.icecream.v1',
Size: 466,
Digest: 'sha256:d9ad22f41d9cb9797c134401416eee2a70446cee1a8eb76fc6b191f4320dade2',
Annotations: [
{
Key: 'demo',
Value: 'true'
},
{
Key: 'format',
Value: 'oci'
}
]
}
};
];
// useNavigate mock
const mockedUsedNavigate = jest.fn();
@ -57,27 +52,17 @@ afterEach(() => {
describe('Referred by tab', () => {
it('should render referrers if there are any', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: mockReferrersList });
render(<ReferredBy repoName="golang" digest="test" />);
render(<ReferredBy referrers={mockReferrersList} />);
expect(await screen.findAllByText('Media type: application/vnd.oci.artifact.manifest.v1+json')).toHaveLength(2);
});
it("renders no referrers if there aren't any", async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: { Referrers: [] } } });
render(<ReferredBy repoName="golang" digest="test" />);
render(<ReferredBy referrers={[]} />);
expect(await screen.findByText(/Nothing found/i)).toBeInTheDocument();
});
it('should log an error if the request fails', async () => {
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<ReferredBy repoName="golang" digest="test" />);
await waitFor(() => expect(error).toBeCalledTimes(1));
});
it('should display the digest when clicking the dropdowns', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: mockReferrersList });
render(<ReferredBy repoName="golang" digest="test" />);
render(<ReferredBy referrers={mockReferrersList} />);
const firstDigest = (await screen.findAllByText(/digest/i))[0];
expect(firstDigest).toBeInTheDocument();
await userEvent.click(firstDigest);
@ -91,13 +76,11 @@ describe('Referred by tab', () => {
});
it('should display the annotations when clicking the dropdown', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: mockReferrersList });
render(<ReferredBy repoName="golang" digest="test" />);
render(<ReferredBy referrers={mockReferrersList} />);
const firstAnnotations = (await screen.findAllByText(/ANNOTATIONS/i))[0];
expect(firstAnnotations).toBeInTheDocument();
await userEvent.click(firstAnnotations);
expect(await screen.findByText(/demo: true/i)).toBeInTheDocument();
await userEvent.click(firstAnnotations);
expect(await screen.findByText(/demo: true/i)).not.toBeInTheDocument();
});
});

View File

@ -24,6 +24,14 @@ jest.mock(
}
);
jest.mock(
'components/Header/Header',
() =>
function Header() {
return <div />;
}
);
it('renders the tags page component', async () => {
render(
<BrowserRouter>

View File

@ -1,4 +1,5 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { api } from 'api';
import VulnerabilitiesDetails from 'components/Tag/Tabs/VulnerabilitiesDetails';
import React from 'react';
@ -432,6 +433,14 @@ const mockCVEList = {
}
};
const mockCVEListFiltered = {
CVEListForImage: {
Tag: '',
Page: { ItemCount: 20, TotalCount: 20 },
CVEList: mockCVEList.CVEListForImage.CVEList.filter((e) => e.Id.includes('2022'))
}
};
const mockCVEFixed = {
pageOne: {
ImageListWithCVEFixed: {
@ -488,6 +497,16 @@ describe('Vulnerabilties page', () => {
await waitFor(() => expect(screen.getAllByText(/fixed in/i)).toHaveLength(20));
});
it('sends filtered query if user types in the search bar', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } });
render(<StateVulnerabilitiesWrapper />);
const cveSearchInput = screen.getByPlaceholderText(/search for/i);
jest.spyOn(api, 'get').mockRejectedValueOnce({ status: 200, data: { data: mockCVEListFiltered } });
await userEvent.type(cveSearchInput, '2022');
expect((await screen.queryAllByText(/2023/i).length) === 0);
expect((await screen.findAllByText(/2022/i)).length === 6);
});
it('renders no vulnerabilities if there are not any', async () => {
jest.spyOn(api, 'get').mockResolvedValue({
status: 200,
@ -506,7 +525,7 @@ describe('Vulnerabilties page', () => {
await waitFor(() =>
expect(screen.getAllByText(/CPAN 2.28 allows Signature Verification Bypass./i)).toHaveLength(1)
);
fireEvent.click(openText[0]);
await fireEvent.click(openText[0]);
await waitFor(() =>
expect(screen.queryByText(/CPAN 2.28 allows Signature Verification Bypass./i)).not.toBeInTheDocument()
);
@ -533,7 +552,7 @@ describe('Vulnerabilties page', () => {
expect(loadMoreBtn).toBeInTheDocument();
await fireEvent.click(loadMoreBtn);
await waitFor(() => expect(loadMoreBtn).not.toBeInTheDocument());
await expect(await screen.findByText('latest')).toBeInTheDocument();
expect(await screen.findByText('latest')).toBeInTheDocument();
});
it('should handle fixed CVE query errors', async () => {

View File

@ -78,11 +78,16 @@ const endpoints = {
detailedRepoInfo: (name) =>
`/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:"${name}"){Images {Manifests {Digest Platform {Os Arch} Size} Vulnerabilities {MaxSeverity Count} Tag LastUpdated Vendor } Summary {Name LastUpdated Size Platforms {Os Arch} Vendors NewestImage {RepoName IsSigned Vulnerabilities {MaxSeverity Count} Manifests {Digest} Tag Title Documentation DownloadCount Source Description Licenses}}}}`,
detailedImageInfo: (name, tag) =>
`/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned Vulnerabilities {MaxSeverity Count} Tag Manifests {History {Layer {Size Digest} HistoryDescription {CreatedBy EmptyLayer}} Digest ConfigDigest LastUpdated Size Platform {Os Arch}} Vendor Licenses }}`,
vulnerabilitiesForRepo: (name, { pageNumber = 1, pageSize = 15 }) =>
`/v2/_zot/ext/search?query={CVEListForImage(image: "${name}", requestedPage: {limit:${pageSize} offset:${
`/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned Vulnerabilities {MaxSeverity Count} Referrers {MediaType ArtifactType Size Digest Annotations{Key Value}} Tag Manifests {History {Layer {Size Digest} HistoryDescription {CreatedBy EmptyLayer}} Digest ConfigDigest LastUpdated Size Platform {Os Arch}} Vendor Licenses }}`,
vulnerabilitiesForRepo: (name, { pageNumber = 1, pageSize = 15 }, searchTerm = '') => {
let query = `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}", requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize
}}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}}}}`,
}}`;
if (!isEmpty(searchTerm)) {
query += `, searchedCVE: "${searchTerm}"`;
}
return `${query}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}}}}`;
},
imageListWithCVEFixed: (cveId, repoName, { pageNumber = 1, pageSize = 3 }) =>
`/v2/_zot/ext/search?query={ImageListWithCVEFixed(id:"${cveId}", image:"${repoName}", requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -17,7 +17,7 @@ import { host } from '../../host';
import { mapToRepo } from 'utilities/objectModels.js';
import { useSearchParams } from 'react-router-dom';
import FilterCard from '../Shared/FilterCard.jsx';
import { isEmpty } from 'lodash';
import { isEmpty, isNil } from 'lodash';
import filterConstants from 'utilities/filterConstants.js';
import { sortByCriteria } from 'utilities/sortCriteria.js';
import { EXPLORE_PAGE_SIZE } from 'utilities/paginationConstants.js';
@ -60,10 +60,15 @@ const useStyles = makeStyles((theme) => ({
[theme.breakpoints.up('md')]: {
display: 'none'
}
},
filterCardsContainer: {
[theme.breakpoints.down('md')]: {
display: 'none'
}
}
}));
function Explore() {
function Explore({ searchInputValue }) {
const [isLoading, setIsLoading] = useState(true);
const [exploreData, setExploreData] = useState([]);
const [sortFilter, setSortFilter] = useState(sortByCriteria.relevance.value);
@ -119,7 +124,7 @@ function Explore() {
api
.get(
`${host()}${endpoints.globalSearch({
searchQuery: search,
searchQuery: !isNil(searchInputValue) ? searchInputValue : search,
pageNumber,
pageSize: EXPLORE_PAGE_SIZE,
sortBy: sortFilter,
@ -269,7 +274,7 @@ function Explore() {
if (!isLoading && !isEndOfList) {
return <div ref={listBottom} />;
}
return '';
return;
};
return (
@ -311,7 +316,7 @@ function Explore() {
</Grid>
</Grid>
<Grid container item xs={12} spacing={5} pt={1}>
<Grid item xs={3} md={3} className="hide-on-mobile">
<Grid item xs={3} md={3} className={classes.filterCardsContainer}>
<Sticky>{renderFilterCards()}</Sticky>
</Grid>
<Grid item xs={12} md={9}>

View File

@ -19,7 +19,7 @@ const useStyles = makeStyles((theme) => {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
justifyContent: 'flex-start',
padding: '2rem',
[theme.breakpoints.down('md')]: {
padding: '1rem'
@ -27,7 +27,8 @@ const useStyles = makeStyles((theme) => {
},
explore: {
color: '#52637A',
fontSize: '1rem',
fontSize: '0.813rem',
fontWeight: '600',
letterSpacing: '0.009375rem',
[theme.breakpoints.down('md')]: {
fontSize: '0.8rem'
@ -49,7 +50,7 @@ function ExploreHeader() {
return (
<div className={classes.exploreHeader}>
<ArrowBackIcon
sx={{ color: '#14191F', fontSize: { xs: '1.5rem', md: '2rem' }, cursor: 'pointer' }}
sx={{ color: '#52637A', marginRight: '1.75rem', fontSize: { xs: '1.5rem', md: '2rem' }, cursor: 'pointer' }}
onClick={() => navigate(-1)}
/>
<Breadcrumbs separator="/" aria-label="breadcrumb">

View File

@ -3,16 +3,17 @@ import React from 'react';
import { Link, useLocation } from 'react-router-dom';
// components
import { AppBar, Toolbar, Stack, Grid } from '@mui/material';
import { AppBar, Toolbar, Grid } from '@mui/material';
// styling
import makeStyles from '@mui/styles/makeStyles';
import logo from '../../assets/zotLogo.svg';
import logoxs from '../../assets/zotLogoSmall.png';
import logo from '../../assets/zotLogoWhite.svg';
import logoxs from '../../assets/zotLogoWhiteSmall.svg';
import githubLogo from '../../assets/Git.png';
import { useState, useEffect } from 'react';
import SearchSuggestion from './SearchSuggestion';
const useStyles = makeStyles(() => ({
const useStyles = makeStyles((theme) => ({
barOpen: {
position: 'sticky',
minHeight: '10%'
@ -28,7 +29,7 @@ const useStyles = makeStyles(() => ({
alignItems: 'center',
justifyContent: 'center',
padding: 0,
backgroundColor: '#fff',
backgroundColor: '#0F2139',
height: '100%',
width: '100%',
borderBottom: '0.0625rem solid #BDBDBD',
@ -58,20 +59,41 @@ const useStyles = makeStyles(() => ({
logoWrapper: {},
logo: {
maxWidth: '130px',
maxHeight: '50px'
maxHeight: '30px'
},
userAvatar: {
height: 46,
width: 46
headerLinkContainer: {
[theme.breakpoints.down('md')]: {
display: 'none'
}
},
link: {
color: '#000'
color: '#F6F7F9',
fontSize: '1rem',
fontWeight: 600
},
grid: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
height: '2.875rem',
[theme.breakpoints.down('md')]: {
justifyContent: 'space-between'
}
},
gridItem: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
},
signInBtn: {
border: '1px solid #F6F7F9',
borderRadius: '0.625rem',
backgroundColor: 'transparent',
color: '#F6F7F9',
fontSize: '1rem',
textTransform: 'none',
fontWeight: 600
}
}));
@ -103,32 +125,51 @@ function setNavShow() {
return show;
}
function Header() {
function Header({ setSearchCurrentValue = () => {} }) {
const show = setNavShow();
const classes = useStyles();
const path = useLocation().pathname;
return (
<AppBar position={show ? 'fixed' : 'absolute'} sx={{ height: '10vh' }}>
<AppBar position={show ? 'fixed' : 'absolute'} sx={{ height: '5rem' }}>
<Toolbar className={classes.header}>
<Stack direction="row" alignItems="center" justifyContent="space-between" className={classes.headerContainer}>
<Grid container className={classes.grid}>
<Grid item xs={2} sx={{ display: 'flex', justifyContent: 'start' }}>
<Link to="/home" className={classes.grid}>
<Grid container className={classes.grid}>
<Grid item container xs={3} md={4} spacing="1.5rem" className={classes.gridItem}>
<Grid item>
<Link to="/home">
<picture>
<source media="(min-width:600px)" srcSet={logo} />
<img alt="zot" src={logoxs} className={classes.logo} />
</picture>
</Link>
</Grid>
<Grid item xs={8}>
{path !== '/' && <SearchSuggestion />}
<Grid item className={classes.headerLinkContainer}>
<a className={classes.link} href="https://zotregistry.io" target="_blank" rel="noreferrer">
Product
</a>
</Grid>
<Grid item md={2} xs={0}>
<div>{''}</div>
<Grid item className={classes.headerLinkContainer}>
<a
className={classes.link}
href="https://zotregistry.io/v1.4.3/general/concepts/"
target="_blank"
rel="noreferrer"
>
Docs
</a>
</Grid>
</Grid>
</Stack>
<Grid item xs={6} md={4} className={classes.gridItem}>
{path !== '/' && <SearchSuggestion setSearchCurrentValue={setSearchCurrentValue} />}
</Grid>
<Grid item container xs={2} md={3} spacing="1.5rem" className={`${classes.gridItem}`}>
<Grid item className={classes.headerLinkContainer}>
<a className={classes.link} href="https://github.com/project-zot/zot" target="_blank" rel="noreferrer">
<img alt="github repository" src={githubLogo} className={classes.logo} />
</a>
</Grid>
</Grid>
</Grid>
</Toolbar>
</AppBar>
);

View File

@ -6,7 +6,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { api, endpoints } from 'api';
import { host } from 'host';
import { mapToImage, mapToRepo } from 'utilities/objectModels';
import { createSearchParams, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { createSearchParams, useNavigate, useSearchParams } from 'react-router-dom';
import { debounce, isEmpty } from 'lodash';
import { useCombobox } from 'downshift';
import { HEADER_SEARCH_PAGE_SIZE } from 'utilities/paginationConstants';
@ -14,31 +14,30 @@ import { HEADER_SEARCH_PAGE_SIZE } from 'utilities/paginationConstants';
const useStyles = makeStyles(() => ({
searchContainer: {
display: 'inline-block',
backgroundColor: '#FFFFFF',
boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
borderRadius: '2.5rem',
minWidth: '60%',
marginLeft: 16,
backgroundColor: '#2B3A4E',
boxShadow: '0 0.313rem 0.625rem rgba(131, 131, 131, 0.08)',
borderRadius: '0.625rem',
minWidth: '100%',
position: 'relative',
zIndex: 1150
},
searchContainerFocused: {
backgroundColor: '#FFFFFF'
},
search: {
position: 'relative',
minWidth: '100%',
flexDirection: 'row',
boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
border: '0.125rem solid #E7E7E7',
borderRadius: '2.5rem',
border: '0.063rem solid #8A96A8',
borderRadius: '0.625rem',
zIndex: 1155
},
searchFocused: {
border: '0.125rem solid #E0E5EB',
backgroundColor: '#FFFFF'
},
searchFailed: {
position: 'relative',
minWidth: '100%',
flexDirection: 'row',
boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
border: '0.125rem solid #ff0303',
borderRadius: '2.5rem',
zIndex: 1155
border: '0.125rem solid #ff0303'
},
resultsWrapper: {
margin: '0',
@ -47,16 +46,19 @@ const useStyles = makeStyles(() => ({
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#FFFFFF',
backgroundColor: '#2B3A4E',
boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
borderBottomLeftRadius: '2.5rem',
borderBottomRightRadius: '2.5rem',
borderBottomLeftRadius: '0.625rem',
borderBottomRightRadius: '0.625rem',
// border: '0.125rem solid #E7E7E7',
borderTop: 0,
width: '100%',
overflowY: 'auto',
zIndex: 1
},
resultsWrapperFocused: {
backgroundColor: '#FFFFFF'
},
resultsWrapperHidden: {
display: 'none'
},
@ -66,9 +68,19 @@ const useStyles = makeStyles(() => ({
cursor: 'pointer'
},
input: {
color: '#464141',
marginLeft: 1,
width: '90%'
width: '90%',
paddingLeft: 10,
height: '40px',
fontSize: '1rem',
backgroundColor: '#2B3A4E',
borderRadius: '0.625rem',
color: '#8A96A8'
},
inputFocused: {
backgroundColor: '#FFFFFF',
borderRadius: '0.625rem',
color: 'rgba(0, 0, 0, 0.6);'
},
searchItem: {
alignItems: 'center',
@ -95,16 +107,15 @@ const useStyles = makeStyles(() => ({
}
}));
function SearchSuggestion() {
const [queryParams, setQueryParams] = useSearchParams();
const search = queryParams.get('search');
const [searchQuery, setSearchQuery] = useState(search || '');
function SearchSuggestion({ setSearchCurrentValue = () => {} }) {
const [searchQuery, setSearchQuery] = useState('');
const [suggestionData, setSuggestionData] = useState([]);
const [queryParams] = useSearchParams();
const search = queryParams.get('search') || '';
const [isLoading, setIsLoading] = useState(false);
const [isFailedSearch, setIsFailedSearch] = useState(false);
const [isComponentFocused, setIsComponentFocused] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const abortController = useMemo(() => new AbortController(), []);
const classes = useStyles();
@ -175,15 +186,15 @@ function SearchSuggestion() {
const handleSeachChange = (event) => {
const value = event?.inputValue;
setSearchQuery(value);
// used to lift up the state for pages that need to know the current value of the search input (currently only Explore) not used in other cases
// one way binding, other components shouldn't set the value of the search input, but using this prop can read it
setSearchCurrentValue(value);
setIsFailedSearch(false);
setIsLoading(true);
setSuggestionData([]);
};
const searchCall = (value) => {
if (location.pathname?.includes('explore')) {
setQueryParams((prevState) => createSearchParams({ ...prevState, search: searchQuery }));
}
if (value !== '') {
// if search term inclused the ':' character, search for images, if not, search repos
if (value?.includes(':')) {
@ -219,15 +230,18 @@ function SearchSuggestion() {
getComboboxProps,
isOpen,
openMenu
// closeMenu
} = useCombobox({
items: suggestionData,
onInputValueChange: handleSeachChange,
onSelectedItemChange: handleSuggestionSelected,
initialInputValue: search ?? '',
itemToString: (item) => item.name ?? item
initialInputValue: !isEmpty(searchQuery) ? searchQuery : search,
itemToString: (item) => item?.name || item
});
useEffect(() => {
setIsComponentFocused(isOpen);
}, [isOpen]);
const renderSuggestions = () => {
return suggestionData.map((suggestion, index) => (
<ListItem
@ -255,9 +269,11 @@ function SearchSuggestion() {
};
return (
<div className={classes.searchContainer}>
<div className={`${classes.searchContainer} ${isComponentFocused && classes.searchContainerFocused}`}>
<Stack
className={isFailedSearch && !isLoading ? classes.searchFailed : classes.search}
className={`${classes.search} ${isComponentFocused && classes.searchFocused} ${
isFailedSearch && !isLoading && classes.searchFailed
}`}
direction="row"
alignItems="center"
justifyContent="space-between"
@ -265,9 +281,8 @@ function SearchSuggestion() {
{...getComboboxProps()}
>
<InputBase
style={{ paddingLeft: 10, height: 46, color: 'rgba(0, 0, 0, 0.6)' }}
placeholder={'Search for content...'}
className={classes.input}
className={`${classes.input} ${isComponentFocused && classes.inputFocused}`}
onKeyUp={handleSearch}
onFocus={() => openMenu()}
{...getInputProps()}
@ -278,10 +293,14 @@ function SearchSuggestion() {
</Stack>
<List
{...getMenuProps()}
className={isOpen && !isLoading && !isFailedSearch ? classes.resultsWrapper : classes.resultsWrapperHidden}
className={
isOpen && !isLoading && !isFailedSearch
? `${classes.resultsWrapper} ${isComponentFocused && classes.resultsWrapperFocused}`
: classes.resultsWrapperHidden
}
>
{isOpen && suggestionData?.length > 0 && renderSuggestions()}
{isOpen && isEmpty(searchQuery) && (
{isOpen && isEmpty(searchQuery) && isEmpty(suggestionData) && (
<>
<ListItem
className={classes.searchItem}

View File

@ -8,6 +8,7 @@ import { mapToRepo } from 'utilities/objectModels';
import Loading from '../Shared/Loading';
import { useNavigate, createSearchParams } from 'react-router-dom';
import { sortByCriteria } from 'utilities/sortCriteria';
import { HOME_POPULAR_PAGE_SIZE, HOME_RECENT_PAGE_SIZE } from 'utilities/paginationConstants';
const useStyles = makeStyles(() => ({
gridWrapper: {
@ -39,8 +40,13 @@ const useStyles = makeStyles(() => ({
},
sectionTitle: {
fontWeight: '700',
color: '#000000DE',
width: '100%'
color: '#0F2139',
width: '100%',
fontSize: '2rem',
textAlign: 'center',
lineHeight: '2.375rem',
letterSpacing: '-0.01rem',
marginLeft: '0.5rem'
},
subtitle: {
color: '#00000099',
@ -52,37 +58,81 @@ const useStyles = makeStyles(() => ({
width: '65%'
},
viewAll: {
color: '#00000099',
color: '#52637A',
fontWeight: '600',
fontSize: '1rem',
lineHeight: '1.5rem',
cursor: 'pointer',
textAlign: 'left'
marginRight: '0.5rem'
}
}));
function Home() {
const [isLoading, setIsLoading] = useState(true);
const [homeData, setHomeData] = useState([]);
const [popularData, setPopularData] = useState([]);
const [recentData, setRecentData] = useState([]);
const navigate = useNavigate();
const abortController = useMemo(() => new AbortController(), []);
const classes = useStyles();
useEffect(() => {
window.scrollTo(0, 0);
const getPopularData = () => {
setIsLoading(true);
api
.get(`${host()}${endpoints.repoList()}`, abortController.signal)
.get(
`${host()}${endpoints.globalSearch({
searchQuery: '',
pageNumber: 1,
pageSize: HOME_POPULAR_PAGE_SIZE,
sortBy: sortByCriteria.downloads?.value
})}`,
abortController.signal
)
.then((response) => {
if (response.data && response.data.data) {
let repoList = response.data.data.RepoListWithNewestImage.Results;
let repoList = response.data.data.GlobalSearch.Repos;
let repoData = repoList.map((responseRepo) => {
return mapToRepo(responseRepo);
});
setHomeData(repoData);
setPopularData(repoData);
setIsLoading(false);
}
})
.catch((e) => {
console.error(e);
});
};
const getRecentData = () => {
setIsLoading(true);
api
.get(
`${host()}${endpoints.globalSearch({
searchQuery: '',
pageNumber: 1,
pageSize: HOME_RECENT_PAGE_SIZE,
sortBy: sortByCriteria.updateTime?.value
})}`,
abortController.signal
)
.then((response) => {
if (response.data && response.data.data) {
let repoList = response.data.data.GlobalSearch.Repos;
let repoData = repoList.map((responseRepo) => {
return mapToRepo(responseRepo);
});
setRecentData(repoData);
setIsLoading(false);
}
})
.catch((e) => {
console.error(e);
});
};
useEffect(() => {
window.scrollTo(0, 0);
getPopularData();
getRecentData();
return () => {
abortController.abort();
};
@ -94,8 +144,8 @@ function Home() {
const renderMostPopular = () => {
return (
homeData &&
homeData.slice(0, 3).map((item, index) => {
popularData &&
popularData.map((item, index) => {
return (
<RepoCard
name={item.name}
@ -120,8 +170,8 @@ function Home() {
const renderRecentlyUpdated = () => {
return (
homeData &&
homeData.slice(0, 2).map((item, index) => {
recentData &&
recentData.map((item, index) => {
return (
<RepoCard
name={item.name}
@ -149,7 +199,7 @@ function Home() {
{isLoading ? (
<Loading />
) : (
<Stack spacing={4} alignItems="center" className={classes.gridWrapper}>
<Stack alignItems="center" className={classes.gridWrapper}>
<Stack
justifyContent="space-between"
alignItems={{ xs: 'flex-start', md: 'flex-end' }}
@ -161,8 +211,10 @@ function Home() {
Most popular images
</Typography>
</div>
<div className={classes.viewAll} onClick={() => handleClickViewAll(sortByCriteria.downloads.value)}>
<Typography variant="body2">View all</Typography>
<div onClick={() => handleClickViewAll(sortByCriteria.downloads.value)}>
<Typography variant="body2" className={classes.viewAll}>
View all
</Typography>
</div>
</Stack>
{renderMostPopular()}

View File

@ -1,5 +1,5 @@
// react global
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
// utility
import { api, endpoints } from '../../api';
@ -134,7 +134,7 @@ const useStyles = makeStyles((theme) => ({
},
platformChipsContainer: {
alignItems: 'center',
padding: '0.5rem 0 0 4rem',
padding: '0.5rem 0 0 1rem',
[theme.breakpoints.down('md')]: {
padding: '0.5rem 0 0 0'
}
@ -154,7 +154,7 @@ const randomImage = () => {
function RepoDetails() {
const [repoDetailData, setRepoDetailData] = useState({});
const [tags, setTags] = useState([]);
const placeholderImage = useRef(randomImage());
const [isLoading, setIsLoading] = useState(true);
const [selectedTab, setSelectedTab] = useState('Overview');
// get url param from <Route here (i.e. image name)
@ -201,18 +201,16 @@ function RepoDetails() {
const filteredPlatforms = platforms?.flatMap((platform) => [platform.Os, platform.Arch]);
return uniq(filteredPlatforms).map((platform, index) => (
<Stack key={`stack${platform}`} alignItems="center" direction="row" spacing={2}>
<Chip
key={`${name}${platform}${index}`}
label={platform}
onClick={handlePlatformChipClick}
sx={{
backgroundColor: '#E0E5EB',
color: '#52637A',
fontSize: '0.8125rem'
}}
/>
</Stack>
<Chip
key={`${name}${platform}${index}`}
label={platform}
onClick={handlePlatformChipClick}
sx={{
backgroundColor: '#E0E5EB',
color: '#52637A',
fontSize: '0.625rem'
}}
/>
));
};
@ -263,7 +261,7 @@ function RepoDetails() {
image={
!isEmpty(repoDetailData?.logo)
? `data:image/png;base64, ${repoDetailData?.logo}`
: randomImage()
: placeholderImage.current
}
alt="icon"
/>
@ -283,7 +281,7 @@ function RepoDetails() {
<Typography gutterBottom className={classes.repoTitle}>
{repoDetailData?.title || 'Title not available'}
</Typography>
<Stack direction="row" spacing={2} className={classes.platformChipsContainer}>
<Stack direction="row" spacing={1} className={classes.platformChipsContainer}>
{platformChips()}
</Stack>
</Grid>

View File

@ -1,11 +1,22 @@
// react global
import React from 'react';
import React, { useRef } from 'react';
import { useNavigate, createSearchParams } from 'react-router-dom';
// utility
import { DateTime } from 'luxon';
// components
import { Card, CardActionArea, CardMedia, CardContent, Typography, Stack, Chip, Grid, Tooltip } from '@mui/material';
import {
Card,
CardActionArea,
CardMedia,
CardContent,
Typography,
Stack,
Chip,
Grid,
Tooltip,
useMediaQuery
} from '@mui/material';
import makeStyles from '@mui/styles/makeStyles';
// placeholder images
@ -17,6 +28,7 @@ import repocube4 from '../../assets/repocube-4.png';
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
import { Markdown } from 'utilities/MarkdowntojsxWrapper';
import { isEmpty, uniq } from 'lodash';
import { useTheme } from '@emotion/react';
// temporary utility to get image
const randomIntFromInterval = (min, max) => {
@ -30,22 +42,22 @@ const randomImage = () => {
const useStyles = makeStyles(() => ({
card: {
marginBottom: 2,
marginTop: '1rem',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFFFFF',
borderColor: '#FFFFFF',
borderRadius: '1.5rem',
borderRadius: '0.75rem',
boxShadow: '0rem 0.313rem 0.625rem rgba(131, 131, 131, 0.08)',
flex: 'none',
alignSelf: 'stretch',
flexGrow: 0,
order: 0,
width: '100%',
maxWidth: '72rem',
'&:hover': {
boxShadow: '0rem 1.1875rem 1.4375rem rgba(131, 131, 131, 0.19)',
borderRadius: '1.5rem'
borderRadius: '0.75rem'
}
},
avatar: {
@ -56,7 +68,7 @@ const useStyles = makeStyles(() => ({
cardBtn: {
height: '100%',
width: '100%',
borderRadius: '1.5rem',
borderRadius: '0.75rem',
borderColor: '#FFFFFF',
'&:hover $focusHighlight': {
opacity: 0
@ -71,6 +83,7 @@ const useStyles = makeStyles(() => ({
color: '#606060',
maxHeight: '9.25rem',
backgroundColor: '#FFFFFF',
padding: '1.188rem 1rem',
'&:hover': {
backgroundColor: '#FFFFFF'
}
@ -78,6 +91,20 @@ const useStyles = makeStyles(() => ({
contentRight: {
height: '100%'
},
contentRightLabel: {
fontSize: '0.75rem',
lineHeight: '1.125rem',
color: '#52637A',
textAlign: 'end'
},
contentRightValue: {
fontSize: '0.75rem',
lineHeight: '1.125rem',
fontWeight: '600',
color: '#14191F',
textAlign: 'end',
marginLeft: '0.5rem'
},
signedBadge: {
color: '#9ccc65',
height: '1.375rem',
@ -86,24 +113,54 @@ const useStyles = makeStyles(() => ({
},
vendor: {
color: '#14191F',
fontSize: '1rem',
fontSize: '0.75rem',
maxWidth: '50%',
textOverflow: 'ellipsis'
textOverflow: 'ellipsis',
lineHeight: '1.125rem'
},
description: {
color: '#52637A',
fontSize: '1rem',
lineHeight: '1.5rem',
textOverflow: 'ellipsis',
marginBottom: 0,
paddingTop: '1rem'
},
versionLast: {
color: '#52637A',
fontSize: '1rem',
fontSize: '0.75rem',
lineHeight: '1.125rem',
textOverflow: 'ellipsis'
},
cardTitle: {
textOverflow: 'ellipsis',
maxWidth: '70%'
maxWidth: '70%',
fontWeight: '600',
color: '#0F2139',
lineHeight: '2rem'
},
platformChips: {
backgroundColor: '#E0E5EB',
color: '#52637A',
fontSize: '0.813rem',
lineHeight: '0.813rem',
borderRadius: '0.375rem',
padding: '0.313rem 0.625rem'
},
chipLabel: {
padding: '0'
}
}));
function RepoCard(props) {
const classes = useStyles();
const navigate = useNavigate();
const placeholderImage = useRef(randomImage());
// dynamically check device size with mui media query hook
const theme = useTheme();
const isXsSize = useMediaQuery(theme.breakpoints.down('md'));
const MAX_PLATFORM_CHIPS = isXsSize ? 3 : 6;
const { name, vendor, platforms, description, downloads, isSigned, lastUpdated, logo, version, vulnerabilityData } =
props;
@ -119,20 +176,20 @@ function RepoCard(props) {
};
const platformChips = () => {
const filteredPlatforms = platforms?.flatMap((platform) => [platform.Os, platform.Arch]);
return uniq(filteredPlatforms).map((platform, index) => (
<Stack key={`stack${platform}`} alignItems="center" direction="row" spacing={2}>
<Chip
key={`${name}${platform}${index}`}
label={platform}
onClick={handlePlatformChipClick}
sx={{
backgroundColor: '#E0E5EB',
color: '#52637A',
fontSize: '0.8125rem'
}}
/>
</Stack>
const filteredPlatforms = uniq(platforms?.flatMap((platform) => [platform.Os, platform.Arch]));
const hiddenChips = filteredPlatforms.length - MAX_PLATFORM_CHIPS;
const displayedPlatforms = filteredPlatforms.slice(0, MAX_PLATFORM_CHIPS + 1);
if (hiddenChips > 0) displayedPlatforms.push(`+${hiddenChips} more`);
return displayedPlatforms.map((platform, index) => (
<Chip
key={`${name}${platform}${index}`}
label={platform}
onClick={handlePlatformChipClick}
className={classes.platformChips}
classes={{
label: classes.chipLabel
}}
/>
));
};
@ -168,7 +225,7 @@ function RepoCard(props) {
img: classes.avatar
}}
component="img"
image={!isEmpty(logo) ? `data:image/png;base64, ${logo}` : randomImage()}
image={!isEmpty(logo) ? `data:image/png;base64, ${logo}` : placeholderImage.current}
alt="icon"
/>
<Tooltip title={name} placement="top">
@ -184,14 +241,14 @@ function RepoCard(props) {
</div>
</Stack>
<Tooltip title={description || 'Description not available'} placement="top">
<Typography className={classes.versionLast} pt={1} sx={{ fontSize: 12 }} gutterBottom noWrap>
<Typography className={classes.description} pt={1} sx={{ fontSize: 12 }} gutterBottom noWrap>
{description || 'Description not available'}
</Typography>
</Tooltip>
<Stack alignItems="center" direction="row" spacing={2} pt={1}>
<Stack alignItems="center" direction="row" spacing={1} pt={1}>
{platformChips()}
</Stack>
<Stack alignItems="center" direction="row" spacing={1} pt={2}>
<Stack alignItems="center" direction="row" spacing={1} pt={'0.5rem'}>
<Tooltip title={getVendor()} placement="top" className="hide-on-mobile">
<Typography className={classes.vendor} variant="body2" noWrap>
{<Markdown options={{ forceInline: true }}>{getVendor()}</Markdown>}
@ -209,19 +266,25 @@ function RepoCard(props) {
</Tooltip>
</Stack>
</Grid>
<Grid item xs={2} md={2} className="hide-on-mobile">
<Stack
alignItems="flex-end"
justifyContent="space-between"
direction="column"
className={classes.contentRight}
>
<Stack direction="column" alignItems="flex-end">
<Typography variant="body2">Downloads {!isNaN(downloads) ? downloads : `not available`}</Typography>
{/* <Typography variant="body2">Rating • {rating || '-'}</Typography> */}
</Stack>
{/* <BookmarkIcon sx={{color:"#52637A"}}/> */}
</Stack>
<Grid item xs={2} md={2} className={`hide-on-mobile ${classes.contentRight}`}>
<Grid container item justifyContent="flex-end" textAlign="end">
<Grid item xs={12}>
<Typography variant="body2" component="span" className={classes.contentRightLabel}>
Downloads
</Typography>
<Typography variant="body2" component="span" className={classes.contentRightValue}>
{!isNaN(downloads) ? downloads : `not available`}
</Typography>
</Grid>
{/* <Grid item xs={12}>
<Typography variant="body2" component="span" className={classes.contentRightLabel}>
Rating •
</Typography>
<Typography variant="body2" component="span" className={classes.contentRightValue}>
#1
</Typography>
</Grid> */}
</Grid>
</Grid>
</Grid>
</CardContent>

View File

@ -1,11 +1,9 @@
import React, { useEffect, useState, useMemo } from 'react';
import React, { useEffect, useState } from 'react';
import { makeStyles } from '@mui/styles';
import { isEmpty } from 'lodash';
import { Divider, Typography, Stack } from '@mui/material';
import ReferrerCard from '../../Shared/ReferrerCard';
import Loading from '../../Shared/Loading';
import { api, endpoints } from 'api';
import { host } from '../../../host';
import { mapReferrer } from 'utilities/objectModels';
const useStyles = makeStyles(() => ({
@ -17,36 +15,24 @@ const useStyles = makeStyles(() => ({
}));
function ReferredBy(props) {
const { repoName, digest } = props;
const [referrers, setReferrers] = useState([]);
const { referrers } = props;
const [referrersData, setReferrersData] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const classes = useStyles();
const abortController = useMemo(() => new AbortController(), []);
useEffect(() => {
api
.get(`${host()}${endpoints.referrers({ repo: repoName, digest })}`, abortController.signal)
.then((response) => {
if (response.data && response.data.data) {
let referrersData = response.data.data.Referrers?.map((referrer) => mapReferrer(referrer));
if (!isEmpty(referrersData)) {
setReferrers(referrersData);
}
}
setIsLoading(false);
})
.catch((e) => {
console.error(e);
setIsLoading(false);
});
return () => {
abortController.abort();
};
if (!isEmpty(referrers)) {
const mappedReferrersData = referrers.map((referrer) => mapReferrer(referrer));
setReferrersData(mappedReferrersData);
} else {
setReferrersData([]);
}
setIsLoading(false);
}, []);
const renderReferrers = () => {
return !isEmpty(referrers) ? (
referrers.map((referrer, index) => {
return !isEmpty(referrersData) ? (
referrersData.map((referrer, index) => {
return (
<ReferrerCard
key={index}
@ -63,16 +49,6 @@ function ReferredBy(props) {
);
};
const renderListBottom = () => {
if (isLoading) {
return <Loading />;
}
if (!isLoading) {
return <div />;
}
return;
};
return (
<div data-testid="referred-by-container">
<Typography
@ -95,8 +71,7 @@ function ReferredBy(props) {
/>
<Stack direction="column" spacing={2}>
<Stack direction="column" spacing={2}>
{renderReferrers()}
{renderListBottom()}
{isLoading ? <Loading /> : renderReferrers()}
</Stack>
</Stack>
</div>

View File

@ -5,16 +5,17 @@ import { api, endpoints } from '../../../api';
// components
import Collapse from '@mui/material/Collapse';
import { Box, Card, CardContent, Divider, Stack, Typography } from '@mui/material';
import { Box, Card, CardContent, Divider, Stack, Typography, InputBase } from '@mui/material';
import makeStyles from '@mui/styles/makeStyles';
import { host } from '../../../host';
import { isEmpty } from 'lodash';
import { debounce, isEmpty } from 'lodash';
import { Link } from 'react-router-dom';
import Loading from '../../Shared/Loading';
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material';
import { VulnerabilityChipCheck } from 'utilities/vulnerabilityAndSignatureCheck';
import { mapCVEInfo } from 'utilities/objectModels';
import { CVE_FIXEDIN_PAGE_SIZE, EXPLORE_PAGE_SIZE } from 'utilities/paginationConstants';
import SearchIcon from '@mui/icons-material/Search';
const useStyles = makeStyles(() => ({
card: {
@ -81,6 +82,25 @@ const useStyles = makeStyles(() => ({
fontWeight: '600',
cursor: 'pointer',
textAlign: 'center'
},
search: {
position: 'relative',
minWidth: '100%',
flexDirection: 'row',
marginBottom: '1.7rem',
boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
border: '0.125rem solid #E7E7E7',
borderRadius: '1rem',
zIndex: 1155
},
searchIcon: {
color: '#52637A',
paddingRight: '3%'
},
input: {
color: '#464141',
marginLeft: 1,
width: '90%'
}
}));
@ -236,27 +256,27 @@ function VulnerabilitiesDetails(props) {
const { name, tag } = props;
// pagination props
const [cveFilter, setCveFilter] = useState('');
const [pageNumber, setPageNumber] = useState(1);
const [isEndOfList, setIsEndOfList] = useState(false);
const listBottom = useRef(null);
const getPaginatedCVEs = () => {
setIsLoading(true);
api
.get(
`${host()}${endpoints.vulnerabilitiesForRepo(`${name}:${tag}`, { pageNumber, pageSize: EXPLORE_PAGE_SIZE })}`,
`${host()}${endpoints.vulnerabilitiesForRepo(
`${name}:${tag}`,
{ pageNumber, pageSize: EXPLORE_PAGE_SIZE },
cveFilter
)}`,
abortController.signal
)
.then((response) => {
if (response.data && response.data.data) {
let cveInfo = response.data.data.CVEListForImage?.CVEList;
let cveListData = mapCVEInfo(cveInfo);
const newCVEList = [...cveData, ...cveListData];
setCveData(newCVEList);
setIsEndOfList(
response.data.data.CVEListForImage.Page?.ItemCount < EXPLORE_PAGE_SIZE ||
newCVEList.length >= response.data.data.CVEListForImage?.Page?.TotalCount
);
setCveData((previousState) => (pageNumber === 1 ? cveListData : [...previousState, ...cveListData]));
setIsEndOfList(response.data.data.CVEListForImage.Page?.ItemCount < EXPLORE_PAGE_SIZE);
} else if (response.data.errors) {
setIsEndOfList(true);
}
@ -270,11 +290,25 @@ function VulnerabilitiesDetails(props) {
});
};
const resetPagination = () => {
setIsLoading(true);
setIsEndOfList(false);
if (pageNumber !== 1) {
setPageNumber(1);
} else {
getPaginatedCVEs();
}
};
const handleCveFilterChange = (e) => {
const { value } = e.target;
setCveFilter(value);
};
const debouncedChangeHandler = useMemo(() => debounce(handleCveFilterChange, 300));
useEffect(() => {
getPaginatedCVEs();
return () => {
abortController.abort();
};
}, [pageNumber]);
// setup intersection obeserver for infinite scroll
@ -302,6 +336,18 @@ function VulnerabilitiesDetails(props) {
};
}, [isLoading, isEndOfList]);
useEffect(() => {
if (isLoading) return;
resetPagination();
}, [cveFilter]);
useEffect(() => {
return () => {
abortController.abort();
debouncedChangeHandler.cancel();
};
}, []);
const renderCVEs = () => {
return !isEmpty(cveData) ? (
cveData.map((cve, index) => {
@ -347,6 +393,17 @@ function VulnerabilitiesDetails(props) {
width: '100%'
}}
/>
<Stack className={classes.search} direction="row" alignItems="center" justifyContent="space-between" spacing={2}>
<InputBase
style={{ paddingLeft: 10, height: 46, color: 'rgba(0, 0, 0, 0.6)' }}
placeholder={'Search for Vulnerabilities...'}
className={classes.input}
onChange={debouncedChangeHandler}
/>
<div className={classes.searchIcon}>
<SearchIcon />
</div>
</Stack>
<Stack direction="column" spacing={2}>
<Stack direction="column" spacing={2}>
{renderCVEs()}

View File

@ -217,6 +217,7 @@ function TagDetails() {
const [isLoading, setIsLoading] = useState(true);
const [selectedTab, setSelectedTab] = useState('Layers');
const [selectedPullTab, setSelectedPullTab] = useState('');
const placeholderImage = useRef(randomImage());
const abortController = useMemo(() => new AbortController(), []);
const mounted = useRef(false);
const navigate = useNavigate();
@ -331,7 +332,7 @@ function TagDetails() {
image={
!isEmpty(imageDetailData?.logo)
? `data:image/ png;base64, ${imageDetailData?.logo}`
: randomImage()
: placeholderImage.current
}
alt="icon"
/>
@ -349,7 +350,7 @@ function TagDetails() {
{/* <BookmarkIcon sx={{color:"#52637A"}}/> */}
</Stack>
<Stack>
<Stack sx={{ width: { xs: '100%', md: 'auto' } }}>
<FormControl sx={{ m: '1', minWidth: '4.6875rem' }} className={classes.sortForm} size="small">
<InputLabel>OS/Arch</InputLabel>
{!isEmpty(selectedManifest) && (
@ -580,7 +581,7 @@ function TagDetails() {
<VulnerabilitiesDetails name={reponame} tag={tag} />
</TabPanel>
<TabPanel value="ReferredBy" className={classes.tabPanel}>
<ReferredBy repoName={reponame} digest={selectedManifest?.digest} />
<ReferredBy referrers={imageDetailData.referrers} />
</TabPanel>
</Grid>
</Grid>

View File

@ -5,6 +5,7 @@ import Header from '../components/Header/Header.jsx';
import makeStyles from '@mui/styles/makeStyles';
import { Container, Grid, Stack } from '@mui/material';
import Explore from 'components/Explore/Explore.jsx';
import { useState } from 'react';
const useStyles = makeStyles(() => ({
container: {
@ -27,14 +28,15 @@ const useStyles = makeStyles(() => ({
function ExplorePage() {
const classes = useStyles();
const [searchCurrentValue, setSearchCurrentValue] = useState();
return (
<Stack className={classes.pageWrapper} direction="column" data-testid="explore-container">
<Header />
<Header setSearchCurrentValue={setSearchCurrentValue} />
<Container className={classes.container}>
<Grid container className={classes.gridWrapper}>
<Grid item className={classes.tile}>
<Explore />
<Explore searchInputValue={searchCurrentValue} />
</Grid>
</Grid>
</Container>

View File

@ -44,6 +44,7 @@ const mapToImage = (responseImage) => {
repoName: responseImage.RepoName,
tag: responseImage.Tag,
manifests: responseImage.Manifests?.map((manifest) => mapToManifest(manifest)) || [],
referrers: responseImage.Referrers,
size: responseImage.Size,
downloadCount: responseImage.DownloadCount,
lastUpdated: responseImage.LastUpdated,

View File

@ -1,6 +1,15 @@
const HEADER_SEARCH_PAGE_SIZE = 9;
const EXPLORE_PAGE_SIZE = 10;
const HOME_PAGE_SIZE = 10;
const HOME_POPULAR_PAGE_SIZE = 3;
const HOME_RECENT_PAGE_SIZE = 2;
const CVE_FIXEDIN_PAGE_SIZE = 5;
export { HEADER_SEARCH_PAGE_SIZE, EXPLORE_PAGE_SIZE, HOME_PAGE_SIZE, CVE_FIXEDIN_PAGE_SIZE };
export {
HEADER_SEARCH_PAGE_SIZE,
EXPLORE_PAGE_SIZE,
HOME_PAGE_SIZE,
CVE_FIXEDIN_PAGE_SIZE,
HOME_POPULAR_PAGE_SIZE,
HOME_RECENT_PAGE_SIZE
};

116
tests/data/config.yaml Normal file
View File

@ -0,0 +1,116 @@
images:
- name: alpine
tags:
- "3.17"
- "3.17.2"
- "3.17.1"
- "3.16"
- "3.16.4"
- "3.16.3"
- "3.16.2"
- "3.16.1"
- "3.15"
- "3.15.7"
- "3.15.6"
- "3.15.5"
- "3.15.4"
- "3.15.3"
- "3.15.2"
- "3.15.1"
- "3.14"
- "3.14.9"
multiarch: "all"
- name: ubuntu
tags:
- "18.04"
- "bionic-20230301"
- "bionic"
- "22.04"
- "jammy-20230301"
- "jammy"
- "latest"
multiarch: ""
- name: debian
tags:
- "bullseye-slim"
- "bullseye-20230227-slim"
- "11.6-slim"
- "11-slim"
multiarch: ""
- name: centos # Not supported since 2020
tags:
- "centos7"
- "7"
- "centos7.9.2009"
- "7.9.2009"
multiarch: ""
- name: node
tags:
- "18-alpine3.16" # Alpine here an below
- "18.15-alpine3.16"
- "18.15.0-alpine3.16"
- "lts-alpine3.16"
- "18-alpine"
- "18-alpine3.17"
- "18.15-alpine"
- "18.15-alpine3.17"
- "18.15.0-alpine3.17"
- "lts-alpine3.17"
- "18-bullseye-slim" # Debian here and below
- "18-slim"
- "18.15-bullseye-slim"
- "18.15.0-bullseye-slim"
- "18.15.0-slim"
multiarch: "all"
- name: nginx
tags:
- "1.23.3" # debian:bullseye-slim based here and below
- "mainline"
- "1.23"
- "1.23.3-alpine" # Depends on alpine slim tags
- "mainline-alpine"
- "1.23-alpine"
- "1.23.3-alpine-slim" # Based on alpine
- "mainline-alpine-slim"
- "1.23-alpine-slim"
multiarch: ""
- name: python
tags:
- "3.8.16-alpine3.17"
- "3.8.16-alpine3.16"
- "3.8.16-bullseye"
multiarch: ""
- name: golang
tags:
- "1.20.2-bullseye"
- "1.20.2-alpine3.17"
multiarch: ""
- name: perl
tags:
- "5.36.0-slim" # debian:bullseye-slim based
multiarch: ""
- name: ruby
tags:
- "3.2.1-slim-bullseye" # debian:bullseye-slim based
multiarch: ""
- name: busybox
tags:
- "1.36.0" # From scratch
- "1.35.0"
multiarch: ""
- name: httpd
tags:
- "2.4.56-alpine3.17"
multiarch: ""
- name: hello-world # From scratch
tags:
- "linux"
multiarch: ""
- name: bash
tags:
- "5.2.15-alpine3.16"
multiarch: ""
- name: rust
tags:
- "1.68-slim-bullseye"
multiarch: ""

179
tests/scripts/load_test_data.py Executable file
View File

@ -0,0 +1,179 @@
#!/usr/bin/env python3
import argparse
import json
import logging
import os
import subprocess
import sys
import tempfile
import yaml
def init_logger(debug=False):
logger = logging.getLogger(__name__)
if debug:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
# log format
log_fmt = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
date_fmt = "%Y-%m-%d %H:%M:%S"
formatter = logging.Formatter(log_fmt, date_fmt)
# create streamHandler and set log fmt
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
# add the streamHandler to logger
logger.addHandler(stream_handler)
return logger
def parse_args():
p = argparse.ArgumentParser()
p.add_argument('-r', '--registry', default='localhost:8080', help='Registry address')
p.add_argument('-u', '--username', default="", help='registry username')
p.add_argument('-p', '--password', default="", help='registry password')
p.add_argument('-c', '--cosign-password', default="", help='cosign key password')
p.add_argument('-d', '--debug', action='store_true', help='enable debug logs')
p.add_argument('-f', '--config-file', default="config.yaml", help='config file containing information about images to upload')
p.add_argument('-m', '--metadata-file', default="image_metadata.json", help='file containing metadata on uploaded images')
p.add_argument('--data-dir', default="", help='location where to store image related data')
return p.parse_args()
def fetch_tags(logger, image_name):
cmd = "skopeo list-tags docker://docker.io/{}".format(image_name)
logger.info("running command: '{}'".format(cmd))
result = subprocess.run(cmd, capture_output=True, shell=True)
if result.returncode != 0:
logger.error("running command `{}` exited with code: {}".format(cmd, str(result.returncode)))
logger.error(result.stderr)
sys.exit(1)
return json.loads(result.stdout)["Tags"]
def pull_modify_push_image(logger, registry, image_name, tag, cosign_password,
multiarch, username, password, debug, data_dir):
logger.info("image '{}:{}' will be processed and pushed".format(image_name, tag))
image_update_script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "pull_update_push_image.sh")
with tempfile.TemporaryDirectory() as meta_dir_name:
metafile = '{}_{}_metadata.json'.format(image_name, tag)
metafile = os.path.join(meta_dir_name, metafile)
cmd = [image_update_script_path, "-r", registry, "-i", image_name, "-t", tag, "-f", metafile]
if data_dir:
cmd.extend(["--data-dir", data_dir])
if username:
cmd.extend(["-u", username, "-p", password])
if cosign_password:
cmd.extend(["-c", cosign_password])
if multiarch:
cmd.extend(["-m", multiarch])
if debug:
cmd.append("-d")
logger.info("running command: '{}'".format(" ".join(cmd)))
result = subprocess.run(cmd, stderr=sys.stderr, stdout=sys.stdout)
if result.returncode != 0:
logger.error("pushing image `{}:{}` exited with code: ".format(image_name, tag) + str(result.returncode))
sys.exit(1)
with open(metafile) as f:
image_metadata = json.load(f)
logger.debug("raw image metadata")
logger.debug(image_metadata)
image_metadata["multiarch"] = multiarch
image_metadata["cves"] = getCVEInfo(image_metadata.pop("trivy"))
logger.debug("processed image metadata")
logger.debug(image_metadata)
return image_metadata
def getCVEInfo(trivy_results):
cve_dict = {}
for result in trivy_results:
for vulnerability in result.get("Vulnerabilities", []):
cve_id = vulnerability["VulnerabilityID"]
package = {
"PackageName": vulnerability.get("PkgName"),
"InstalledVersion": vulnerability.get("InstalledVersion"),
"FixedVersion": vulnerability.get("FixedVersion", "Not Specified")
}
if cve_dict.get(cve_id):
cve_dict[cve_id]["PackageList"].append(package)
else:
cve_dict[cve_id] = {
"ID": cve_id,
"Title": vulnerability.get("Title"),
"Description": vulnerability.get("Description"),
"Severity": vulnerability.get("Severity"),
"PackageList": [package]
}
return cve_dict
def main():
args = parse_args()
registry = args.registry
username = args.username
password = args.password
cosign_password = args.cosign_password
config_file = args.config_file
debug = args.debug
metadata_file = args.metadata_file
data_dir= args.data_dir
logger = init_logger(debug)
with open(config_file, "r") as f:
config = yaml.load(f, Loader=yaml.SafeLoader)
metadata = {}
for image in config["images"]:
image_name = image["name"]
multiarch = image["multiarch"]
if not multiarch:
multiarch = ""
actual_tags = fetch_tags(logger, image_name)
expected_tags = image["tags"]
logger.debug("image '{}' has the following tags specified in the config file: '{}'".format(image_name, ",".join(expected_tags)))
logger.debug("image '{}' has the following tags on source registry: '{}'".format(image_name, ",".join(actual_tags)))
for tag in expected_tags:
found = False
for actual_tag in actual_tags:
if actual_tag == tag:
found = True
break
if not found:
logger.error("image '{}:{}' not found".format(image_name, tag))
sys.exit(1)
for tag in expected_tags:
image_metadata = pull_modify_push_image(logger, registry, image_name, tag, cosign_password, multiarch, username, password, debug, data_dir)
metadata.setdefault(image_name, {})
metadata[image_name][tag] = image_metadata
with open(metadata_file, "w") as f:
json.dump(metadata, f, indent=2)
logger.info("Done loading images, see more details in '{}'".format(metadata_file))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,246 @@
#!/bin/bash
registry=""
image=""
tag=""
cosign_password=""
metafile=""
multiarch=""
username=""
username=""
debug=0
data_dir=$(pwd)
while (( "$#" )); do
case $1 in
-r|--registry)
if [ -z "$2" ]; then
echo "Option registry requires an argument"
exit 1
fi
registry=$2;
shift 2
;;
-i|--image)
if [ -z "$2" ]; then
echo "Option image requires an argument"
exit 1
fi
image=$2
shift 2
;;
-t|--tag)
if [ -z "$2" ]; then
echo "Option tag requires an argument"
exit 1
fi
tag=$2
shift 2
;;
-u|--username)
if [ -z "$2" ]; then
echo "Option username requires an argument"
exit 1
fi
username=$2
shift 2
;;
-p|--password)
if [ -z "$2" ]; then
echo "Option password requires an argument"
exit 1
fi
password=$2
shift 2
;;
-c|--cosign-password)
if [ -z "$2" ]; then
echo "Option cosign-password requires an argument"
exit 1
fi
cosign_password=$2
shift 2
;;
-m|--multiarch)
if [ -z "$2" ]; then
echo "Option multiarch requires an argument"
exit 1
fi
multiarch=$2
shift 2
;;
-f|--file)
if [ -z "$2" ]; then
echo "Option metafile requires an argument"
exit 1
fi
metafile=$2
shift 2
;;
--data-dir)
if [ -z "$2" ]; then
echo "Option data-dir requires an argument"
exit 1
fi
data_dir=$2
shift 2
;;
-d|--debug)
debug=1
shift 1
;;
--)
shift 1
break
;;
*)
break
;;
esac
done
if [ ${debug} -eq 1 ]; then
set -x
fi
images_dir=${data_dir}/images
docker_docs_dir=${data_dir}/docs
cosign_key_path=${data_dir}/cosign.key
function verify_prerequisites {
mkdir -p ${data_dir}
if [ ! command -v regctl ] &>/dev/null; then
echo "you need to install regctl as a prerequisite" >&3
return 1
fi
if [ ! command -v skopeo ] &>/dev/null; then
echo "you need to install skopeo as a prerequisite" >&3
return 1
fi
if [ ! command -v cosign ] &>/dev/null; then
echo "you need to install cosign as a prerequisite" >&3
return 1
fi
if [ ! command -v trivy ] &>/dev/null; then
echo "you need to install trivy as a prerequisite" >&3
return 1
fi
if [ ! command -v jq ] &>/dev/null; then
echo "you need to install jq as a prerequisite" >&3
return 1
fi
if [ ! -f "${cosign_key_path}" ]; then
COSIGN_PASSWORD=${cosign_password} cosign generate-key-pair
key_dir=$(dirname ${cosign_key_path})
mv cosign.key ${cosign_key_path}
mv cosign.pub ${key_dir}
fi
# pull docker docs repo
if [ ! -d ${docker_docs_dir} ]
then
git clone https://github.com/docker-library/docs.git ${docker_docs_dir}
fi
return 0
}
verify_prerequisites
repo=$(cat ${docker_docs_dir}/${image}/github-repo)
description="$(cat ${docker_docs_dir}/${image}/README-short.txt)"
license="$(cat ${docker_docs_dir}/${image}/license.md)"
vendor="$(cat ${docker_docs_dir}/${image}/maintainer.md)"
logo=$(base64 -w 0 ${docker_docs_dir}/${image}/logo.png)
echo ${repo}
sed -i.bak "s|%%GITHUB-REPO%%|${repo}|g" ${docker_docs_dir}/${image}/maintainer.md; rm ${docker_docs_dir}/${image}/maintainer.md.bak
sed -i.bak "s|%%IMAGE%%|${image}|g" ${docker_docs_dir}/${image}/content.md; rm ${docker_docs_dir}/${image}/content.md.bak
doc=$(cat ${docker_docs_dir}/${image}/content.md)
local_image_ref_skopeo=oci:${images_dir}:${image}-${tag}
local_image_ref_regtl=ocidir://${images_dir}:${image}-${tag}
local_image_ref_trivy=${images_dir}:${image}-${tag}
remote_src_image_ref=docker://${image}:${tag}
remote_dest_image_ref=${registry}/${image}:${tag}
multiarch_arg=""
if [ ! -z "${multiarch}" ]; then
multiarch_arg="--multi-arch=${multiarch}"
fi
# Verify if image is already present in local oci layout
skopeo inspect ${local_image_ref_skopeo}
if [ $? -eq 0 ]; then
echo "Image ${local_image_ref_skopeo} found locally"
else
echo "Image ${local_image_ref_skopeo} will be copied"
skopeo --insecure-policy copy --format=oci ${multiarch_arg} ${remote_src_image_ref} ${local_image_ref_skopeo}
if [ $? -ne 0 ]; then
exit 1
fi
fi
# Mofify image in local oci layout and update the old reference to point to the new index
regctl image mod --replace --annotation org.opencontainers.image.title=${image} ${local_image_ref_regtl}
regctl image mod --replace --annotation org.opencontainers.image.description="${description}" ${local_image_ref_regtl}
regctl image mod --replace --annotation org.opencontainers.image.url=${repo} ${local_image_ref_regtl}
regctl image mod --replace --annotation org.opencontainers.image.source=${repo} ${local_image_ref_regtl}
regctl image mod --replace --annotation org.opencontainers.image.licenses="${license}" ${local_image_ref_regtl}
regctl image mod --replace --annotation org.opencontainers.image.vendor="${vendor}" ${local_image_ref_regtl}
regctl image mod --replace --annotation org.opencontainers.image.documentation="${description}" ${local_image_ref_regtl}
credentials_args=""
if [ ! -z "${username}" ]; then
credentials_args="--dest-creds ${username}:${username}"
fi
# Upload image to target registry
skopeo copy --dest-tls-verify=false ${multiarch_arg} ${credentials_args} ${local_image_ref_skopeo} docker://${remote_dest_image_ref}
if [ $? -ne 0 ]; then
exit 1
fi
# Upload image logo as image media type
regctl artifact put --annotation artifact.type=com.zot.logo.image --annotation format=oci \
--artifact-type "application/vnd.zot.logo.v1" --subject ${remote_dest_image_ref} ${remote_dest_image_ref}-logo-image << EOF
${logo}
EOF
if [ $? -ne 0 ]; then
exit 1
fi
trivy_out_file=trivy-${image}-${tag}.json
if [ ! -z "${multiarch}" ]; then
trivy image --scanners vuln --format json --input ${local_image_ref_trivy} -o ${trivy_out_file}
jq -n --argfile trivy_file ${trivy_out_file} '.trivy=$trivy_file.Results' > ${trivy_out_file}.tmp
mv ${trivy_out_file}.tmp ${trivy_out_file}
else
echo '{"trivy":[]}' > ${trivy_out_file}
fi
# Sign new updated image
COSIGN_PASSWORD=${cosign_password} cosign sign ${remote_dest_image_ref} --key ${cosign_key_path} --allow-insecure-registry
if [ $? -ne 0 ]; then
exit 1
fi
details_file=details-${image}-${tag}.json
jq -n \
--arg org.opencontainers.image.title "${image}" \
--arg org.opencontainers.image.description " $description" \
--arg org.opencontainers.image.url "${repo}" \
--arg org.opencontainers.image.source "${repo}" \
--arg org.opencontainers.image.licenses "${license}" \
--arg org.opencontainers.image.vendor "${vendor}" \
--arg org.opencontainers.image.documentation "${description}" \
'$ARGS.named' > ${details_file}
jq -c -s add ${details_file} ${trivy_out_file} > ${metafile}
rm ${details_file} ${trivy_out_file}