From 4d66b2fa08597fd4d456ea5b0030c34deb39d56d Mon Sep 17 00:00:00 2001 From: CrazyMax Date: Mon, 27 Feb 2023 09:10:57 +0100 Subject: [PATCH] docker: install and download methods for macos and windows Signed-off-by: CrazyMax --- .github/workflows/e2e.yml | 39 ++++++ README.md | 1 + __tests__/docker/install.test.e2e.ts | 34 +++++ __tests__/docker/install.test.ts | 50 +++++++ jest.config.e2e.ts | 31 +++++ package.json | 6 +- scripts/setup-docker.ps1 | 69 ++++++++++ src/docker/install.ts | 195 +++++++++++++++++++++++++++ src/scripts.ts | 21 +++ src/util.ts | 24 ++++ tsconfig.json | 2 +- 11 files changed, 469 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 __tests__/docker/install.test.e2e.ts create mode 100644 __tests__/docker/install.test.ts create mode 100644 jest.config.e2e.ts create mode 100644 scripts/setup-docker.ps1 create mode 100644 src/docker/install.ts create mode 100644 src/scripts.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..b970040 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,39 @@ +name: e2e + +on: + workflow_dispatch: + push: + branches: + - 'main' + pull_request: + paths-ignore: + - '.github/buildx-releases.json' + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + #- ubuntu-latest + - macos-latest + - windows-latest + steps: + - + name: Checkout + uses: actions/checkout@v3 + - + name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 16 + cache: 'yarn' + - + name: Install + run: yarn install + - + name: Test + run: yarn test:e2e + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index c3e8a55..04b5ea0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ [![Version](https://img.shields.io/npm/v/@docker/actions-toolkit?label=version&logo=npm&style=flat-square)](https://www.npmjs.com/package/@docker/actions-toolkit) [![Downloads](https://img.shields.io/npm/dw/@docker/actions-toolkit?logo=npm&style=flat-square)](https://www.npmjs.com/package/@docker/actions-toolkit) [![Test workflow](https://img.shields.io/github/actions/workflow/status/docker/actions-toolkit/test.yml?label=test&logo=github&style=flat-square)](https://github.com/docker/actions-toolkit/actions?workflow=test) +[![E2E workflow](https://img.shields.io/github/actions/workflow/status/docker/actions-toolkit/e2e.yml?label=e2e&logo=github&style=flat-square)](https://github.com/docker/actions-toolkit/actions?workflow=e2e) [![Codecov](https://img.shields.io/codecov/c/github/docker/actions-toolkit?logo=codecov&style=flat-square)](https://codecov.io/gh/docker/actions-toolkit) # Actions Toolkit diff --git a/__tests__/docker/install.test.e2e.ts b/__tests__/docker/install.test.e2e.ts new file mode 100644 index 0000000..9b55615 --- /dev/null +++ b/__tests__/docker/install.test.e2e.ts @@ -0,0 +1,34 @@ +/** + * Copyright 2023 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {describe, expect, test} from '@jest/globals'; + +import {Install} from '../../src/docker/install'; +import {Docker} from '../../src/docker/docker'; + +describe('install', () => { + // prettier-ignore + test.each(['23.0.0'])( + 'install docker %s', async (version) => { + await expect((async () => { + const install = new Install(); + const toolPath = await install.download(version); + await install.install(toolPath); + await Docker.printVersion(); + await Docker.printInfo(); + })()).resolves.not.toThrow(); + }); +}); diff --git a/__tests__/docker/install.test.ts b/__tests__/docker/install.test.ts new file mode 100644 index 0000000..33f5415 --- /dev/null +++ b/__tests__/docker/install.test.ts @@ -0,0 +1,50 @@ +/** + * Copyright 2023 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {describe, expect, jest, test, beforeEach, afterEach} from '@jest/globals'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as rimraf from 'rimraf'; +import osm = require('os'); + +import {Install} from '../../src/docker/install'; + +// prettier-ignore +const tmpDir = path.join(process.env.TEMP || '/tmp', 'buildx-jest'); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +afterEach(function () { + rimraf.sync(tmpDir); +}); + +describe('download', () => { + // prettier-ignore + test.each([ + ['19.03.6', 'linux'], + ['20.10.22', 'linux'], + ['20.10.22', 'darwin'], + ['20.10.22', 'win32'], + ])( + 'acquires %p of docker (%s)', async (version, platformOS) => { + jest.spyOn(osm, 'platform').mockImplementation(() => platformOS); + const install = new Install(); + const toolPath = await install.download(version); + expect(fs.existsSync(toolPath)).toBe(true); + }, 100000); +}); diff --git a/jest.config.e2e.ts b/jest.config.e2e.ts new file mode 100644 index 0000000..d2656db --- /dev/null +++ b/jest.config.e2e.ts @@ -0,0 +1,31 @@ +/** + * Copyright 2023 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports = { + clearMocks: true, + testEnvironment: 'node', + moduleFileExtensions: ['js', 'ts'], + setupFiles: ['dotenv/config'], + testMatch: ['**/*.test.e2e.ts'], + testTimeout: 1800000, // 30 minutes + transform: { + '^.+\\.ts$': 'ts-jest' + }, + moduleNameMapper: { + '^csv-parse/sync': '/node_modules/csv-parse/dist/cjs/sync.cjs' + }, + verbose: false +}; diff --git a/package.json b/package.json index ab40ec6..19386bb 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "prettier": "prettier --check \"./**/*.ts\"", "prettier:fix": "prettier --write \"./**/*.ts\"", "test": "jest", - "test-coverage": "jest --coverage" + "test-coverage": "jest --coverage", + "test:e2e": "jest -c jest.config.e2e.ts" }, "repository": { "type": "git", @@ -35,7 +36,8 @@ "test": "__tests__" }, "files": [ - "lib" + "lib", + "scripts" ], "publishConfig": { "access": "public", diff --git a/scripts/setup-docker.ps1 b/scripts/setup-docker.ps1 new file mode 100644 index 0000000..982a003 --- /dev/null +++ b/scripts/setup-docker.ps1 @@ -0,0 +1,69 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ToolDir, + + [Parameter(Mandatory = $false)] + [string]$TmpDir, + + [Parameter(Mandatory = $true)] + [string]$DockerHost) + +$pwver = (Get-ItemProperty -Path HKLM:\SOFTWARE\Microsoft\PowerShell\3\PowerShellEngine -Name 'PowerShellVersion').PowerShellVersion +Write-Host "PowerShell version: $pwver" + +# Create temp directory +if (!$TmpDir) { $TmpDir = $env:TEMP } +New-Item -ItemType Directory "$TmpDir" -ErrorAction SilentlyContinue | Out-Null + +# Remove existing service +if (Get-Service docker -ErrorAction SilentlyContinue) { + $dockerVersion = (docker version -f "{{.Server.Version}}") + Write-Host "Current installed Docker version: $dockerVersion" + # stop service + Stop-Service -Force -Name docker + Write-Host "Service stopped" + # remove service + sc.exe delete "docker" + # removes event log entry. we could use "Remove-EventLog -LogName -Source docker" + # but this cmd is not available atm + $ErrorActionPreference = "SilentlyContinue" + & reg delete "HKLM\SYSTEM\CurrentControlSet\Services\EventLog\Application\docker" /f 2>&1 | Out-Null + $ErrorActionPreference = "Stop" + Write-Host "Service removed" +} + +$env:DOCKER_HOST = $DockerHost +Write-Host "DOCKER_HOST: $env:DOCKER_HOST" + +Write-Host "Creating service" +New-Item -ItemType Directory "$TmpDir\moby-root" -ErrorAction SilentlyContinue | Out-Null +New-Item -ItemType Directory "$TmpDir\moby-exec" -ErrorAction SilentlyContinue | Out-Null +Start-Process -Wait -NoNewWindow "$ToolDir\dockerd" ` + -ArgumentList ` + "--host=$DockerHost", ` + "--data-root=$TmpDir\moby-root", ` + "--exec-root=$TmpDir\moby-exec", ` + "--pidfile=$TmpDir\docker.pid", ` + "--register-service" +Write-Host "Starting service" +Start-Service -Name docker +Write-Host "Service started successfully!" + +$tries=20 +Write-Host "Waiting for Docker daemon to start..." +While ($true) { + $ErrorActionPreference = "SilentlyContinue" + & "$ToolDir\docker" version | Out-Null + $ErrorActionPreference = "Stop" + If ($LastExitCode -eq 0) { + break + } + $tries-- + If ($tries -le 0) { + Throw "Failed to get a response from Docker daemon" + } + Write-Host -NoNewline "." + Start-Sleep -Seconds 1 +} +Write-Host "Docker daemon started successfully!" diff --git a/src/docker/install.ts b/src/docker/install.ts new file mode 100644 index 0000000..a3d6507 --- /dev/null +++ b/src/docker/install.ts @@ -0,0 +1,195 @@ +/** + * Copyright 2023 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import * as core from '@actions/core'; +import * as io from '@actions/io'; +import * as tc from '@actions/tool-cache'; +import * as util from 'util'; + +import * as scripts from '../scripts'; +import {Context} from '../context'; +import {Exec} from '../exec'; +import {Util} from '../util'; + +export class Install { + public async download(version: string, channel?: string): Promise { + channel = channel || 'stable'; + const downloadURL = this.downloadURL(version, channel); + + core.info(`Downloading ${downloadURL}`); + const downloadPath = await tc.downloadTool(downloadURL); + core.debug(`docker.Install.download downloadPath: ${downloadPath}`); + + let extractFolder: string; + if (os.platform() == 'win32') { + extractFolder = await tc.extractZip(downloadPath); + } else { + extractFolder = await tc.extractTar(downloadPath, path.join(Context.tmpDir(), 'docker')); + } + if (Util.isDirectory(path.join(extractFolder, 'docker'))) { + extractFolder = path.join(extractFolder, 'docker'); + } + core.debug(`docker.Install.download extractFolder: ${extractFolder}`); + + core.info('Fixing perms'); + fs.readdir(path.join(extractFolder), function (err, files) { + if (err) { + throw err; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + files.forEach(function (file, index) { + fs.chmodSync(path.join(extractFolder, file), '0755'); + }); + }); + + const tooldir = await tc.cacheDir(extractFolder, `docker-${channel}`, version.replace(/(0+)([1-9]+)/, '$2')); + core.addPath(tooldir); + core.info('Added Docker to PATH'); + return tooldir; + } + + public async install(toolDir: string): Promise { + switch (os.platform()) { + case 'darwin': { + await this.installDarwin(toolDir); + break; + } + case 'linux': { + await this.installLinux(toolDir); + break; + } + case 'win32': { + await this.installWindows(toolDir); + break; + } + default: { + throw new Error(`Unsupported platform: ${os.platform()}`); + } + } + } + + private async installDarwin(toolDir: string): Promise { + if (!(await Install.colimaInstalled())) { + await core.group('Installing colima', async () => { + await Exec.exec('brew', ['install', 'colima']); + }); + } + // colima is already started on the runner so env var added in download + // method is not expanded to the running process. + const envs = Object.assign({}, process.env, { + PATH: `${toolDir}:${process.env.PATH}` + }) as { + [key: string]: string; + }; + await core.group('Starting colima', async () => { + await Exec.exec('colima', ['start', '--runtime', 'docker', '--mount-type', '9p'], {env: envs}); + }); + } + + private async installLinux(toolDir: string): Promise { + core.addPath(toolDir); + core.info('Added Docker to PATH'); + } + + private async installWindows(toolDir: string): Promise { + const dockerHost = 'npipe:////./pipe/setup_docker_action'; + const setupCmd = await Util.powershellCommand(scripts.setupDockerPowershell, { + ToolDir: toolDir, + TmpDir: Context.tmpDir(), + DockerHost: dockerHost + }); + await core.group('Install Docker daemon service', async () => { + await Exec.exec(setupCmd.command, setupCmd.args); + }); + await core.group('Create Docker context', async () => { + await Exec.exec('docker', ['context', 'create', 'setup-docker-action', '--docker', `host=${dockerHost}`]); + await Exec.exec('docker', ['context', 'use', 'setup-docker-action']); + }); + } + + private downloadURL(version: string, channel: string): string { + let platformOS, platformArch: string; + switch (os.platform()) { + case 'darwin': { + platformOS = 'mac'; + break; + } + case 'linux': { + platformOS = 'linux'; + break; + } + case 'win32': { + platformOS = 'win'; + break; + } + default: { + platformOS = os.platform(); + break; + } + } + switch (os.arch()) { + case 'x64': { + platformArch = 'x86_64'; + break; + } + case 'ppc64': { + platformArch = 'ppc64le'; + break; + } + case 'arm': { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const arm_version = (process.config.variables as any).arm_version; + switch (arm_version) { + case 6: { + platformArch = 'armel'; + break; + } + case 7: { + platformArch = 'armhf'; + break; + } + default: { + platformArch = `v${arm_version}`; + break; + } + } + break; + } + default: { + platformArch = os.arch(); + break; + } + } + const ext = platformOS === 'win' ? '.zip' : '.tgz'; + return util.format('https://download.docker.com/%s/static/%s/%s/docker-%s%s', platformOS, channel, platformArch, version, ext); + } + + private static async colimaInstalled(): Promise { + return await io + .which('colima', true) + .then(res => { + core.debug(`docker.Install.colimaAvailable ok: ${res}`); + return true; + }) + .catch(error => { + core.debug(`docker.Install.colimaAvailable error: ${error}`); + return false; + }); + } +} diff --git a/src/scripts.ts b/src/scripts.ts new file mode 100644 index 0000000..0056f5a --- /dev/null +++ b/src/scripts.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2023 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; + +const scriptsPath = path.join(__dirname, '..', 'scripts'); + +export const setupDockerPowershell = path.join(scriptsPath, 'setup-docker.ps1'); diff --git a/src/util.ts b/src/util.ts index 2250139..bed6ee5 100644 --- a/src/util.ts +++ b/src/util.ts @@ -14,7 +14,9 @@ * limitations under the License. */ +import fs from 'fs'; import * as core from '@actions/core'; +import * as io from '@actions/io'; import {parse} from 'csv-parse/sync'; export interface InputListOpts { @@ -71,4 +73,26 @@ export class Util { } return true; } + + public static async powershellCommand(script: string, params: Record) { + const powershellPath: string = await io.which('powershell', true); + const escapedScript = script.replace(/'/g, "''").replace(/"|\n|\r/g, ''); + const escapedParams: string[] = []; + for (const key in params) { + escapedParams.push(`-${key} '${params[key].replace(/'/g, "''").replace(/"|\n|\r/g, '')}'`); + } + return { + command: `"${powershellPath}"`, + args: ['-NoLogo', '-Sta', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Unrestricted', '-Command', `& '${escapedScript}' ${escapedParams.join(' ')}`] + }; + } + + public static isDirectory(p) { + try { + return fs.lstatSync(p).isDirectory(); + } catch (_) { + // noop + } + return false; + } } diff --git a/tsconfig.json b/tsconfig.json index f6a43ac..9be7112 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,6 @@ "./__tests__/**/*", "./lib/**/*", "node_modules", - "jest.config.ts" + "jest.config*.ts" ] }