This commit is contained in:
Tomáš Koscielniak 2025-08-23 23:54:30 +03:00 committed by GitHub
commit 57fdeba788
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 540 additions and 2 deletions

90
.github/workflows/boot-playwright.yml vendored Normal file
View file

@ -0,0 +1,90 @@
name: Playwright nightly boot tests
on:
pull_request:
types: [opened, reopened, synchronize, labeled, unlabeled]
workflow_dispatch:
merge_group:
# this prevents multiple jobs from the same pr
# running when new changes are pushed.
concurrency:
group: ${{github.workflow}}-${{ github.ref }}
cancel-in-progress: true
jobs:
playwright-tests:
runs-on:
- codebuild-image-builder-frontend-${{ github.run_id }}-${{ github.run_attempt }}
- instance-size:large
- buildspec-override:true
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Get current PR URL
id: get-pr-url
run: |
# Extract the pull request URL from the event payload
pr_url=$(jq -r '.pull_request.html_url' < "$GITHUB_EVENT_PATH")
echo "Pull Request URL: $pr_url"
# Set the PR URL as an output using the environment file
echo "pr_url=$pr_url" >> $GITHUB_ENV
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
- name: Install front-end dependencies
run: npm ci
- name: Install playwright
run: npx playwright install --with-deps
# This prevents an error related to minimum watchers when running the front-end and playwright
- name: Increase file watchers limit
run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p
- name: Update /etc/hosts
run: sudo npm run patch:hosts
- name: Start front-end server
run: |
npm run start:federated &
npx wait-on http://localhost:8003/apps/image-builder/
- name: Run testing proxy
run: docker run -d --network=host -e HTTPS_PROXY=$RH_PROXY_URL -v "$(pwd)/config:/config:ro,Z" --name consoledot-testing-proxy quay.io/dvagner/consoledot-testing-proxy
- name: Run front-end Playwright tests
env:
BASE_URL: https://stage.foo.redhat.com:1337
run: |
export PLAYWRIGHT_USER=image-builder-playwright-$RANDOM
export PLAYWRIGHT_PASSWORD=image-builder-playwright-$(uuidgen)
# Step 1: Create a new empty account
curl -k -X POST https://account-manager-stage.app.eng.rdu2.redhat.com/account/new -d "{\"username\": \"$PLAYWRIGHT_USER\", \"password\":\"$PLAYWRIGHT_PASSWORD\"}"
# Step 2: Attach subscriptions to the new account
curl -k -X POST https://account-manager-stage.app.eng.rdu2.redhat.com/account/attach \
-d "{\"username\": \"$PLAYWRIGHT_USER\", \"password\":\"$PLAYWRIGHT_PASSWORD\", \"sku\":[\"RH00003\"],\"quantity\": 1}"
# Step 3: Activate the new account by accepting Terms and Conditions
curl -k -X POST https://account-manager-stage.app.eng.rdu2.redhat.com/account/activate -d "{\"username\": \"$PLAYWRIGHT_USER\", \"password\":\"$PLAYWRIGHT_PASSWORD\"}"
# Step 4: Refresh account to update subscription pools
curl -k -X POST https://account-manager-stage.app.eng.rdu2.redhat.com/account/refresh -d "{\"username\": \"$PLAYWRIGHT_USER\", \"password\":\"$PLAYWRIGHT_PASSWORD\"}"
# Step 5: View account to check account status
curl -k -X GET "https://account-manager-stage.app.eng.rdu2.redhat.com/account/get?username=$PLAYWRIGHT_USER&password=$PLAYWRIGHT_PASSWORD"
CURRENTS_PROJECT_ID=hIU6nO CURRENTS_RECORD_KEY=$CURRENTS_RECORD_KEY npx playwright test playwright/BootTests
- name: Store front-end Test report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 10

View file

@ -14,6 +14,7 @@ concurrency:
jobs:
playwright-tests:
if: false # Temporarily disabled
runs-on:
- codebuild-image-builder-frontend-${{ github.run_id }}-${{ github.run_attempt }}
- instance-size:large
@ -79,7 +80,7 @@ jobs:
# Step 5: View account to check account status
curl -k -X GET "https://account-manager-stage.app.eng.rdu2.redhat.com/account/get?username=$PLAYWRIGHT_USER&password=$PLAYWRIGHT_PASSWORD"
CURRENTS_PROJECT_ID=hIU6nO CURRENTS_RECORD_KEY=$CURRENTS_RECORD_KEY npx playwright test
CURRENTS_PROJECT_ID=hIU6nO CURRENTS_RECORD_KEY=$CURRENTS_RECORD_KEY npx playwright test playwright/Customizations
- name: Store front-end Test report
uses: actions/upload-artifact@v4

1
.gitignore vendored
View file

