Skip to content

Commit

Permalink
CI: Add workflow to do a simple smoke test from a release
Browse files Browse the repository at this point in the history
This is used to verify that our artifacts in a release are correct (and we
can ship them).  It checks that signatures look sane.

This currently just starts the application then quits.

Signed-off-by: Mark Yen <mark.yen@suse.com>
  • Loading branch information
mook-as committed Apr 5, 2024
1 parent 1058c98 commit 01ece1d
Show file tree
Hide file tree
Showing 2 changed files with 298 additions and 0 deletions.
132 changes: 132 additions & 0 deletions .github/workflows/smoke-test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
name: Release smoke test
permissions: {}
on:
workflow_dispatch:
inputs:
tag:
description: >
Download artifacts from release with this tag, rather than picking the
first draft release.
type: string

jobs:
find-release:
name: Find release
runs-on: ubuntu-latest
permissions:
contents: write # Needed to list draft releases
outputs:
release: ${{ steps.output.outputs.RELEASE_ID }}
steps:
- name: Override release
if: inputs.tag != ''
run: printf "RELEASE_ID=%s\n" '${{ inputs.tag }}' >>"$GITHUB_ENV"
- name: Find release
if : inputs.tag == ''
run: >-
set -o xtrace;
printf "RELEASE_ID=%s\n" >>"$GITHUB_ENV"
"$(gh api repos/${{ github.repository }}/releases
--jq 'map(select(.draft))[0].tag_name')"
env:
GH_TOKEN: ${{ github.token }}
- id: output
name: Emit output
run: |
if [[ -z "$RELEASE_ID" ]]; then
echo "Failed to find release ID" >&2
exit 1
fi
echo "Found release $RELEASE_ID"
echo "RELEASE_ID=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
smoke-test:
name: Smoke test
needs: find-release
strategy:
fail-fast: false
matrix:
include:
- { platform: macos, runs-on: macos-latest, pattern: '*.aarch64.dmg' }
- { platform: win32, runs-on: windows-latest, pattern: '*.msi' }
- { platform: linux, runs-on: ubuntu-latest, pattern: 'rancher-desktop-linux-*.zip' }
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version-file: package.json
cache: yarn
- uses: actions/setup-go@v5
with:
go-version-file: src/go/rdctl/go.mod
cache-dependency-path: src/go/**/go.sum

- name: "Linux: Enable KVM access"
if: runner.os == 'Linux'
run: sudo chmod a+rwx /dev/kvm

- name: "Linux: Initialize pass"
if: runner.os == 'Linux'
run: |
# Configure the agent to allow default passwords
HOMEDIR="$(gpgconf --list-dirs homedir)" # spellcheck-ignore-line
mkdir -p "${HOMEDIR}"
chmod 0700 "${HOMEDIR}"
echo "allow-preset-passphrase" >> "${HOMEDIR}/gpg-agent.conf"
# Create a GPG key
gpg --quick-generate-key --yes --batch --passphrase '' \
user@rancher-desktop.test default \
default never
# Get info about the newly created key
DATA="$(gpg --batch --with-colons --with-keygrip --list-secret-keys)"
FINGERPRINT="$(awk -F: '/^fpr:/ { print $10 ; exit }' <<< "${DATA}")" # spellcheck-ignore-line
GRIP="$(awk -F: '/^grp:/ { print $10 ; exit }' <<< "${DATA}")"
# Save the password
gpg-connect-agent --verbose "PRESET_PASSPHRASE ${GRIP} -1 00" /bye
# Initialize pass
pass init "${FINGERPRINT}"
- name: "Windows: Stop unwanted services"
if: runner.os == 'Windows'
run: >-
Get-Service -ErrorAction Continue -Name
@('W3SVC', 'docker')
| Stop-Service
- name: "Windows: Update any pre-installed WSL"
if: runner.os == 'Windows'
run: wsl --update
continue-on-error: true

- name: Set log directory
shell: bash
run: |
echo "LOGS_DIR=$(pwd)/logs" >> "$GITHUB_ENV"
mkdir logs
- name: Flag build for M1
if: runner.os == 'macOS' && startsWith(runner.arch, 'ARM')
run: echo M1=1 >> "$GITHUB_ENV"
- # Needs a network timeout for macos & windows. See https://github.com/yarnpkg/yarn/issues/8242 for more info
run: yarn install --frozen-lockfile --network-timeout 1000000
- name: Download installer
run: >-
gh release download '${{ needs.find-release.outputs.release }}'
--clobber --pattern "$PATTERN" --pattern "$PATTERN.sha512sum"
env:
GH_TOKEN: ${{ github.token }}
PATTERN: ${{ matrix.pattern }}
- run: node scripts/ts-wrapper.js scripts/smoke-test.ts
- name: Upload logs
uses: actions/upload-artifact@v4
with:
name: logs-${{ matrix.platform }}.zip
path: logs
if-no-files-found: warn

166 changes: 166 additions & 0 deletions scripts/smoke-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// This script is expected to run from CI, and does a final smoke test.
// There should be an installer in the current directory (or in the case of
// Linux, a zip file), along with its accompanying sha512sum file.

import crypto from 'crypto';
import fs from 'fs';
import os from 'os';
import path from 'path';
import util from 'util';

import { State } from '@pkg/backend/backend';
import { spawnFile } from '@pkg/utils/childProcess';

async function run(...args: string[]) {
return await spawnFile(args[0], args.slice(1), { stdio: 'pipe' });
}

