Skip to content

Commit

Permalink
feat: Adds option to pull arm64/amd64 examples (#1196)
Browse files Browse the repository at this point in the history
* feat: Adds option to pull arm64/amd64 examples

### What does this PR do?

* Adds the ability to choose what initial arm64 or amd64 image example
  you want
* Defaults to native architecture when initially loading
* Pulls the architecture correctly

### 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 #1154

### How to test this PR?

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

1. Go to examples page
2. Select from the drop down the arch you want
3. Pull the image & build

Signed-off-by: Charlie Drage <[email protected]>

* chore: update based on review

Signed-off-by: Charlie Drage <[email protected]>

* chore: update to use getArch

Signed-off-by: Charlie Drage <[email protected]>

---------

Signed-off-by: Charlie Drage <[email protected]>
  • Loading branch information
cdrage authored Jan 23, 2025
1 parent 89a1205 commit 750470b
Show file tree
Hide file tree
Showing 8 changed files with 122 additions and 39 deletions.
6 changes: 3 additions & 3 deletions packages/backend/assets/examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"image": "registry.gitlab.com/fedora/bootc/examples/httpd",
"tag": "latest",
"categories": ["fedora"],
"architectures": ["amd64"],
"architectures": ["amd64", "arm64"],
"basedir": "httpd",
"size": 2000000000,
"readme": "# HTTPD\n\nThis example provides an Apache HTTPD server exposed on port 80.\n\n## Building this example\n\n1. Build the image with either `podman build` or [Podman Desktop](https://podman-desktop.io/).\n2. (Optional for login and SSH access) Create a [\"config.toml\" or \"config.json\" build config](https://docs.fedoraproject.org/en-US/bootc/authentication/#_bootc_image_builder) that contains login and SSH information.\n3. Build the image with either [bootc-image-builder](https://github.com/osbuild/bootc-image-builder) or the [Podman Desktop BootC extension](https://github.com/containers/podman-desktop-extension-bootc) and (optionally) the \"config.toml\" you created.\n\n## Using this example\n\n1. Launch the virtual machine.\n2. Visit the VM IP address `http://<ip-address>` on your browser."
Expand All @@ -22,7 +22,7 @@
"image": "registry.gitlab.com/fedora/bootc/examples/tailscale",
"tag": "latest",
"categories": ["fedora"],
"architectures": ["amd64"],
"architectures": ["amd64", "arm64"],
"basedir": "tailscale",
"size": 1720000000,
"readme": "# Tailscale\n\nThis example provides a Tailscale service installed to be used for networking / VPN access.\n\n## Building this example\n\n1. Build the image with either `podman build` or [Podman Desktop](https://podman-desktop.io/).\n2. (Optional for login and SSH access) Create a [\"config.toml\" or \"config.json\" build config](https://docs.fedoraproject.org/en-US/bootc/authentication/#_bootc_image_builder) that contains login and SSH information.\n3. Build the image with either [bootc-image-builder](https://github.com/osbuild/bootc-image-builder) or the [Podman Desktop BootC extension](https://github.com/containers/podman-desktop-extension-bootc) and (optionally) the \"config.toml\" you created.\n\n## Using this example\n\n1. Launch the virtual machine.\n2. Login with either SSH or your credentials.\n3. Run `tailscale up`."
Expand All @@ -35,7 +35,7 @@
"image": "registry.gitlab.com/fedora/bootc/examples/app-podman-systemd",
"tag": "latest",
"categories": ["fedora"],
"architectures": ["amd64"],
"architectures": ["amd64", "arm64"],
"basedir": "app-podman-systemd",
"size": 1530000000,
"readme": "# Podman Container running via SystemD\n\nThis is a very simple image that runs a webserver (caddy) as a \"referenced\" container image via [podman-systemd](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html) that is also configured for automatic updates.\n\n## Building this example\n\n1. Build the image with either `podman build` or [Podman Desktop](https://podman-desktop.io/).\n2. (Optional for login and SSH access) Create a [\"config.toml\" or \"config.json\" build config](https://docs.fedoraproject.org/en-US/bootc/authentication/#_bootc_image_builder) that contains login and SSH information.\n3. Build the image with either [bootc-image-builder](https://github.com/osbuild/bootc-image-builder) or the [Podman Desktop BootC extension](https://github.com/containers/podman-desktop-extension-bootc) and (optionally) the \"config.toml\" you created.\n\n## Using this example\n\n1. Launch the virtual machine.\n2. Visit the VM IP address `http://<ip-address>` on your browser."
Expand Down
12 changes: 8 additions & 4 deletions packages/backend/src/api-impl.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
* Copyright (C) 2024-2025 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -25,7 +25,7 @@ import { History } from './history';
import * as containerUtils from './container-utils';
import { Messages } from '/@shared/src/messages/Messages';
import { telemetryLogger } from './extension';
import { checkPrereqs, isLinux, isMac, isWindows, getUidGid } from './machine-utils';
import { checkPrereqs, isLinux, isMac, isWindows, getUidGid, getArch } from './machine-utils';
import * as fs from 'node:fs';
import path from 'node:path';
import { getContainerEngine } from './container-utils';
Expand Down Expand Up @@ -252,11 +252,11 @@ export class BootcApiImpl implements BootcApi {
}

// Pull an image from the registry
async pullImage(imageName: string): Promise<void> {
async pullImage(imageName: string, arch?: string): Promise<void> {
let success: boolean = false;
let error: string = '';
try {
await containerUtils.pullImage(await getContainerEngine(), imageName);
await containerUtils.pullImage(await getContainerEngine(), imageName, arch);
success = true;
} catch (err) {
await podmanDesktopApi.window.showErrorMessage(`Error pulling image: ${err}`);
Expand Down Expand Up @@ -290,6 +290,10 @@ export class BootcApiImpl implements BootcApi {
return isWindows();
}

async getArch(): Promise<string> {
return getArch();
}

async getUidGid(): Promise<string> {
return getUidGid();
}
Expand Down
14 changes: 11 additions & 3 deletions packages/backend/src/container-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
* Copyright (C) 2024-2025 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -68,13 +68,21 @@ export async function inspectManifest(engineId: string, image: string): Promise<
}

// Pull the image
export async function pullImage(connection: ContainerProviderConnection, image: string) {
export async function pullImage(connection: ContainerProviderConnection, image: string, arch?: string) {
// Throughout bootc-image-builder and bootc, we just use "arm64" and "amd64" for the architecture,
// make sure that arch is either "arm64" or "amd64" before passing it to the API, and rename it to linux/arm64 or linux/amd64
// otherwise we just leave it as undefined.
if (arch && (arch === 'arm64' || arch === 'amd64')) {
arch = `linux/${arch}`;
} else {
arch = undefined;
}
const telemetryData: Record<string, unknown> = {};
telemetryData.image = image;

console.log('Pulling image: ', image);
try {
await extensionApi.containerEngine.pullImage(connection, image, () => {});
await extensionApi.containerEngine.pullImage(connection, image, () => {}, arch);
telemetryData.success = true;
} catch (e) {
console.error(e);
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/machine-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ export function isMac(): boolean {
return env.isMac;
}

export function getArch(): string {
return os.arch();
}

export function isArm(): boolean {
return os.arch() === 'arm64';
}
Expand Down
3 changes: 2 additions & 1 deletion packages/frontend/src/Examples.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
* Copyright (C) 2024-2025 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -32,6 +32,7 @@ vi.mock('./api/client', async () => {
bootcClient: {
getExamples: vi.fn(),
listBootcImages: vi.fn(),
getArch: vi.fn(),
},
rpcBrowser: {
subscribe: () => {
Expand Down
37 changes: 33 additions & 4 deletions packages/frontend/src/lib/ExampleCard.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
* Copyright (C) 2024-2025 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -28,6 +28,7 @@ import type { Example, ExampleState } from '/@shared/src/models/examples';
vi.mock('../api/client', () => {
return {
bootcClient: {
getArch: vi.fn(),
pullImage: vi.fn(),
openLink: vi.fn(),
telemetryLogUsage: vi.fn(),
Expand All @@ -53,11 +54,14 @@ const example = {
categories: ['Category 1'],
image: 'quay.io/example/example1',
tag: 'latest',
architectures: ['amd64'],
architectures: ['amd64', 'arm64'],
state: 'unpulled',
} as Example;

test('renders ExampleCard with correct content', async () => {
// Mock to be amd64
vi.mocked(bootcClient.getArch).mockResolvedValue('x64');

// Render the component with the example prop
render(ExampleCard, { props: { example } });

Expand All @@ -69,7 +73,7 @@ test('renders ExampleCard with correct content', async () => {
expect(exampleDescription).toBeInTheDocument();

// Verify architectures are displayed
const architectureText = screen.getByText('amd64');
const architectureText = screen.getByText('AMD64');
expect(architectureText).toBeInTheDocument();
});

Expand All @@ -86,6 +90,9 @@ test('redirection to /example/:id is called when More details button is clicked'
});

test('pullImage function is called when Pull image button is clicked', async () => {
// Mock to be amd64
vi.mocked(bootcClient.getArch).mockResolvedValue('x64');

// Render the component with the example prop (state is 'unpulled')
render(ExampleCard, { props: { example } });

Expand All @@ -95,7 +102,7 @@ test('pullImage function is called when Pull image button is clicked', async ()
await fireEvent.click(pullButton);

// Ensure bootcClient.pullImage is called with the correct image name
expect(bootcClient.pullImage).toHaveBeenCalledWith('quay.io/example/example1');
expect(bootcClient.pullImage).toHaveBeenCalledWith('quay.io/example/example1', 'amd64');

expect(bootcClient.telemetryLogUsage).toHaveBeenCalled();
});
Expand All @@ -119,3 +126,25 @@ test('Build image button is displayed if example is pulled', async () => {

expect(bootcClient.telemetryLogUsage).toHaveBeenCalled();
});

test('Expect to have a dropdown with two options, AMD64 and ARM64', async () => {
// Mock to be amd64
vi.mocked(bootcClient.getArch).mockResolvedValue('x64');

render(ExampleCard, { props: { example } });

// Find the dropdown (by default it'll be AMD64)
const dropdown = screen.getByRole('button', { name: 'AMD64' });
await fireEvent.click(dropdown);

// There may be "multiple" buttons due to the original button showing AMD64, but simply query to see if there exists
// any buttons with AMD64 and ARM64
const amd64Option = screen.queryAllByRole('button', { name: 'AMD64' });
const arm64Option = screen.queryAllByRole('button', { name: 'ARM64' });

// Expect amd64 option to have length 2 since the "default" button is shown as AMD64, then there is the dropdown.
expect(amd64Option).toHaveLength(2);

// Expect 1
expect(arm64Option).toHaveLength(1);
});
80 changes: 58 additions & 22 deletions packages/frontend/src/lib/ExampleCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
import type { Example } from '/@shared/src/models/examples';
import { faArrowDown, faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons';
import { bootcClient } from '/@/api/client';
import { Button } from '@podman-desktop/ui-svelte';
import { Button, Dropdown } from '@podman-desktop/ui-svelte';
import { router } from 'tinro';
import DiskImageIcon from './DiskImageIcon.svelte';
import { filesize } from 'filesize';
import { onMount } from 'svelte';
interface Props {
example: Example;
Expand All @@ -15,15 +16,18 @@ let { example }: Props = $props();
let pullInProgress = $state(false);
let selectedArch = $state('');
let archOptions = [];
async function openURL(): Promise<void> {
router.goto(`/example/${example.id}`);
}
async function pullImage(): Promise<void> {
async function pullImage(arch?: string): Promise<void> {
if (example.image) {
pullInProgress = true;
bootcClient.telemetryLogUsage('example-pull-image', { image: example.image });
bootcClient.pullImage(example.image);
bootcClient.telemetryLogUsage('example-pull-image', { image: example.image, arch: arch });
bootcClient.pullImage(example.image, arch);
}
}
Expand All @@ -33,6 +37,32 @@ async function gotoBuild(): Promise<void> {
router.goto(`/disk-images/build/${encodeURIComponent(example.image)}/${encodeURIComponent(example.tag)}`);
}
}
onMount(async () => {
// Get the default architecture based on `isArm`
let defaultArch: string;
// ONLY accept ARM and X86 architectures for now as
// we don't support other architectures (yet) since they are not yet hosted / as common (ex. s390x)
// if so, we just set it as undefined and let the user choose
const hostArch = await bootcClient.getArch();
if (hostArch === 'arm64') {
defaultArch = 'arm64';
} else if (hostArch === 'x64') {
defaultArch = 'amd64';
}
// Generate options dynamically with labels in uppercase
if (example.architectures) {
archOptions = example.architectures.map(arch => ({
label: arch.toUpperCase(),
value: arch,
}));
// Ensure the selected architecture matches one of the options
selectedArch = archOptions.find(option => option.value === defaultArch)?.value || archOptions[0]?.value || '';
}
});
</script>

<div class="no-underline">
Expand All @@ -42,25 +72,31 @@ async function gotoBuild(): Promise<void> {
aria-label={example.name}>
<!-- Show 'architectures' in small font at the bottom-->
{#if example.architectures}
<div class="flex flex-row mb-1">
<div class="flex-grow text-[var(--pd-content-card-text)] opacity-50 text-xs uppercase">
{#each example.architectures as architecture}
<span class="mr-1">{architecture}</span>
{/each}
<div class="flex flex-row mb-1 justify-between items-center">
<div class="flex flex-row">
{#if example.size}
<div class="text-[var(--pd-content-card-text)] opacity-50 text-xs uppercase mr-1">
<span>{filesize(example.size)}</span>
</div>
{/if}

{#if example.tag}
<div class="text-[var(--pd-content-card-text)] opacity-50 text-xs uppercase">
<span>{example.tag}</span>
</div>
{/if}
</div>

{#if example.size}
<div class="text-[var(--pd-content-card-text)] opacity-50 text-xs uppercase mr-1">
<span>{filesize(example.size)}</span>
</div>
{/if}

<!-- Show example.tag but far right -->
{#if example.tag}
<div class="text-[var(--pd-content-card-text)] opacity-50 text-xs uppercase">
<span>{example.tag}</span>
</div>
{/if}
<div class="text-xs">
<Dropdown
name="archChoice"
id="archChoice"
class="text-xs"
disabled={example?.state === 'pulled'}
bind:value={selectedArch}
options={example.architectures.map(arch => ({ label: arch.toUpperCase(), value: arch }))}>
</Dropdown>
</div>
</div>
{/if}

Expand Down Expand Up @@ -88,7 +124,7 @@ async function gotoBuild(): Promise<void> {
>Build image</Button>
{:else if example?.state === 'unpulled'}
<Button
on:click={pullImage}
on:click={() => pullImage(selectedArch)}
icon={faArrowDown}
aria-label="Pull image"
title="Pull image"
Expand Down
5 changes: 3 additions & 2 deletions packages/shared/src/BootcAPI.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
* Copyright (C) 2024-2025 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -26,7 +26,7 @@ export abstract class BootcApi {
abstract launchVM(build: BootcBuildInfo): Promise<void>;
abstract buildExists(folder: string, types: BuildType[]): Promise<boolean>;
abstract buildImage(build: BootcBuildInfo, overwrite?: boolean): Promise<void>;
abstract pullImage(image: string): Promise<void>;
abstract pullImage(image: string, arch?: string): Promise<void>;
abstract inspectImage(image: ImageInfo): Promise<ImageInspectInfo>;
abstract inspectManifest(image: ImageInfo): Promise<ManifestInspectInfo>;
abstract deleteBuilds(builds: BootcBuildInfo[]): Promise<void>;
Expand All @@ -41,6 +41,7 @@ export abstract class BootcApi {
abstract isLinux(): Promise<boolean>;
abstract isMac(): Promise<boolean>;
abstract isWindows(): Promise<boolean>;
abstract getArch(): Promise<string>;
abstract getUidGid(): Promise<string>;
abstract getExamples(): Promise<ExamplesList>;
abstract loadLogsFromFolder(folder: string): Promise<string>;
Expand Down

0 comments on commit 750470b

Please sign in to comment.