diff --git a/.github/workflows/smoke-test.yaml b/.github/workflows/smoke-test.yaml new file mode 100644 index 00000000000..9ba9ce84810 --- /dev/null +++ b/.github/workflows/smoke-test.yaml @@ -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: read # Needed to list draft releases + outputs: + release: ${{ steps.output.outputs.RELEASE_ID }} + steps: + - name: Override release + if: inputs.tag != '' + run: echo "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 + diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts new file mode 100644 index 00000000000..667d2dcd432 --- /dev/null +++ b/scripts/smoke-test.ts @@ -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 { + 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)[] = []; + +/** + * Map of platform to function to install Rancher Desktop. + * @returns Path to `rdctl`. + */ +const installFunc: Partial Promise>> = { + 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 { + 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 { + 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); +});