// Locate the archive, check its checksum, and return the file name.
async function getArchive(): Promise<string> {
const entries = await fs.promises.readdir('.', { withFileTypes: true });
const entry = entries.find(e => e.name.endsWith('.sha512sum') && e.isFile());

if (!entry) {
throw new Error('Could not find checksum file!');
}
const archiveName = entry.name.replace(/\.sha512sum$/, '');
const hash = crypto.createHash('sha512');

await new Promise((resolve) => {
hash.on('finish', resolve);
fs.createReadStream(archiveName).pipe(hash);
});
const digest = hash.digest('hex');
const lines = (await fs.promises.readFile(entry.name, 'utf-8')).split(/\r?\n/);
const wantedLine = lines.find(line => line.endsWith(archiveName));

if (!wantedLine) {
throw new Error(`Could not find expected checksum for ${ archiveName }`);
}
if (!wantedLine.startsWith(digest)) {
throw new Error(`Invalid checksum: got ${ digest }, expecting ${ wantedLine }`);
}

return archiveName;
}

/** Clean up functions to run; the actual execution will be in reverse order. */
const cleanups: (() => Promise<unknown>)[] = [];

/**
* Map of platform to function to install Rancher Desktop.
* @returns Path to `rdctl`.
*/
const installFunc: Partial<Record<NodeJS.Platform, (archiveName: string) => Promise<string>>> = {
darwin: async(archiveName) => {
const mountPoint = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rd-dmg-'));

cleanups.push(() => fs.promises.rm(mountPoint, { recursive: true }));
const srcApp = path.join(mountPoint, 'Rancher Desktop.app');
const destApp = '/Applications/Rancher Desktop.app';

// The archive is a .dmg; expand it and copy it to /Applications
await run('codesign', '--verify', '--deep', '--strict', '--verbose=2', archiveName);
await run('hdiutil', 'attach', archiveName, '-mountpoint', mountPoint);
cleanups.push(() => run('hdiutil', 'detach', mountPoint));
await run('codesign', '--verify', '--deep', '--strict', '--verbose=2', srcApp);
await fs.promises.mkdir(destApp);
cleanups.push(() => fs.promises.rm(destApp, { recursive: true }));
await run('/bin/sh', '-c', `tar -cC '${ srcApp }' . | tar -xC '${ destApp }'`);

return path.join(destApp, 'Contents/Resources/resources/darwin/bin/rdctl');
},
linux: async(archiveName) => {
await run('sudo', 'mkdir', '-p', '/opt/rancher-desktop');
await run('sudo', 'unzip', '-d', '/opt/rancher-desktop', archiveName);

return '/opt/rancher-desktop/resources/resources/linux/bin/rdctl';
},
win32: async(archiveName) => {
async function verify(filePath: string): Promise<void> {
const command = `Get-AuthenticodeSignature '${ filePath } | ConvertTo-JSON`;
const { stdout } = await run('powershell.exe', '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command);
const result: { Status: number, StatusMessage: string} = JSON.parse(stdout);

if (result.Status !== 0) {
console.log(result);
throw new Error(`${ filePath } is not correctly signed: ${ result.StatusMessage }`);
}
}

await verify(archiveName);

// GitHub-hosted runners have WSL installed, so this should succeed.
await run('msiexec', '/i', archiveName, '/passive', 'ALLUSERS=1');
const installDirectory = 'C:/Program Files/Rancher Desktop';

for (const entry of await fs.promises.readdir(installDirectory, { recursive: true, withFileTypes: true })) {
if (entry.isFile() && entry.name.toLowerCase().endsWith('.exe')) {
await verify(entry.path);
}
}

return path.join(installDirectory, 'resources/resources/win32/bin/rdctl.exe');
},
};

async function waitForBackend(rdctl: string): Promise<void> {
const startTime = new Date();
const deadline = startTime.valueOf() + 10 * 60 * 1_000;

while (Date.now() < deadline) {
try {
const { stdout } = await run(rdctl, 'api', '/v1/backend_state');
const { vmState } = JSON.parse(stdout) as {vmState: State };

if (vmState === State.ERROR ) {
throw new Error('Backend reached error state');
}
if ([State.STARTED, State.DISABLED].includes(vmState)) {
return ;
}
} catch (e) {
// Ignore errors with rdctl; we guard only via the timeout.
console.log(`Ignoring rdctl error: ${ e }`);
}
await util.promisify(setTimeout)(10 * 1_000);
}
throw new Error(`Timed out waiting for backend to stabilize: from ${ startTime } to ${ new Date() }`);
}

(async() => {
const installer = installFunc[process.platform];

if (!installer) {
throw new Error(`No installer defined for ${ process.platform }`);
}

let bestError: unknown;

try {
const rdctl = await installer(await getArchive());

await run(rdctl, 'start', '--no-modal-dialogs', '--kubernetes.enabled', '--application.updater.enabled=false');
cleanups.push(() => run(rdctl, 'shutdown'));
await waitForBackend(rdctl);
} catch (e) {
bestError = e;
} finally {
for (const cleanup of cleanups.reverse()) {
try {
await cleanup();
} catch (e) {
bestError ||= e;
console.error('Failed to run cleanup:', e);
}
}
}

if (bestError) {
throw bestError;
}
console.log('Smoke test passed.');
})().catch((e) => {
console.error(e);
process.exit(1);
});

0 comments on commit 01ece1d

Please sign in to comment.