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 19, 2024
1 parent 82de2c0 commit 5d685f1
Show file tree
Hide file tree
Showing 5 changed files with 277 additions and 13 deletions.
29 changes: 26 additions & 3 deletions packages/backend/src/api-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,34 @@ export class BootcApiImpl implements BootcApi {
let images: ImageInfo[] = [];
try {
const retrieveImages = await podmanDesktopApi.containerEngine.listImages();
images = retrieveImages.filter(image => {
const filteredImages: ImageInfo[] = [];
for (const image of retrieveImages) {
let includeImage = false;

// If the image has no RepoTags, it is likely a <none> / tmp image and we should not be
// displaying it in the list of images.
if (!image.RepoTags) {
continue;
}

if (image.Labels) {
return image.Labels['bootc'] ?? image.Labels['containers.bootc'];
// 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);
// 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-1712917745';

// 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';
28 changes: 28 additions & 0 deletions packages/backend/src/container-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,3 +317,31 @@ 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): 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);

// 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);

// Get all images
const images = await extensionApi.containerEngine.listImages();

// Filter out the images that have the same digest value
return images.filter(image => image.Digest && digestValues.includes(image.Digest));
}
190 changes: 190 additions & 0 deletions packages/frontend/src/Build.spec.ts
Original file line number Diff line number Diff line change
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 Down Expand Up @@ -205,13 +279,18 @@ 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
while (screen.getByLabelText('image-select')?.children.length !== 2) {
await new Promise(resolve => setTimeout(resolve, 100));
}

console.log(screen.getByLabelText('validation').outerHTML);

const overwrite = screen.getByLabelText('Overwrite existing build');
expect(overwrite).toBeDefined();
const overwrite2 = screen.getByLabelText('overwrite-select');
Expand Down Expand Up @@ -343,3 +422,114 @@ 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');
});

/*
// UPDATE this, have to mock inspectManifest to return images that contain the 'bootc' and 'containers.bootc' labels
// or something similar to that.
test('Show the image if isManifest: true and Labels is empty', async () => {
const mockedImages: ImageInfo[] = [
{
Id: 'image1',
RepoTags: [],
Labels: {},
engineId: 'engine1',
engineName: 'engine1',
ParentId: 'parent1',
Created: 0,
VirtualSize: 0,
Size: 0,
Containers: 0,
SharedSize: 0,
Digest: 'sha256:image1',
isManifest: true,
},
];
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('Select an image');
});
*/
37 changes: 28 additions & 9 deletions packages/frontend/src/Build.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ async function validate() {
}
if (!buildArch) {
errorFormValidation = 'At least one architecture must be selected';
errorFormValidation = 'Architecture must be selected';
existingBuild = false;
return;
}
Expand Down Expand Up @@ -228,13 +228,30 @@ onMount(async () => {
async function updateAvailableArchitectures(selectedImage: string) {
const image = findImage(selectedImage);
if (image) {
try {
const imageInspect = await bootcClient.inspectImage(image);
if (imageInspect?.Architecture) {
availableArchitectures = [imageInspect.Architecture];
// If it is a manifest, we can just inspectManifest and get the architecture(s) from there
if (image?.isManifest) {
try {
const manifest = await bootcClient.inspectManifest(image);
// Go through each manifest.manifests and get the architecture from manifest.platform.architecture
availableArchitectures = manifest.manifests.map(manifest => manifest.platform.architecture);
} catch (error) {
console.error('Error inspecting manifest:', error);
}
} else {
try {
const imageInspect = await bootcClient.inspectImage(image);
// Architecture is a mandatory field in the image inspect and should **always** be there.
if (imageInspect?.Architecture) {
availableArchitectures = [imageInspect.Architecture];
} else {
// If for SOME reason Architecture is missing (testing purposes, weird output, etc.)
// we will set availableArchitectures to an empty array to disable the architecture selection.
availableArchitectures = [];
console.error('Architecture not found in image inspect:', imageInspect);
}
} catch (error) {
console.error('Error inspecting image:', error);
}
} catch (error) {
console.error('Error inspecting image:', error);
}
}
}
Expand All @@ -253,11 +270,13 @@ $: if (selectedImage) {
}
$: if (availableArchitectures) {
// If there is only ONE available architecture, select it automatically.
if (availableArchitectures.length === 1) {
// If there is only ONE available architecture, select it automatically.
buildArch = availableArchitectures[0];
// If none, disable buildArch selection regardless of what was selected before in history, etc.
} else if (availableArchitectures.length > 1 && buildArch && !availableArchitectures.includes(buildArch)) {
buildArch = undefined;
} else if (availableArchitectures.length === 0) {
// If none, disable buildArch selection regardless of what was selected before in history, etc.
buildArch = undefined;
}
}
Expand Down

0 comments on commit 5d685f1

Please sign in to comment.