Compare commits
	
		
			7 Commits
		
	
	
		
			commit-4db
			...
			commit-2f9
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 2f94cc30ae | ||
|  | ddf1d9224b | ||
|  | 7471fb58a8 | ||
|  | 2b3058fb14 | ||
|  | ecff33fe01 | ||
|  | 60ca6d21d5 | ||
|  | 9029b97b47 | 
							
								
								
									
										110
									
								
								.github/workflows/end-to-end-test.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								.github/workflows/end-to-end-test.yml
									
									
									
									
										vendored
									
									
										Normal 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
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -8,6 +8,7 @@ | ||||
|  | ||||
| # testing | ||||
| /coverage | ||||
| /tests/data/ | ||||
|  | ||||
| # production | ||||
| /build | ||||
|   | ||||
							
								
								
									
										15
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								Makefile
									
									
									
									
									
								
							| @@ -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 | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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' } | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   | ||||
| @@ -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,8 +76,7 @@ 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); | ||||
|   | ||||
| @@ -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 ( | ||||
| @@ -72,6 +72,102 @@ const mockImage = { | ||||
|             } | ||||
|           } | ||||
|         ] | ||||
|       }, | ||||
|       { | ||||
|         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: { | ||||
| @@ -285,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', () => ({ | ||||
| @@ -328,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 />); | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
							
								
								
									
										15
									
								
								src/api.js
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								src/api.js
									
									
									
									
									
								
							| @@ -76,13 +76,18 @@ 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 {Manifests {Digest Platform {Os Arch}} Vulnerabilities {MaxSeverity Count} Tag LastUpdated Vendor Size } Summary {Name LastUpdated Size Platforms {Os Arch} Vendors NewestImage {RepoName IsSigned Vulnerabilities {MaxSeverity Count} Manifests {Digest} Tag Title Documentation DownloadCount Source Description Licenses}}}}`, | ||||
|     `/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 | ||||
|   | ||||
| @@ -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, | ||||
| @@ -269,7 +269,7 @@ function Explore() { | ||||
|     if (!isLoading && !isEndOfList) { | ||||
|       return <div ref={listBottom} />; | ||||
|     } | ||||
|     return ''; | ||||
|     return; | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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 | ||||
|   }); | ||||
|  | ||||
| @@ -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} | ||||
|   | ||||
| @@ -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} | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -1,8 +1,6 @@ | ||||
| // react global | ||||
| import React, { useState } from 'react'; | ||||
|  | ||||
| import { head } from 'lodash'; | ||||
|  | ||||
| // components | ||||
| import Typography from '@mui/material/Typography'; | ||||
| import { Card, CardContent, Divider, Stack, InputBase, FormControl, Select, InputLabel, MenuItem } from '@mui/material'; | ||||
| @@ -77,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); | ||||
|     } | ||||
| @@ -86,13 +84,11 @@ export default function Tags(props) { | ||||
|       filteredTags.map((tag) => { | ||||
|         return ( | ||||
|           <TagCard | ||||
|             key={tag.Tag} | ||||
|             tag={tag.Tag} | ||||
|             lastUpdated={tag.LastUpdated} | ||||
|             digest={head(tag.Manifests)?.Digest} | ||||
|             vendor={tag.Vendor} | ||||
|             size={tag.Size} | ||||
|             platform={head(tag.Manifests)?.Platform} | ||||
|             key={tag.tag} | ||||
|             tag={tag.tag} | ||||
|             lastUpdated={tag.lastUpdated} | ||||
|             vendor={tag.vendor} | ||||
|             manifests={tag.manifests} | ||||
|           /> | ||||
|         ); | ||||
|       }) | ||||
|   | ||||
| @@ -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}> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import React, { useEffect, useState, useMemo, useRef } from 'react'; | ||||
| import { isEmpty, head } from 'lodash'; | ||||
| import { isEmpty } from 'lodash'; | ||||
|  | ||||
| // utility | ||||
| import { api, endpoints } from '../../../api'; | ||||
| @@ -148,10 +148,8 @@ function DependsOn(props) { | ||||
|             repoName={dependence.repoName} | ||||
|             tag={dependence.tag} | ||||
|             vendor={dependence.vendor} | ||||
|             platform={head(dependence.manifests)?.platform} | ||||
|             isSigned={dependence.isSigned} | ||||
|             size={head(dependence.manifests)?.size} | ||||
|             digest={head(dependence.manifests)?.digest} | ||||
|             manifests={dependence.manifests} | ||||
|             key={index} | ||||
|             lastUpdated={dependence.lastUpdated} | ||||
|           /> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import React, { useEffect, useMemo, useState, useRef } from 'react'; | ||||
| import { isEmpty, head } from 'lodash'; | ||||
| import { isEmpty } from 'lodash'; | ||||
|  | ||||
| // utility | ||||
| import { api, endpoints } from '../../../api'; | ||||
| @@ -148,10 +148,8 @@ function IsDependentOn(props) { | ||||
|             repoName={dependence.repoName} | ||||
|             tag={dependence.tag} | ||||
|             vendor={dependence.vendor} | ||||
|             platform={head(dependence.manifests)?.platform} | ||||
|             isSigned={dependence.isSigned} | ||||
|             size={head(dependence.manifests)?.size} | ||||
|             digest={head(dependence.manifests)?.digest} | ||||
|             manifests={dependence.manifests} | ||||
|             key={index} | ||||
|             lastUpdated={dependence.lastUpdated} | ||||
|           /> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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()} | ||||
|   | ||||
| @@ -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'; | ||||
| @@ -220,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(); | ||||
|  | ||||
| @@ -240,7 +245,16 @@ function TagDetails() { | ||||
|           let imageInfo = response.data.data.Image; | ||||
|           let imageData = mapToImage(imageInfo); | ||||
|           setImageDetailData(imageData); | ||||
|           setSelectedManifest(head(imageData.manifests)); | ||||
|           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)) { | ||||
| @@ -286,6 +300,11 @@ function TagDetails() { | ||||
|     return 'Pull Image'; | ||||
|   }; | ||||
|  | ||||
|   const handleOSArchChange = (e) => { | ||||
|     const { value } = e.target; | ||||
|     setSelectedManifest(value); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {isLoading ? ( | ||||
| @@ -329,6 +348,26 @@ 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: {selectedManifest?.digest} | ||||
| @@ -541,7 +580,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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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, | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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 | ||||
| }; | ||||
|   | ||||
| @@ -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
									
								
							
							
						
						
									
										116
									
								
								tests/data/config.yaml
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										148
									
								
								tests/scripts/load_test_data.py
									
									
									
									
									
										Executable 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() | ||||
							
								
								
									
										229
									
								
								tests/scripts/pull_update_push_image.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										229
									
								
								tests/scripts/pull_update_push_image.sh
									
									
									
									
									
										Executable 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} | ||||
		Reference in New Issue
	
	Block a user