Skip to content

Commit

Permalink
feat: add manifest / multi-arch selection support
Browse files Browse the repository at this point in the history
### What does this PR do?

* Manifests are now listed, inspected and propagated within the Build
  page
* Simply select a manifest, select the arch and it will build

### Screenshot / video of UI

<!-- If this PR is changing UI, please include
screenshots or screencasts showing the difference -->

### What issues does this PR fix or reference?

<!-- Include any related issues from Podman Desktop
repository (or from another issue tracker). -->

Closes podman-desktop#324

### How to test this PR?

<!-- Please explain steps to reproduce -->

Follow the video above, or do the following tests:

1. Build a manifest within Podman Desktop (select two architectures, and
   build a bootc image). Must use the latest Podman Desktop
2. Select the manifest within the extension and see it build.

Signed-off-by: Charlie Drage <[email protected]>
  • Loading branch information
cdrage committed Apr 25, 2024
1 parent 008d266 commit f016b0b
Show file tree
Hide file tree
Showing 5 changed files with 336 additions and 17 deletions.
28 changes: 23 additions & 5 deletions packages/backend/src/api-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,30 @@ export class BootcApiImpl implements BootcApi {
async listBootcImages(): Promise<ImageInfo[]> {
let images: ImageInfo[] = [];
try {
const retrieveImages = await podmanDesktopApi.containerEngine.listImages();
images = retrieveImages.filter(image => {
if (image.Labels) {
return image.Labels['bootc'] ?? image.Labels['containers.bootc'];
const retrievedImages = await podmanDesktopApi.containerEngine.listImages();
const filteredImages: ImageInfo[] = [];
for (const image of retrievedImages) {
let includeImage = false;

// The image must have RepoTags and Labels to be considered a bootc image
if (image.RepoTags && image.Labels) {
// Convert to boolean by checking the string is non-empty
includeImage = !!(image.Labels['bootc'] ?? image.Labels['containers.bootc']);
} else if (image?.isManifest) {
// Manifests **usually** do not have any labels. If this is the case, we must find the images associated to the
// manifest in order to determine if we are going to return the manifest or not.
const manifestImages = await containerUtils.getImagesFromManifest(image, retrievedImages);
// Checking if any associated image has a non-empty label
includeImage = manifestImages.some(
manifestImage => !!(manifestImage.Labels['bootc'] ?? manifestImage.Labels['containers.bootc']),
);
}
});

if (includeImage) {
filteredImages.push(image);
}
}
images = filteredImages;
} catch (err) {
await podmanDesktopApi.window.showErrorMessage(`Error listing images: ${err}`);
console.error('Error listing images: ', err);
Expand Down
6 changes: 5 additions & 1 deletion packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,8 @@

// Image related
export const bootcImageBuilderContainerName = '-bootc-image-builder';
export const bootcImageBuilderName = 'quay.io/centos-bootc/bootc-image-builder:latest-1713548482';

// CHANGE IN THE FUTURE... MUST BE THE ONE WITH MANIFEST IMPLEMENTATION
// export const bootcImageBuilderName = 'quay.io/centos-bootc/bootc-image-builder:latest-1712917745';
// Below only works for arm64 M1 testing
export const bootcImageBuilderName = 'quay.io/bootc-extension/test-arm64-bib-image:latest';
32 changes: 32 additions & 0 deletions packages/backend/src/container-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,3 +317,35 @@ export async function removeContainerAndVolumes(engineId: string, container: str
throw new Error('There was an error removing the container and volumes: ' + e);
}
}

// Image retrieval for manifest
// Params: Pass in ImageInfo
// 1. Check if isManifest for ImageInfo is true
// 2. If true, perform a inspectManifest to get the manifest object
// 3. Go through the array of manifests and get the digest values's (sha256)
// 4. do listImages and filter out the images that have the same digest value
// 5. Return the imageInfo of all matching images (these are the images that are part of the manifest)
export async function getImagesFromManifest(
image: extensionApi.ImageInfo,
images: extensionApi.ImageInfo[],
): Promise<extensionApi.ImageInfo[]> {
if (!image.isManifest) {
throw new Error('Image is not a manifest');
}

// Get the manifest
const manifest = await inspectManifest(image.engineId, image.Id);

if (manifest.manifests === undefined) {
return [];
}

// Get the digest values, if there are no digests, make sure array is [] so we don't run into
// issues with the filter
const digestValues = manifest.manifests
.map(manifest => manifest.digest)
.filter(digest => digest !== undefined);

// Filter out the images that have the same digest value
return images.filter(image => image.Digest && digestValues.includes(image.Digest));
}
250 changes: 248 additions & 2 deletions packages/frontend/src/Build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@
***********************************************************************/

import { vi, test, expect } from 'vitest';
import { screen, render } from '@testing-library/svelte';
import { screen, render, waitFor } from '@testing-library/svelte';
import Build from './Build.svelte';
import type { BootcBuildInfo } from '/@shared/src/models/bootc';
import type { ImageInfo, ImageInspectInfo } from '@podman-desktop/api';
import type { ImageInfo, ImageInspectInfo, ManifestInspectInfo } from '@podman-desktop/api';
import { bootcClient } from './api/client';

const mockHistoryInfo: BootcBuildInfo[] = [
Expand Down Expand Up @@ -80,6 +80,79 @@ const mockBootcImages: ImageInfo[] = [
},
];

const mockImageInspect: ImageInspectInfo = {
Architecture: 'amd64',
engineId: '',
engineName: '',
Id: '',
RepoTags: [],
RepoDigests: [],
Parent: '',
Comment: '',
Created: '',
Container: '',
ContainerConfig: {
Hostname: '',
Domainname: '',
User: '',
AttachStdin: false,
AttachStdout: false,
AttachStderr: false,
ExposedPorts: {},
Tty: false,
OpenStdin: false,
StdinOnce: false,
Env: [],
Cmd: [],
ArgsEscaped: false,
Image: '',
Volumes: {},
WorkingDir: '',
Entrypoint: undefined,
OnBuild: undefined,
Labels: {},
},
DockerVersion: '',
Author: '',
Config: {
Hostname: '',
Domainname: '',
User: '',
AttachStdin: false,
AttachStdout: false,
AttachStderr: false,
ExposedPorts: {},
Tty: false,
OpenStdin: false,
StdinOnce: false,
Env: [],
Cmd: [],
ArgsEscaped: false,
Image: '',
Volumes: {},
WorkingDir: '',
Entrypoint: undefined,
OnBuild: [],
Labels: {},
},
Os: '',
Size: 0,
VirtualSize: 0,
GraphDriver: {
Name: '',
Data: {
DeviceId: '',
DeviceName: '',
DeviceSize: '',
},
},
RootFS: {
Type: '',
Layers: undefined,
BaseLayer: undefined,
},
};

vi.mock('./api/client', async () => {
return {
bootcClient: {
Expand All @@ -88,6 +161,7 @@ vi.mock('./api/client', async () => {
listHistoryInfo: vi.fn(),
listBootcImages: vi.fn(),
inspectImage: vi.fn(),
inspectManifest: vi.fn(),
},
rpcBrowser: {
subscribe: () => {
Expand All @@ -108,6 +182,7 @@ async function waitRender(customProperties?: object): Promise<void> {
}

test('Render shows correct images and history', async () => {
vi.mocked(bootcClient.inspectImage).mockResolvedValue(mockImageInspect);
vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo);
vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockBootcImages);
vi.mocked(bootcClient.buildExists).mockResolvedValue(false);
Expand Down Expand Up @@ -205,6 +280,9 @@ test('Check that overwriting an existing build works', async () => {
vi.mocked(bootcClient.checkPrereqs).mockResolvedValue(undefined);
vi.mocked(bootcClient.buildExists).mockResolvedValue(true);

// Mock the inspectImage to return 'amd64' as the architecture so it's selected / we can test the override function
vi.mocked(bootcClient.inspectImage).mockResolvedValue(mockImageInspect);

await waitRender({ imageName: 'image2', imageTag: 'latest' });

// Wait until children length is 2 meaning it's fully rendered / propagated the changes
Expand Down Expand Up @@ -343,3 +421,171 @@ test('In the rare case that Architecture from inspectImage is blank, do not sele
expect(x86_64).toBeDefined();
expect(x86_64.classList.contains('opacity-50'));
});

test('Do not show an image if it has no repotags and has isManifest as false', async () => {
const mockedImages: ImageInfo[] = [
{
Id: 'image1',
RepoTags: [],
Labels: {
bootc: 'true',
},
engineId: 'engine1',
engineName: 'engine1',
ParentId: 'parent1',
Created: 0,
VirtualSize: 0,
Size: 0,
Containers: 0,
SharedSize: 0,
Digest: 'sha256:image1',
isManifest: false,
},
];

vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo);
vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockedImages);
vi.mocked(bootcClient.buildExists).mockResolvedValue(false);
vi.mocked(bootcClient.checkPrereqs).mockResolvedValue(undefined);
await waitRender();

// Wait until children length is 1
while (screen.getByLabelText('image-select')?.children.length !== 1) {
await new Promise(resolve => setTimeout(resolve, 100));
}

const select = screen.getByLabelText('image-select');
expect(select).toBeDefined();
expect(select.children.length).toEqual(1);
expect(select.children[0].textContent).toEqual('Select an image');

// Find the <p> that CONTAINS "No bootable container compatible images found."
const noImages = screen.getByText(/No bootable container compatible images found./);
expect(noImages).toBeDefined();
});

test('If inspectImage fails, do not select any architecture / make them available', async () => {
vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo);
vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockBootcImages);
vi.mocked(bootcClient.checkPrereqs).mockResolvedValue(undefined);
vi.mocked(bootcClient.buildExists).mockResolvedValue(false);
vi.mocked(bootcClient.inspectImage).mockRejectedValue('Error');

await waitRender({ imageName: 'image2', imageTag: 'latest' });

// Wait until children length is 2 meaning it's fully rendered / propagated the changes
while (screen.getByLabelText('image-select')?.children.length !== 2) {
await new Promise(resolve => setTimeout(resolve, 100));
}

const arm64 = screen.getByLabelText('arm64-select');
expect(arm64).toBeDefined();
// Expect it to be "disabled" (opacity-50)
expect(arm64.classList.contains('opacity-50'));

const x86_64 = screen.getByLabelText('amd64-select');
expect(x86_64).toBeDefined();
expect(x86_64.classList.contains('opacity-50'));

// Expect Architecture must be selected to be shown
const validation = screen.getByLabelText('validation');
expect(validation).toBeDefined();
expect(validation.textContent).toEqual('Architecture must be selected');
});

test('Show the image if isManifest: true and Labels is empty', async () => {


// spy on inspectManifest
const spyOnInspectManifest = vi.spyOn(bootcClient, 'inspectManifest');

const mockedImages: ImageInfo[] = [
{
Id: 'image1',
RepoTags: ['testmanifest1:latest'],
Labels: {},
engineId: 'engine1',
engineName: 'engine1',
ParentId: 'parent1',
Created: 0,
VirtualSize: 0,
Size: 0,
Containers: 0,
SharedSize: 0,
Digest: 'sha256:image1',
isManifest: true,
},
// "children" images of a manifest that has the 'bootc' and 'containers.bootc' labels
// they have no repo tags, but have the labels / architecture
{
Id: 'image2',
RepoTags: [],
Labels: {
bootc: 'true',
},
engineId: 'engine1',
engineName: 'engine1',
ParentId: 'parent1',
Created: 0,
VirtualSize: 0,
Size: 0,
Containers: 0,
SharedSize: 0,
Digest: 'sha256:image2',
isManifest: false,
},
];

// FIX BELOW TESTS
const mockedManifestInspect: ManifestInspectInfo = {
engineId: 'podman1',
engineName: 'podman',
manifests: [
{
digest: 'sha256:image2',
mediaType: 'mediaType',
platform: {
architecture: 'amd64',
features: [],
os: 'os',
variant: 'variant',
},
size: 100,
urls: ['url1', 'url2'],
},
],
mediaType: 'mediaType',
schemaVersion: 1,
};


vi.mocked(bootcClient.inspectManifest).mockResolvedValue(mockedManifestInspect);
vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo);
vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockedImages);
vi.mocked(bootcClient.buildExists).mockResolvedValue(false);
vi.mocked(bootcClient.checkPrereqs).mockResolvedValue(undefined);
await waitRender();

// Wait until children length is 2
while (screen.getByLabelText('image-select')?.children.length !== 2) {
await new Promise(resolve => setTimeout(resolve, 100));
}

const select = screen.getByLabelText('image-select');
expect(select).toBeDefined();
expect(select.children.length).toEqual(2);
expect(select.children[1].textContent).toEqual('testmanifest1:latest');

// Expect input amd64 to be selected
const x86_64 = screen.getByLabelText('amd64-select');
expect(x86_64).toBeDefined();
// Expect it to be "selected"
expect(x86_64.classList.contains('bg-purple-500'));

// arm64 should be disabled
const arm64 = screen.getByLabelText('arm64-select');
expect(arm64).toBeDefined();
expect(arm64.classList.contains('opacity-50'));


});
Loading

0 comments on commit f016b0b

Please sign in to comment.