@ -49,3 +49,4 @@ rpmbuild
/playwright/.cache/
.env
.auth
/image-downloads/

View file

@ -8,10 +8,15 @@ phases:
- echo Entered the install phase...
- nohup /usr/local/bin/dockerd --host=unix:///var/run/docker.sock --host=tcp://127.0.0.1:2375 --storage-driver=overlay2 &
- timeout 15 sh -c "until docker info; do echo .; sleep 1; done"
- apt-get install -y openssh-client python3
- pip install python-openstackclient
pre_build:
commands:
- echo Entered the pre_build phase...
- echo Adding SSH key to the agent...
- eval "$(ssh-agent -s)"
- echo "$OS_SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
build:
commands:

View file

@ -171,4 +171,14 @@ module.exports = defineConfig([
]
},
},
{
files: [
'playwright/BootTests/helpers/OpenStackWrapper.ts',
'playwright/BootTests/helpers/imageBuilding.ts',
],
rules: {
'no-console': 'off',
},
},
]);

View file

@ -5,7 +5,11 @@ import {
} from '@playwright/test';
import 'dotenv/config';
const reporters: ReporterDescription[] = [['html'], ['list']];
const reporters: ReporterDescription[] = [['html']];
if (!process.env.CI) {
reporters.push(['list']);
}
if (process.env.CURRENTS_PROJECT_ID && process.env.CURRENTS_RECORD_KEY) {
reporters.push(['@currents/playwright']);

View file

@ -0,0 +1,81 @@
import { expect } from '@playwright/test';
import { v4 as uuidv4 } from 'uuid';
import {
buildImage,
constructFilePath,
downloadImage,
} from './helpers/imageBuilding';
import { OpenStackWrapper } from './helpers/OpenStackWrapper';
import { navigateToWizard, selectTarget } from './helpers/targetChooser';
import { test } from '../fixtures/customizations';
import { isHosted } from '../helpers/helpers';
import { ensureAuthenticated } from '../helpers/login';
import { ibFrame, navigateToLandingPage } from '../helpers/navHelpers';
import {
createBlueprint,
deleteBlueprint,
fillInDetails,
registerLater,
} from '../helpers/wizardHelpers';
test('Boot qcow2 image and test hostname', async ({ page, cleanup }) => {
test.setTimeout(120 * 60 * 1000); // 2 hours
test.skip(
!isHosted(),
'Skipping test. Boot test run only on the hosted service.',
);
const blueprintName = 'boot-test-qcow-' + uuidv4();
const filePath = constructFilePath(blueprintName, 'qcow2');
// Delete the blueprint and Openstack resources after the run
await cleanup.add(() => deleteBlueprint(page, blueprintName));
await cleanup.add(() => OpenStackWrapper.deleteImage(blueprintName));
await cleanup.add(() => OpenStackWrapper.deleteInstance(blueprintName));
await ensureAuthenticated(page);
await navigateToLandingPage(page);
const frame = await ibFrame(page);
await test.step('Select target', async () => {
await navigateToWizard(frame);
await selectTarget(frame, 'qcow2');
});
await test.step('Register later', async () => {
await registerLater(frame);
await frame.getByRole('button', { name: 'Review and finish' }).click();
});
await test.step('Fill the BP details', async () => {
await fillInDetails(frame, blueprintName);
});
await test.step('Create BP', async () => {
await createBlueprint(frame, blueprintName);
});
await test.step('Build the image', async () => {
await buildImage(page);
});
await test.step('Download the image', async () => {
await downloadImage(page, filePath);
});
// Initialize Openstack wrapper
const image = new OpenStackWrapper(blueprintName, 'qcow2', filePath);
await test.step('Prepare Openstack instance', async () => {
await image.createImage();
await image.launchInstance();
});
await test.step('Test if the image booted', async () => {
const [exitCode, output] = await image.exec('echo "Hello World"');
expect(exitCode).toBe(0);
expect(output).toContain('Hello World');
});
});

View file

@ -0,0 +1,246 @@
import { exec } from 'child_process';
import { test } from '@playwright/test';
export class OpenStackWrapper {
private ipAddress: string;
private imageName: string;
private instanceName: string;
private diskFormat: string;
private imageFilePath: string;
// Add an option to use environment variable for local debugging
private keyName: string =
process.env.OS_SSH_KEY_NAME ?? 'image-builder-frontend-ci';
private canConnect: boolean;
/**
* This class serves as a wrapper around the OpenStack CLI.
* It provides methods to create an image, launch an instance, and execute commands on the instance.
* @param imageName - The name of the image to create.
* @param diskFormat - The disk format of the image.
* @param imageFilePath - The path to the image file.
* @param instanceName - The name of the instance to launch. If not provided, the image name is used.
*/
public constructor(
imageName: string,
diskFormat: string,
imageFilePath: string,
instanceName?: string,
) {
this.imageName = imageName;
this.instanceName = instanceName ?? imageName;
this.diskFormat = diskFormat;
this.imageFilePath = imageFilePath;
this.canConnect = false;
}
/**
* Wrapper around exec so it can be simply called as await execCommand(...).
* Executes a command and returns the output.
* @param command - The command to execute.
* @throws Error if command fails
* @returns stdout
*/
private static async execCommand(command: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
exec(command, (error, stdout) => {
if (error) {
// Reject the promise with an error that includes stderr
reject(new Error(`Command failed: ${error.message}`));
return;
}
resolve(stdout);
});
});
}
/**
* Upload an image to Openstack.
*/
public async createImage(): Promise<void> {
const sleepTime = 10000; // 10 seconds
const retries = 40; // 40 * 10 seconds = 4 minutes for image to be in 'active' state
try {
console.log(`Uploading image ${this.imageName} to Openstack`);
await OpenStackWrapper.execCommand(
`openstack image create -f json --disk-format="${this.diskFormat}" --file=${this.imageFilePath} ${this.imageName}`,
);
// Wait until the image is in 'active' state
for (let i = 0; i < retries; i++) {
const output = await OpenStackWrapper.execCommand(
`openstack image show -f json ${this.imageName}`,
);
const image = JSON.parse(output);
if (image.status === 'active') {
console.log(
`Image ${this.imageName} successfully uploaded to Openstack`,
);
return;
}
// Wait before checking again
await new Promise((resolve) => setTimeout(resolve, sleepTime));
}
// If the image is not ready after the retries, throw an error
throw new OpenStackError(
`Instance ${this.imageName} didn't launch after 10 minutes.`,
);
} catch (error) {
console.error(`Error creating image: ${error}`);
throw error;
}
}
/**
* Launch an instance from the created image.
*/
public async launchInstance(): Promise<void> {
const sleepTime = 30000; // 30 seconds
const retries = 20; // 20 * 30 seconds = 10 minutes to launch the instance
try {
console.log(
`Launching instance ${this.instanceName} from image ${this.imageName}`,
);
const output = await OpenStackWrapper.execCommand(
`openstack server create -f json --image="${this.imageName}" --flavor="g.standard.small" --network="shared_net_1" --security-group="default" --key-name="${this.keyName}" ${this.instanceName}`,
);
const instance = JSON.parse(output);
// Expect the instance started building
if (instance.status !== 'BUILD') {
throw new OpenStackError(
`Instance ${this.instanceName} does not have expected status 'BUILD', but '${instance.status}'`,
);
}
// Wait until the instance is running (in active state)
for (let i = 0; i < retries; i++) {
const output = await OpenStackWrapper.execCommand(
`openstack server show -f json ${this.instanceName}`,
);
const instance = JSON.parse(output);
if (instance.status === 'ACTIVE') {
// Instance is running
this.ipAddress = instance.addresses.shared_net_1[0];
console.log(`Instance ${this.instanceName} launched successfully`);
return;
}
// Wait before checking again
await new Promise((resolve) => setTimeout(resolve, sleepTime));
}
// If the instance is not running after the retries, throw an error
throw new OpenStackError(
`Instance ${this.instanceName} didn't launch after 10 minutes.`,
);
} catch (error) {
console.error(`Error launching instance: ${error}`);
throw error;
}
}
/**
* Check if we can connect to the instance. Raises an error if we can't.
* @throws OpenStackError if we can't connect to the instance even after the retries.
*/
private async checkConnection(): Promise<void> {
if (!this.canConnect) {
const sleepTime = 15000; // 15 seconds
const retries = 4; // 4 * 15 seconds = 1 minute to wait if we can connect to the instance
for (let i = 0; i < retries; i++) {
try {
const output = await OpenStackWrapper.execCommand(
`ssh -o StrictHostKeyChecking=accept-new cloud-user@${this.ipAddress} "echo 'Hello'"`,
);
if (output.includes('Hello')) {
this.canConnect = true;
break;
}
} catch (error) {
if (i < retries - 1) {
await new Promise((resolve) => setTimeout(resolve, sleepTime));
} else {
throw new OpenStackError(
`Failed to connect to instance ${this.imageName} after ${retries} attempts. Reason: ${error}`,
);
}
}
}
console.log(`Instance ${this.imageName} is ready to connect`);
}
}
/**
* Executes a command via SSH on the running instance and returns the exit code and output.
* @param command - The command to execute.
* @param user - The user to execute the command as. If not provided, defaults to 'cloud-user'.
* @returns [exitCode, stdout]
*/
public async exec(command: string, user?: string): Promise<[number, string]> {
await this.checkConnection();
// Execute the SSH command using the private wrapper
try {
const output = await OpenStackWrapper.execCommand(
`ssh -o StrictHostKeyChecking=accept-new ${user ?? 'cloud-user'}@${this.ipAddress} ${command}`,
);
return [0, output];
} catch (error) {
return [error.code, error.message];
}
}
/**
* Delete an image from Openstack.
* @param imageName - The name of the image to delete.
* @throws OpenStackError if the image is found but failed to delete.
*/
public static async deleteImage(imageName: string): Promise<void> {
await test.step(
'Delete the image on Openstack with name: ' + imageName,
async () => {
try {
await this.execCommand(`openstack image delete ${imageName}`);
console.log(`Image ${imageName} deleted`);
} catch (error) {
if (!error.message.includes('Multi Backend support not enabled.')) {
throw new OpenStackError(
`Image was found, but failed to delete. Reason: ${error.message}`,
);
}
// Fail gracefully, no image to delete
}
},
);
}
/**
* Delete an instance from Openstack.
* @param instanceName - The name of the instance to delete.
* @throws OpenStackError if the instance is found but failed to delete.
*/
public static async deleteInstance(instanceName: string): Promise<void> {
await test.step(
'Delete the instance on Openstack with name: ' + instanceName,
async () => {
try {
await this.execCommand(`openstack server delete ${instanceName}`);
console.log(`Instance ${instanceName} deleted`);
} catch (error) {
if (!error.message.includes('No Server found')) {
throw new OpenStackError(
`Instance was found, but failed to delete. Reason: ${error.message}`,
);
}
// Fail gracefully, no instance to delete
}
},
);
}
}
/**
* Custom error class for OpenStack errors.
*/
class OpenStackError extends Error {
constructor(message: string) {
super(message);
this.name = 'OpenStackError';
}
}

