6 Commits

Author SHA1 Message Date
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
9029b97b47 feat: multi-arch image features
Signed-off-by: Raul Kele <raulkeleblk@gmail.com>
2023-03-09 10:44:43 +02:00
4db0c2ee8b patch: Update API usage for multi-arch support
Signed-off-by: Raul Kele <raulkeleblk@gmail.com>
2023-03-06 17:25:07 +02:00
29 changed files with 1205 additions and 348 deletions

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

@ -0,0 +1,110 @@
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
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

@ -13,8 +13,9 @@ jest.mock('react-router-dom', () => ({
}));
const mockImageList = {
RepoListWithNewestImage: {
Results: [
GlobalSearch: {
Page: { TotalCount: 6, ItemCount: 3 },
Repos: [
{
Name: 'alpine',
Size: '2806985',
@ -65,28 +66,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 +104,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,7 +124,8 @@ 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 } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } });
render(<Home />);
await waitFor(() => expect(screen.getAllByText(/alpine/i)).toHaveLength(2));
await waitFor(() => expect(screen.getAllByText(/mongo/i)).toHaveLength(2));
@ -140,14 +133,16 @@ describe('Home component', () => {
});
it('renders signature icons', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } });
render(<Home />);
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 } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } });
render(<Home />);
expect(await screen.findAllByTestId('low-vulnerability-icon')).toHaveLength(2);
expect(await screen.findAllByTestId('high-vulnerability-icon')).toHaveLength(2);
@ -158,11 +153,12 @@ describe('Home component', () => {
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<Home />);
await waitFor(() => expect(error).toBeCalledTimes(1));
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 } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } });
render(<Home />);
const viewAllButtons = await screen.findAllByText(/view all/i);
expect(viewAllButtons).toHaveLength(2);

View File

