forked from rancher-sandbox/rancher-desktop
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
CI: Add workflow to do a simple smoke test from a release
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
Showing
2 changed files
with
298 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); |