View file

@ -0,0 +1,56 @@
import { Page } from '@playwright/test';
export const buildImage = async (page: Page) => {
/**
* Build the image and wait for it to be ready.
* @param page - the page object
*/
await page.getByRole('button', { name: 'Build images' }).click();
let timeSpentBuilding = 0;
console.log('Starting the build');
// eslint-disable-next-line disable-autofix/@typescript-eslint/no-unnecessary-condition
while (true) {
if (
(await page.getByText('Ready').isVisible()) ||
(await page.getByText('Expires in').isVisible())
) {
console.log(`Image is ready (Time spent: ${timeSpentBuilding / 60000}m)`);
break;
} else if (await page.getByText('Image build failed').isVisible()) {
throw new Error('Image build failed');
}
await new Promise((resolve) => setTimeout(resolve, 30000));
// Show how much time passed since the build started
timeSpentBuilding += 30000;
if (timeSpentBuilding % (60000 * 5) === 0) {
// Log only every 5 minutes
console.log(
`Waiting for image to be ready (Time spent: ${timeSpentBuilding / 60000}m)`,
);
}
}
};
export const downloadImage = async (page: Page, filePath: string) => {
/**
* Download the image and save it to the specified path.
* @param page - the page object
* @param filePath - the path to save the image
*/
// Start waiting for download before clicking. Note no await.
console.log('Downloading image');
const downloadPromise = page.waitForEvent('download');
await page.getByText('Download').first().click();
const download = await downloadPromise;
await download.saveAs(filePath);
console.log(`Downloaded file: ${filePath}`);
};
export const constructFilePath = (blueprintName: string, extension: string) => {
/**
* Construct the file path for the image.
* @param blueprintName - the name of the blueprint
* @param extension - the extension of the image
*/
return `./image-downloads/${blueprintName}.${extension}`;
};

View file

@ -0,0 +1,44 @@
import { FrameLocator, Page } from '@playwright/test';
export const navigateToWizard = async (page: Page | FrameLocator) => {
/**
* Open the wizard.
* @param page - the page object
*/
await page.getByRole('button', { name: 'Create image blueprint' }).click();
};
export const selectTarget = async (
page: Page | FrameLocator,
target: 'qcow2' | 'iso' | 'wsl' | 'ova' | 'vmdk',
) => {
/**
* Select the target.
* @param page - the page object
* @param target - the target to select (qcow2, iso, wsl, ova, vmdk)
*/
switch (target) {
case 'qcow2':
await page.getByRole('checkbox', { name: 'Virtualization' }).click();
break;
case 'iso':
await page.getByRole('checkbox', { name: 'Bare metal' }).click();
break;
case 'wsl':
await page.getByRole('checkbox', { name: 'WSL' }).click();
break;
case 'ova':
await page
.getByRole('checkbox', {
name: 'VMware vSphere - Open virtualization format',
})
.click();
break;
case 'vmdk':
await page
.getByRole('checkbox', { name: 'VMware vSphere - Virtual disk' })
.click();
break;
}
await page.getByRole('button', { name: 'Next' }).click();
};