@ -11,37 +11,49 @@ jest.mock('react-router-dom', () => ({
const mockedTagsData = [
{
Digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559',
Tag: 'latest',
LastUpdated: '2022-07-19T18:06:18.818788283Z',
Vendor: 'test1',
Size: '569130088',
Platform: {
Os: 'linux',
Arch: 'amd64'
}
tag: 'latest',
vendor: 'test1',
manifests: [
{
lastUpdated: '2022-07-19T18:06:18.818788283Z',
digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559',
size: '569130088',
platform: {
Os: 'linux',
Arch: 'amd64'
}
}
]
},
{
Digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559',
Tag: 'bullseye',
LastUpdated: '2022-07-19T18:06:18.818788283Z',
Vendor: 'test1',
Size: '569130088',
Platform: {
Os: 'linux',
Arch: 'amd64'
}
tag: 'bullseye',
vendor: 'test1',
manifests: [
{
digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559',
lastUpdated: '2022-07-19T18:06:18.818788283Z',
size: '569130088',
platform: {
Os: 'linux',
Arch: 'amd64'
}
}
]
},
{
Digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559',
Tag: '1.5.2',
LastUpdated: '2022-07-19T18:06:18.818788283Z',
Vendor: 'test1',
Size: '569130088',
Platform: {
Os: 'linux',
Arch: 'amd64'
}
tag: '1.5.2',
vendor: 'test1',
manifests: [
{
lastUpdated: '2022-07-19T18:06:18.818788283Z',
digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559',
size: '569130088',
platform: {
Os: 'linux',
Arch: 'amd64'
}
}
]
}
];
@ -60,7 +72,20 @@ describe('Tags component', () => {
const tagLink = await screen.findByText('latest');
fireEvent.click(tagLink);
await waitFor(() => {
expect(mockedUsedNavigate).toHaveBeenCalledWith('tag/latest');
expect(mockedUsedNavigate).toHaveBeenCalledWith('tag/latest', { state: { digest: null } });
});
});
it('should navigate to specific manifest when clicking the digest', async () => {
render(<Tags tags={mockedTagsData} />);
const openBtn = screen.getAllByText(/digest/i);
await fireEvent.click(openBtn[0]);
const tagLink = await screen.findByText(/sha256:adca4/i);
fireEvent.click(tagLink);
await waitFor(() => {
expect(mockedUsedNavigate).toHaveBeenCalledWith('tag/latest', {
state: { digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559' }
});
});
});

View File

@ -12,6 +12,7 @@ const mockDependenciesList = {
{
RepoName: 'project-stacker/c3/static-ubuntu-amd64',
Tag: 'tag1',
Manifests: [],
Vulnerabilities: {
MaxSeverity: 'HIGH',
Count: 5
@ -20,6 +21,7 @@ const mockDependenciesList = {
{
RepoName: 'tag2',
Tag: 'tag2',
Manifests: [],
Vulnerabilities: {
MaxSeverity: 'CRITICAL',
Count: 2
@ -28,6 +30,7 @@ const mockDependenciesList = {
{
RepoName: 'tag3',
Tag: 'tag3',
Manifests: [],
Vulnerabilities: {
MaxSeverity: 'LOW',
Count: 7
@ -36,6 +39,7 @@ const mockDependenciesList = {
{
RepoName: 'tag4',
Tag: 'tag4',
Manifests: [],
Vulnerabilities: {
MaxSeverity: 'HIGH',
Count: 5

View File

@ -1,11 +1,10 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { api } from 'api';
import HistoryLayers from 'components/Tag/Tabs/HistoryLayers';
import React from 'react';
const mockLayersList = [
{
Layer: { Size: '2806054', Digest: '213ec9aee27d8be045c6a92b7eac22c9a64b44558193775a1a7f626352392b49', Score: null },
Layer: { Size: '2806054', Digest: '213ec9aee27d8be045c6a92b7eac22c9a64b44558193775a1a7f626352392b49' },
HistoryDescription: {
Created: '2022-08-09T17:19:53.274069586Z',
CreatedBy: '/bin/sh -c #(nop) ADD file:2a949686d9886ac7c10582a6c29116fd29d3077d02755e87e111870d63607725 in / ',
@ -33,33 +32,20 @@ afterEach(() => {
describe('Layers page', () => {
it('renders the layers if there are any', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: { Image: { History: mockLayersList } } } });
render(<HistoryLayers name="alpine:latest" />);
render(<HistoryLayers name="alpine:latest" history={mockLayersList} />);
expect(await screen.findAllByTestId('layer-card-container')).toHaveLength(1);
});
it('renders no layers if there are not any', async () => {
jest.spyOn(api, 'get').mockResolvedValue({
status: 200,
data: { data: { History: { Tag: '', mockLayersList: [] } } }
});
render(<HistoryLayers name="alpine:latest" />);
render(<HistoryLayers name="alpine:latest" history={[]} />);
await waitFor(() => expect(screen.getAllByText(/No Layer data available/i)).toHaveLength(1));
});
it('opens dropdown and renders layer command and digest', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: { Image: { History: mockLayersList } } } });
render(<HistoryLayers name="alpine:latest" />);
render(<HistoryLayers name="alpine:latest" history={mockLayersList} />);
expect(screen.queryAllByText(/DIGEST/i)).toHaveLength(0);
const openDetails = await screen.findAllByText(/details/i);
fireEvent.click(openDetails[0]);
expect(await screen.findAllByText(/DIGEST/i)).toHaveLength(1);
});
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(<HistoryLayers name="alpine:latest" />);
await waitFor(() => expect(error).toBeCalledTimes(1));
});
});

View File

@ -12,6 +12,7 @@ const mockDependentsList = {
{
RepoName: 'project-stacker/c3/static-ubuntu-amd64',
Tag: 'tag1',
Manifests: [],
Vulnerabilities: {
MaxSeverity: 'HIGH',
Count: 5
@ -20,6 +21,7 @@ const mockDependentsList = {
{
RepoName: 'tag2',
Tag: 'tag2',
Manifests: [],
Vulnerabilities: {
MaxSeverity: 'CRITICAL',
Count: 2
@ -28,6 +30,7 @@ const mockDependentsList = {
{
RepoName: 'tag3',
Tag: 'tag3',
Manifests: [],
Vulnerabilities: {
MaxSeverity: 'LOW',
Count: 5
@ -36,6 +39,7 @@ const mockDependentsList = {
{
RepoName: 'tag4',
Tag: 'tag4',
Manifests: [],
Vulnerabilities: {
MaxSeverity: 'HIGH',
Count: 3

View File

@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
import { api } from 'api';
import TagDetails from 'components/Tag/TagDetails';
import MockThemeProvier from '__mocks__/MockThemeProvider';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
const TagDetailsThemeWrapper = () => {
return (
@ -24,57 +24,157 @@ const mockImage = {
Image: {
RepoName: 'centos',
Tag: '8',
Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e29',
LastUpdated: '2020-12-08T00:22:52.526672082Z',
Size: '75183423',
ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78',
Platform: {
Os: 'linux',
Arch: 'amd64'
},
Manifests: [
{
Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e29',
LastUpdated: '2020-12-08T00:22:52.526672082Z',
Size: '75183423',
ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78',
Platform: {
Os: 'linux',
Arch: 'amd64'
},
History: [
{
Layer: {
Size: '75181999',
Digest: 'sha256:7a0437f04f83f084b7ed68ad9c4a4947e12fc4e1b006b38129bac89114ec3621',
Score: null
},
HistoryDescription: {
Created: '2020-12-08T00:22:52.526672082Z',
CreatedBy:
'/bin/sh -c #(nop) ADD file:bd7a2aed6ede423b719ceb2f723e4ecdfa662b28639c8429731c878e86fb138b in / ',
Author: '',
Comment: '',
EmptyLayer: false
}
},
{
Layer: null,
HistoryDescription: {
Created: '2020-12-08T00:22:52.895811646Z',
CreatedBy:
'/bin/sh -c #(nop) LABEL org.label-schema.schema-version=1.0 org.label-schema.name=CentOS Base Image org.label-schema.vendor=CentOS org.label-schema.license=GPLv2 org.label-schema.build-date=20201204',
Author: '',
Comment: '',
EmptyLayer: true
}
},
{
Layer: null,
HistoryDescription: {
Created: '2020-12-08T00:22:53.076477777Z',
CreatedBy: '/bin/sh -c #(nop) CMD ["/bin/bash"]',
Author: '',
Comment: '',
EmptyLayer: true
}
}
]
},
{
Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf45etertdfg973e29',
LastUpdated: '2020-12-08T00:22:52.526672082Z',
Size: '75183423',
ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78',
Platform: {
Os: 'windows',
Arch: 'amd64'
},
History: [
{
Layer: {
Size: '75181999',
Digest: 'sha256:7a0437f04f83f084b7ed68ad9c4a4947e12fc4e1b006b38129bac89114ec3621',
Score: null
},
HistoryDescription: {
Created: '2020-12-08T00:22:52.526672082Z',
CreatedBy:
'/bin/sh -c #(nop) ADD file:bd7a2aed6ede423b719ceb2f723e4ecdfa662b28639c8429731c878e86fb138b in / ',
Author: '',
Comment: '',
EmptyLayer: false
}
},
{
Layer: null,
HistoryDescription: {
Created: '2020-12-08T00:22:52.895811646Z',
CreatedBy:
'/bin/sh -c #(nop) LABEL org.label-schema.schema-version=1.0 org.label-schema.name=CentOS Base Image org.label-schema.vendor=CentOS org.label-schema.license=GPLv2 org.label-schema.build-date=20201204',
Author: '',
Comment: '',
EmptyLayer: true
}
},
{
Layer: null,
HistoryDescription: {
Created: '2020-12-08T00:22:53.076477777Z',
CreatedBy: '/bin/sh -c #(nop) CMD ["/bin/bash"]',
Author: '',
Comment: '',
EmptyLayer: true
}
}
]
},
{
Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e25',
LastUpdated: '2020-12-08T00:22:52.526672082Z',
Size: '75183423',
ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78',
Platform: {
Os: 'linux',
Arch: 'arm'
},
History: [
{
Layer: {
Size: '75181999',
Digest: 'sha256:7a0437f04f83f084b7ed68ad9c4a4947e12fc4e1b006b38129bac89114ec3621',
Score: null
},
HistoryDescription: {
Created: '2020-12-08T00:22:52.526672082Z',
CreatedBy:
'/bin/sh -c #(nop) ADD file:bd7a2aed6ede423b719ceb2f723e4ecdfa662b28639c8429731c878e86fb138b in / ',
Author: '',
Comment: '',
EmptyLayer: false
}
},
{
Layer: null,
HistoryDescription: {
Created: '2020-12-08T00:22:52.895811646Z',
CreatedBy:
'/bin/sh -c #(nop) LABEL org.label-schema.schema-version=1.0 org.label-schema.name=CentOS Base Image org.label-schema.vendor=CentOS org.label-schema.license=GPLv2 org.label-schema.build-date=20201204',
Author: '',
Comment: '',
EmptyLayer: true
}
},
{
Layer: null,
HistoryDescription: {
Created: '2020-12-08T00:22:53.076477777Z',
CreatedBy: '/bin/sh -c #(nop) CMD ["/bin/bash"]',
Author: '',
Comment: '',
EmptyLayer: true
}
}
]
}
],
Vulnerabilities: {
MaxSeverity: 'CRITICAL',
Count: 10
},
Vendor: 'CentOS',
History: [
{
Layer: {
Size: '75181999',
Digest: 'sha256:7a0437f04f83f084b7ed68ad9c4a4947e12fc4e1b006b38129bac89114ec3621',
Score: null
},
HistoryDescription: {
Created: '2020-12-08T00:22:52.526672082Z',
CreatedBy:
'/bin/sh -c #(nop) ADD file:bd7a2aed6ede423b719ceb2f723e4ecdfa662b28639c8429731c878e86fb138b in / ',
Author: '',
Comment: '',
EmptyLayer: false
}
},
{
Layer: null,
HistoryDescription: {
Created: '2020-12-08T00:22:52.895811646Z',
CreatedBy:
'/bin/sh -c #(nop) LABEL org.label-schema.schema-version=1.0 org.label-schema.name=CentOS Base Image org.label-schema.vendor=CentOS org.label-schema.license=GPLv2 org.label-schema.build-date=20201204',
Author: '',
Comment: '',
EmptyLayer: true
}
},
{
Layer: null,
HistoryDescription: {
Created: '2020-12-08T00:22:53.076477777Z',
CreatedBy: '/bin/sh -c #(nop) CMD ["/bin/bash"]',
Author: '',
Comment: '',
EmptyLayer: true
}
}
]
Vendor: 'CentOS'
}
};
@ -82,14 +182,18 @@ const mockImageNone = {
Image: {
RepoName: 'centos',
Tag: '8',
Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e29',
LastUpdated: '2020-12-08T00:22:52.526672082Z',
Size: '75183423',
ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78',
Platform: {
Os: 'linux',
Arch: 'amd64'
},
Manifests: [
{
Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e29',
LastUpdated: '2020-12-08T00:22:52.526672082Z',
Size: '75183423',
ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78',
Platform: {
Os: 'linux',
Arch: 'amd64'
}
}
],
Vulnerabilities: {
MaxSeverity: 'NONE',
Count: 10
@ -102,14 +206,18 @@ const mockImageUnknown = {
Image: {
RepoName: 'centos',
Tag: '8',
Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e29',
LastUpdated: '2020-12-08T00:22:52.526672082Z',
Size: '75183423',
ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78',
Platform: {
Os: 'linux',
Arch: 'amd64'
},
Manifests: [
{
Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e29',
LastUpdated: '2020-12-08T00:22:52.526672082Z',
Size: '75183423',
ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78',
Platform: {
Os: 'linux',
Arch: 'amd64'
}
}
],
Vulnerabilities: {
MaxSeverity: 'UNKNOWN',
Count: 10
@ -122,14 +230,18 @@ const mockImageFailed = {
Image: {
RepoName: 'centos',
Tag: '8',
Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e29',
LastUpdated: '2020-12-08T00:22:52.526672082Z',
Size: '75183423',
ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78',
Platform: {
Os: 'linux',
Arch: 'amd64'
},
Manifests: [
{
Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e29',
LastUpdated: '2020-12-08T00:22:52.526672082Z',
Size: '75183423',
ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78',
Platform: {
Os: 'linux',
Arch: 'amd64'
}
}
],
Vulnerabilities: {
MaxSeverity: '',
Count: 10
@ -142,14 +254,18 @@ const mockImageLow = {
Image: {
RepoName: 'centos',
Tag: '8',
Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e29',
LastUpdated: '2020-12-08T00:22:52.526672082Z',
Size: '75183423',
ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78',
Platform: {
Os: 'linux',
Arch: 'amd64'
},
Manifests: [
{
Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e29',
LastUpdated: '2020-12-08T00:22:52.526672082Z',
Size: '75183423',
ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78',
Platform: {
Os: 'linux',
Arch: 'amd64'
}
}
],
Vulnerabilities: {
MaxSeverity: 'LOW',
Count: 10
@ -162,14 +278,18 @@ const mockImageMedium = {
Image: {
RepoName: 'centos',
Tag: '8',
Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e29',
LastUpdated: '2020-12-08T00:22:52.526672082Z',
Size: '75183423',
ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78',
Platform: {
Os: 'linux',
Arch: 'amd64'
},
Manifests: [
{
Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e29',
LastUpdated: '2020-12-08T00:22:52.526672082Z',
Size: '75183423',
ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78',
Platform: {
Os: 'linux',
Arch: 'amd64'
}
}
],
Vulnerabilities: {
MaxSeverity: 'MEDIUM',
Count: 10
@ -182,14 +302,18 @@ const mockImageHigh = {
Image: {
RepoName: 'centos',
Tag: '8',
Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e29',
LastUpdated: '2020-12-08T00:22:52.526672082Z',
Size: '75183423',
ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78',
Platform: {
Os: 'linux',
Arch: 'amd64'
},
Manifests: [
{
Digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e29',
LastUpdated: '2020-12-08T00:22:52.526672082Z',
Size: '75183423',
ConfigDigest: 'sha256:8dd57e171a61368ffcfde38045ddb6ed74a32950c271c1da93eaddfb66a77e78',
Platform: {
Os: 'linux',
Arch: 'amd64'
}
}
],
Vulnerabilities: {
MaxSeverity: 'HIGH',
Count: 10
@ -206,6 +330,7 @@ const mockDependenciesList = {
{
RepoName: 'project-stacker/c3/static-ubuntu-amd64',
Tag: 'tag1',
Manifests: [],
Vulnerabilities: {
MaxSeverity: 'HIGH',
Count: 5
@ -214,6 +339,7 @@ const mockDependenciesList = {
{
RepoName: 'tag2',
Tag: 'tag2',
Manifests: [],
Vulnerabilities: {
MaxSeverity: 'CRITICAL',
Count: 2
@ -222,6 +348,7 @@ const mockDependenciesList = {
{
RepoName: 'tag3',
Tag: 'tag3',
Manifests: [],
Vulnerabilities: {
MaxSeverity: 'LOW',
Count: 7
@ -230,6 +357,7 @@ const mockDependenciesList = {
{
RepoName: 'tag4',
Tag: 'tag4',
Manifests: [],
Vulnerabilities: {
MaxSeverity: 'HIGH',
Count: 5
@ -253,7 +381,8 @@ jest.mock('react-router-dom', () => ({
useParams: () => {
return { name: 'test', tag: '1.0.1' };
},
useNavigate: () => mockUseNavigate
useNavigate: () => mockUseNavigate,
useLocation: jest.fn()
}));
jest.mock('../../host', () => ({
@ -296,6 +425,24 @@ describe('Tags details', () => {
await waitFor(() => expect(error).toBeCalledTimes(1));
});
it('should show the data of the different manifests when switching between them', async () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImage } });
render(<TagDetailsThemeWrapper />);
const manifestSelect = await screen.findByText(/linux\/amd64/i);
await userEvent.click(manifestSelect);
await userEvent.click(await screen.findByText(/windows\/amd64/i));
expect(await screen.findByText(/windows\/amd64/i)).toBeInTheDocument();
});
it('should preselect a manifest if data is received', async () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImage } });
useLocation.mockImplementation(() => ({
state: { digest: 'sha256:63a795ca90aa6e7cca60941e826810a4cd0a2e73ea02bf458241df2a5c973e25' }
}));
render(<TagDetailsThemeWrapper />);
expect(await screen.findByText(/linux\/arm/i)).toBeInTheDocument();
});
it('should redirect to homepage if it receives invalid data', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: null, errors: ['testerror'] } });
render(<TagDetailsThemeWrapper />);

View File

@ -76,15 +76,13 @@ const endpoints = {
(pageNumber - 1) * pageSize
}}){Results {Name LastUpdated Size Platforms {Os Arch} NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description Licenses Title Source IsSigned Documentation Vendor Labels} DownloadCount}}}`,
detailedRepoInfo: (name) =>
`/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:"${name}"){Images {Digest Vulnerabilities {MaxSeverity Count} Tag LastUpdated Vendor Size Platform {Os Arch}} Summary {Name LastUpdated Size Platforms {Os Arch} Vendors NewestImage {RepoName IsSigned Vulnerabilities {MaxSeverity Count} Layers {Size Digest} Digest Tag Title Documentation DownloadCount Source Description Licenses History {Layer {Size Digest} HistoryDescription {Created CreatedBy Author Comment EmptyLayer}}}}}}`,
`/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 Digest LastUpdated Size ConfigDigest Platform {Os Arch} Vendor Licenses }}`,
`/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:${
(pageNumber - 1) * pageSize
}}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}}}}`,
layersDetailsForImage: (name) =>
`/v2/_zot/ext/search?query={Image(image: "${name}"){History {Layer {Size Digest Score} HistoryDescription {Created CreatedBy Author Comment EmptyLayer} }}}`,
imageListWithCVEFixed: (cveId, repoName, { pageNumber = 1, pageSize = 3 }) =>
`/v2/_zot/ext/search?query={ImageListWithCVEFixed(id:"${cveId}", image:"${repoName}", requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize
@ -92,11 +90,11 @@ const endpoints = {
dependsOnForImage: (name, { pageNumber = 1, pageSize = 15 } = {}) =>
`/v2/_zot/ext/search?query={BaseImageList(image: "${name}", requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize
}}){Page {TotalCount ItemCount} Results { RepoName Tag Description Digest Vendor DownloadCount LastUpdated Size Platform {Os Arch} IsSigned Vulnerabilities {MaxSeverity Count}}}}`,
}}){Page {TotalCount ItemCount} Results { RepoName Tag Description Manifests {Digest Platform {Os Arch} Size} Vendor DownloadCount LastUpdated IsSigned Vulnerabilities {MaxSeverity Count}}}}`,
isDependentOnForImage: (name, { pageNumber = 1, pageSize = 15 } = {}) =>
`/v2/_zot/ext/search?query={DerivedImageList(image: "${name}", requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize
}}){Page {TotalCount ItemCount} Results {RepoName Tag Description Digest Vendor DownloadCount LastUpdated Size Platform {Os Arch} IsSigned Vulnerabilities {MaxSeverity Count}}}}`,
}}){Page {TotalCount ItemCount} Results {RepoName Tag Description Manifests {Digest Platform {Os Arch} Size} Vendor DownloadCount LastUpdated IsSigned Vulnerabilities {MaxSeverity Count}}}}`,
globalSearch: ({
searchQuery = '""',
pageNumber = 1,

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';
@ -63,7 +63,7 @@ const useStyles = makeStyles((theme) => ({
}
}));
function Explore() {
function Explore({ searchInputValue }) {
const [isLoading, setIsLoading] = useState(true);
const [exploreData, setExploreData] = useState([]);
const [sortFilter, setSortFilter] = useState(sortByCriteria.relevance.value);
@ -119,7 +119,7 @@ function Explore() {
api
.get(
`${host()}${endpoints.globalSearch({
searchQuery: search,
searchQuery: !isNil(searchInputValue) ? searchInputValue : search,
pageNumber,
pageSize: EXPLORE_PAGE_SIZE,
sortBy: sortFilter,

View File

@ -103,7 +103,7 @@ function setNavShow() {
return show;
}
function Header() {
function Header({ setSearchCurrentValue = () => {} }) {
const show = setNavShow();
const classes = useStyles();
const path = useLocation().pathname;
@ -122,7 +122,7 @@ function Header() {
</Link>
</Grid>
<Grid item xs={8}>
{path !== '/' && <SearchSuggestion />}
{path !== '/' && <SearchSuggestion setSearchCurrentValue={setSearchCurrentValue} />}
</Grid>
<Grid item md={2} xs={0}>
<div>{''}</div>

View File

@ -95,12 +95,11 @@ 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 navigate = useNavigate();
@ -174,13 +173,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) => {
setQueryParams((prevState) => createSearchParams({ ...prevState, search: searchQuery }));
if (value !== '') {
// if search term inclused the ':' character, search for images, if not, search repos
if (value?.includes(':')) {
@ -221,7 +222,7 @@ function SearchSuggestion() {
items: suggestionData,
onInputValueChange: handleSeachChange,
onSelectedItemChange: handleSuggestionSelected,
initialInputValue: search ?? '',
initialInputValue: !isEmpty(searchQuery) ? searchQuery : search,
itemToString: (item) => item.name ?? item
});
@ -241,7 +242,7 @@ function SearchSuggestion() {
root: classes.searchItemIconBg,
img: classes.searchItemIcon
}}
src={`data:image/png;base64, ${suggestion.logo}`}
src={suggestion.logo ? `data:image/png;base64, ${suggestion.logo}` : ''}
>
<PhotoIcon className={classes.searchItemIcon} />
</Avatar>
@ -278,7 +279,7 @@ function SearchSuggestion() {
className={isOpen && !isLoading && !isFailedSearch ? classes.resultsWrapper : 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: {
@ -60,29 +61,70 @@ const useStyles = makeStyles(() => ({
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 +136,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 +162,8 @@ function Home() {
const renderRecentlyUpdated = () => {
return (
homeData &&
homeData.slice(0, 2).map((item, index) => {
recentData &&
recentData.map((item, index) => {
return (
<RepoCard
name={item.name}

View File

@ -20,7 +20,7 @@ import { TabContext, TabList, TabPanel } from '@mui/lab';
import RepoDetailsMetadata from './RepoDetailsMetadata';
import Loading from '../Shared/Loading';
import { isEmpty } from 'lodash';
import { isEmpty, uniq } from 'lodash';
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
import { mapToRepoFromRepoInfo } from 'utilities/objectModels';
@ -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'
}
@ -164,6 +164,7 @@ function RepoDetails() {
const classes = useStyles();
useEffect(() => {
setIsLoading(true);
api
.get(`${host()}${endpoints.detailedRepoInfo(name)}`, abortController.signal)
.then((response) => {
@ -197,30 +198,19 @@ function RepoDetails() {
const platformChips = () => {
const platforms = repoDetailData?.platforms || [];
const filteredPlatforms = platforms?.flatMap((platform) => [platform.Os, platform.Arch]);
return platforms.map((platform, index) => (
<Stack key={`stack${platform?.Os}${platform?.Arch}`} alignItems="center" direction="row" spacing={2}>
<Chip
key={`${name}${platform?.Os}${index}`}
label={platform?.Os}
onClick={handlePlatformChipClick}
sx={{
backgroundColor: '#E0E5EB',
color: '#52637A',
fontSize: '0.8125rem'
}}
/>
<Chip
key={`${name}${platform?.Arch}${index}`}
label={platform?.Arch}
onClick={handlePlatformChipClick}
sx={{
backgroundColor: '#E0E5EB',
color: '#52637A',
fontSize: '0.8125rem'
}}
/>
</Stack>
return uniq(filteredPlatforms).map((platform, index) => (
<Chip
key={`${name}${platform}${index}`}
label={platform}
onClick={handlePlatformChipClick}
sx={{
backgroundColor: '#E0E5EB',
color: '#52637A',
fontSize: '0.625rem'
}}
/>
));
};
@ -291,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

@ -75,7 +75,7 @@ export default function Tags(props) {
const [sortFilter, setSortFilter] = useState(tagsSortByCriteria.updateTimeDesc.value);
const renderTags = (tags) => {
const selectedSort = Object.values(tagsSortByCriteria).find((sc) => sc.value === sortFilter);
const filteredTags = tags.filter((t) => t.Tag?.includes(tagsFilter));
const filteredTags = tags.filter((t) => t.tag?.includes(tagsFilter));
if (selectedSort) {
filteredTags.sort(selectedSort.func);
}
@ -84,13 +84,11 @@ export default function Tags(props) {
filteredTags.map((tag) => {
return (
<TagCard
key={tag.Tag}
tag={tag.Tag}
lastUpdated={tag.LastUpdated}
digest={tag.Digest}
vendor={tag.Vendor}
size={tag.Size}
platform={tag.Platform}
key={tag.tag}
tag={tag.tag}
lastUpdated={tag.lastUpdated}
vendor={tag.vendor}
manifests={tag.manifests}
/>
);
})

View File

@ -16,7 +16,7 @@ import repocube4 from '../../assets/repocube-4.png';
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
import { Markdown } from 'utilities/MarkdowntojsxWrapper';
import { isEmpty } from 'lodash';
import { isEmpty, uniq } from 'lodash';
// temporary utility to get image
const randomIntFromInterval = (min, max) => {
@ -119,30 +119,18 @@ function RepoCard(props) {
};
const platformChips = () => {
const platformsOsArch = platforms || [];
return platformsOsArch.map((platform, index) => (
<Stack key={`stack${platform?.Os}${platform?.Arch}`} alignItems="center" direction="row" spacing={2}>
<Chip
key={`${name}${platform?.Os}${index}`}
label={platform?.Os}
onClick={handlePlatformChipClick}
sx={{
backgroundColor: '#E0E5EB',
color: '#52637A',
fontSize: '0.8125rem'
}}
/>
<Chip
key={`${name}${platform?.Arch}${index}`}
label={platform?.Arch}
onClick={handlePlatformChipClick}
sx={{
backgroundColor: '#E0E5EB',
color: '#52637A',
fontSize: '0.8125rem'
}}
/>
</Stack>
const filteredPlatforms = platforms?.flatMap((platform) => [platform.Os, platform.Arch]);
return uniq(filteredPlatforms).map((platform, index) => (
<Chip
key={`${name}${platform}${index}`}
label={platform}
onClick={handlePlatformChipClick}
sx={{
backgroundColor: '#E0E5EB',
color: '#52637A',
fontSize: '0.625rem'
}}
/>
));
};
@ -198,7 +186,7 @@ function RepoCard(props) {
{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}>

View File

@ -60,7 +60,7 @@ const useStyles = makeStyles(() => ({
}));
export default function TagCard(props) {
const { repoName, tag, lastUpdated, vendor, digest, size, platform } = props;
const { repoName, tag, lastUpdated, vendor, manifests } = props;
const [open, setOpen] = useState(false);
const classes = useStyles();
@ -70,11 +70,11 @@ export default function TagCard(props) {
: `Timestamp N/A`;
const navigate = useNavigate();
const goToTags = () => {
const goToTags = (digest = null) => {
if (repoName) {
navigate(`/image/${encodeURIComponent(repoName)}/tag/${tag}`);
navigate(`/image/${encodeURIComponent(repoName)}/tag/${tag}`, { state: { digest } });
} else {
navigate(`tag/${tag}`);
navigate(`tag/${tag}`, { state: { digest } });
}
};
@ -135,23 +135,38 @@ export default function TagCard(props) {
<Typography variant="body1"> Size </Typography>
</Grid>
</Grid>
<Grid container item xs={12} direction={'row'}>
<Grid item xs={6} md={4}>
<Tooltip title={digest || ''} placement="top">
<Typography variant="body1">{digest?.substr(0, 12)}</Typography>
</Tooltip>
{manifests.map((el) => (
<Grid container item xs={12} key={el.digest} direction={'row'}>
<Grid item xs={6} md={4}>
<Tooltip title={el.digest || ''} placement="top">
<Typography
variant="body1"
sx={{ color: '#1479FF', textDecorationLine: 'underline', cursor: 'pointer' }}
onClick={() => goToTags(el.digest)}
>
{el.digest?.substr(0, 12)}
</Typography>
</Tooltip>
</Grid>
<Grid item xs={6} md={4} sx={{ display: 'flex', justifyContent: 'center' }}>
<Typography variant="body1">
{el.platform?.Os}/{el.platform?.Arch}
</Typography>
</Grid>
<Grid
item
xs={0}
md={4}
className="hide-on-mobile"
sx={{ display: 'flex', justifyContent: 'flex-end' }}
>
<Typography sx={{ textAlign: 'right' }} variant="body1">
{transform.formatBytes(el.size)}
</Typography>
</Grid>
</Grid>
<Grid item xs={6} md={4} sx={{ display: 'flex', justifyContent: 'center' }}>
<Typography variant="body1">
{platform?.Os}/{platform?.Arch}
</Typography>
</Grid>
<Grid item xs={0} md={4} className="hide-on-mobile" sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Typography sx={{ textAlign: 'right' }} variant="body1">
{transform.formatBytes(size)}
</Typography>
</Grid>
</Grid>
))}
</Box>
</Collapse>
</CardContent>

View File

@ -148,10 +148,8 @@ function DependsOn(props) {
repoName={dependence.repoName}
tag={dependence.tag}
vendor={dependence.vendor}
platform={dependence.platform}
isSigned={dependence.isSigned}
size={dependence.size}
digest={dependence.digest}
manifests={dependence.manifests}
key={index}
lastUpdated={dependence.lastUpdated}
/>

View File

@ -1,14 +1,9 @@
import React, { useEffect, useMemo, useState } from 'react';
// utility
import { api, endpoints } from '../../../api';
// components
import { Divider, Stack, Typography } from '@mui/material';
import LayerCard from '../../Shared/LayerCard.jsx';
import makeStyles from '@mui/styles/makeStyles';
import { host } from '../../../host';
import { isEmpty } from 'lodash';
import Loading from '../../Shared/Loading';
const useStyles = makeStyles(() => ({
@ -83,25 +78,8 @@ function HistoryLayers(props) {
const { name, history } = props;
useEffect(() => {
if (history && !isEmpty(history)) {
setHistoryData(history);
setIsLoading(false);
} else {
api
.get(`${host()}${endpoints.layersDetailsForImage(name)}`, abortController.signal)
.then((response) => {
if (response.data && response.data.data) {
let layersHistory = response.data.data.Image;
setHistoryData(layersHistory?.History);
setIsLoading(false);
}
})
.catch((e) => {
console.error(e);
setHistoryData([]);
setIsLoading(false);
});
}
setHistoryData(history);
setIsLoading(false);
return () => {
abortController.abort();
};
@ -129,7 +107,7 @@ function HistoryLayers(props) {
<Loading />
) : (
<Stack direction="column" spacing={2} sx={{ marginTop: '1.7rem' }} data-testid="layer-card-container">
{historyData ? (
{historyData?.length > 0 ? (
historyData.map((layer, index) => {
return (
<LayerCard

View File

@ -148,10 +148,8 @@ function IsDependentOn(props) {
repoName={dependence.repoName}
tag={dependence.tag}
vendor={dependence.vendor}
platform={dependence.platform}
isSigned={dependence.isSigned}
size={dependence.size}
digest={dependence.digest}
manifests={dependence.manifests}
key={index}
lastUpdated={dependence.lastUpdated}
/>

View File

@ -1,4 +1,4 @@
import { useNavigate, useParams } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import React, { useEffect, useMemo, useState, useRef } from 'react';
// utility
@ -19,7 +19,8 @@ import {
MenuItem,
Tab,
Typography,
InputBase
InputBase,
InputLabel
} from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import makeStyles from '@mui/styles/makeStyles';
@ -37,7 +38,7 @@ import VulnerabilitiesDetails from './Tabs/VulnerabilitiesDetails';
import HistoryLayers from './Tabs/HistoryLayers';
import DependsOn from './Tabs/DependsOn';
import IsDependentOn from './Tabs/IsDependentOn';
import { isEmpty } from 'lodash';
import { isEmpty, head } from 'lodash';
import Loading from '../Shared/Loading';
import { dockerPull, podmanPull, skopeoPull } from 'utilities/pullStrings';
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
@ -212,6 +213,7 @@ const randomImage = () => {
function TagDetails() {
const [imageDetailData, setImageDetailData] = useState({});
const [selectedManifest, setSelectedManifest] = useState({});
const [isLoading, setIsLoading] = useState(true);
const [selectedTab, setSelectedTab] = useState('Layers');
const [selectedPullTab, setSelectedPullTab] = useState('');
@ -219,6 +221,10 @@ function TagDetails() {
const mounted = useRef(false);
const navigate = useNavigate();
// check for optional preselected digest
const { state } = useLocation() || {};
const { digest } = state || '';
// get url param from <Route here (i.e. image name)
const { reponame, tag } = useParams();
@ -239,6 +245,16 @@ function TagDetails() {
let imageInfo = response.data.data.Image;
let imageData = mapToImage(imageInfo);
setImageDetailData(imageData);
if (!isEmpty(digest)) {
const preselectedManifest = imageData.manifests?.find((el) => el.digest === digest);
if (preselectedManifest) {
setSelectedManifest(preselectedManifest);
} else {
setSelectedManifest(head(imageData.manifests));
}
} else {
setSelectedManifest(head(imageData.manifests));
}
setPullString(dockerPull(imageData.name));
setSelectedPullTab(dockerPull(imageData.name));
} else if (!isEmpty(response.data.errors)) {
@ -258,7 +274,7 @@ function TagDetails() {
}, [reponame, tag]);
const getPlatform = () => {
return imageDetailData?.platform ? imageDetailData.platform : '--/--';
return selectedManifest.platform ? selectedManifest.platform : '--/--';
};
const handleTabChange = (event, newValue) => {
@ -284,6 +300,11 @@ function TagDetails() {
return 'Pull Image';
};
const handleOSArchChange = (e) => {
const { value } = e.target;
setSelectedManifest(value);
};
return (
<>
{isLoading ? (
@ -327,9 +348,29 @@ function TagDetails() {
<SignatureIconCheck isSigned={imageDetailData.isSigned} />
{/* <BookmarkIcon sx={{color:"#52637A"}}/> */}
</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) && (
<Select
label="OS/Arch"
value={selectedManifest}
onChange={handleOSArchChange}
MenuProps={{ disableScrollLock: true }}
>
{imageDetailData.manifests.map((el) => (
<MenuItem key={el.digest} value={el}>
{`${el.platform?.Os}/${el.platform?.Arch}`}
</MenuItem>
))}
</Select>
)}
</FormControl>
</Stack>
</Stack>
<Typography gutterBottom className={classes.digest}>
DIGEST: {imageDetailData?.digest}
DIGEST: {selectedManifest?.digest}
</Typography>
</Grid>
<Grid item xs={0} md={4} className={`${classes.pull} hide-on-mobile`}>
@ -527,19 +568,19 @@ function TagDetails() {
<Grid container>
<Grid item xs={12}>
<TabPanel value="Layers" className={classes.tabPanel}>
<HistoryLayers name={imageDetailData.name} history={imageDetailData.history} />
<HistoryLayers name={imageDetailData.name} history={selectedManifest.history} />
</TabPanel>
<TabPanel value="DependsOn" className={classes.tabPanel}>
<DependsOn name={imageDetailData.name} />
<DependsOn name={imageDetailData.name} digest={selectedManifest.digest} />
</TabPanel>
<TabPanel value="IsDependentOn" className={classes.tabPanel}>
<IsDependentOn name={imageDetailData.name} />
<IsDependentOn name={imageDetailData.name} digest={selectedManifest.digest} />
</TabPanel>
<TabPanel value="Vulnerabilities" className={classes.tabPanel}>
<VulnerabilitiesDetails name={reponame} tag={tag} />
</TabPanel>
<TabPanel value="ReferredBy" className={classes.tabPanel}>
<ReferredBy repoName={reponame} digest={imageDetailData?.digest} />
<ReferredBy repoName={reponame} digest={selectedManifest?.digest} />
</TabPanel>
</Grid>
</Grid>
@ -549,8 +590,8 @@ function TagDetails() {
<Grid item xs={12} md={4} className={classes.metadata}>
<TagDetailsMetadata
platform={getPlatform()}
size={imageDetailData?.size}
lastUpdated={imageDetailData?.lastUpdated}
size={selectedManifest?.size}
lastUpdated={selectedManifest?.lastUpdated}
license={imageDetailData?.license}
/>
</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

@ -20,7 +20,7 @@ const mapToRepo = (responseRepo) => {
const mapToRepoFromRepoInfo = (responseRepoInfo) => {
return {
name: responseRepoInfo.Summary?.Name,
images: responseRepoInfo.Images,
images: responseRepoInfo.Images?.map((image) => mapToImage(image)) || [],
lastUpdated: responseRepoInfo.Summary?.LastUpdated,
size: responseRepoInfo.Summary?.Size,
platforms: responseRepoInfo.Summary?.Platforms,
@ -43,22 +43,40 @@ const mapToImage = (responseImage) => {
return {
repoName: responseImage.RepoName,
tag: responseImage.Tag,
lastUpdated: responseImage.LastUpdated,
manifests: responseImage.Manifests?.map((manifest) => mapToManifest(manifest)) || [],
size: responseImage.Size,
digest: responseImage.Digest || responseImage.ConfigDigest,
platform: responseImage.Platform,
vendor: responseImage.Vendor,
history: responseImage.History,
downloadCount: responseImage.DownloadCount,
lastUpdated: responseImage.LastUpdated,
description: responseImage.Description,
isSigned: responseImage.IsSigned,
license: responseImage.Licenses,
labels: responseImage.Labels,
title: responseImage.Title,
source: responseImage.Source,
documentation: responseImage.Documentation,
vendor: responseImage.Vendor,
authors: responseImage.Authors,
vulnerabiltySeverity: responseImage.Vulnerabilities?.MaxSeverity,
vulnerabilityCount: responseImage.Vulnerabilities?.Count,
isSigned: responseImage.IsSigned,
logo: responseImage.Logo,
// frontend only prop to increase interop with Repo objects and code reusability
name: `${responseImage.RepoName}:${responseImage.Tag}`
};
};
const mapToManifest = (responseManifest) => {
return {
digest: responseManifest.Digest,
configDigest: responseManifest.ConfigDigest,
lastUpdated: responseManifest.LastUpdated,
size: responseManifest.Size,
platform: responseManifest.Platform,
downloadCount: responseManifest.DownloadCount,
layers: responseManifest.Layers,
history: responseManifest.History,
vulnerabilities: responseManifest.Vulnerabilities
};
};
const mapCVEInfo = (cveInfo) => {
const cveList = cveInfo.map((cve) => {
return {
@ -79,4 +97,4 @@ const mapReferrer = (referrer) => ({
annotations: referrer.Annotations?.map((annotation) => ({ key: annotation.Key, value: annotation.Value }))
});
export { mapToRepo, mapToImage, mapToRepoFromRepoInfo, mapCVEInfo, mapReferrer };
export { mapToRepo, mapToImage, mapToRepoFromRepoInfo, mapCVEInfo, mapReferrer, mapToManifest };

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
};

View File

@ -32,28 +32,28 @@ export const tagsSortByCriteria = {
value: 'UPDATETIME_DESC',
label: 'Newest',
func: (a, b) => {
return DateTime.fromISO(b.LastUpdated).diff(DateTime.fromISO(a.LastUpdated));
return DateTime.fromISO(b.lastUpdated).diff(DateTime.fromISO(a.lastUpdated));
}
},
updateTime: {
value: 'UPDATETIME',
label: 'Oldest',
func: (a, b) => {
return DateTime.fromISO(a.LastUpdated).diff(DateTime.fromISO(b.LastUpdated));
return DateTime.fromISO(a.lastUpdated).diff(DateTime.fromISO(b.lastUpdated));
}
},
alphabetic: {
value: 'ALPHABETIC',
label: 'A - Z',
func: (a, b) => {
return a.Tag?.localeCompare(b.Tag);
return a.tag?.localeCompare(b.tag);
}
},
alphabeticDesc: {
value: 'ALPHABETIC_DESC',
label: 'Z - A',
func: (a, b) => {
return b.Tag?.localeCompare(a.Tag);
return b.tag?.localeCompare(a.tag);
}
}
};

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: ""

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

@ -0,0 +1,148 @@
#!/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)
image_metadata[image_name][tag]["multiarch"] = multiarch
return image_metadata
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[image_name][tag]
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,229 @@
#!/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 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}
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
# 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=$(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'
)
jq -n --arg image "${image}" --arg tag "${tag}" --argjson details "${details}" '.[$image][$tag]=$details' > ${metafile}