From 1335f081af255bf3cbec6aa92bdce74f6fe3ffed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Fri, 6 Sep 2024 12:23:22 +0200 Subject: [PATCH] docker/install: Support `version: master` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for installing Docker `master` packages from `moby/moby-bin` and `dockereng/cli-bin` images. This could also allow to install arbitrary version from these images but for now it's only used for `master`. Signed-off-by: Paweł Gronowski --- src/docker/install.ts | 23 ++++- src/dockerhub.ts | 24 +++--- src/hubRepository.ts | 157 ++++++++++++++++++++++++++++++++++ src/types/docker/mediatype.ts | 19 ++++ 4 files changed, 212 insertions(+), 11 deletions(-) create mode 100644 src/hubRepository.ts create mode 100644 src/types/docker/mediatype.ts diff --git a/src/docker/install.ts b/src/docker/install.ts index c681d97..80dbab4 100644 --- a/src/docker/install.ts +++ b/src/docker/install.ts @@ -33,6 +33,7 @@ import {Exec} from '../exec'; import {Util} from '../util'; import {limaYamlData, dockerServiceLogsPs1, setupDockerWinPs1} from './assets'; import {GitHubRelease} from '../types/github'; +import {HubRepository} from '../hubRepository'; export interface InstallOpts { version?: string; @@ -71,7 +72,7 @@ export class Install { return this._toolDir || Context.tmpDir(); } - public async download(): Promise { + async downloadStaticArchive(): Promise { const release: GitHubRelease = await Install.getRelease(this.version); this._version = release.tag_name.replace(/^v+|v+$/g, ''); core.debug(`docker.Install.download version: ${this._version}`); @@ -92,6 +93,26 @@ export class Install { extractFolder = path.join(extractFolder, 'docker'); } core.debug(`docker.Install.download extractFolder: ${extractFolder}`); + return extractFolder; + } + + public async download(): Promise { + let extractFolder: string; + + core.info(`Downloading Docker ${this.version} from ${this.channel}`); + + this._version = this.version; + if (this.version == 'master') { + core.info(`Downloading from moby/moby-bin`); + const moby = await HubRepository.build('moby/moby-bin'); + const cli = await HubRepository.build('dockereng/cli-bin'); + + extractFolder = await moby.extractImage(this.version); + await cli.extractImage(this.version, extractFolder); + } else { + core.info(`Downloading from download.docker.com`); + extractFolder = await this.downloadStaticArchive(); + } core.info('Fixing perms'); fs.readdir(path.join(extractFolder), function (err, files) { diff --git a/src/dockerhub.ts b/src/dockerhub.ts index 62bea8d..3bf1d59 100644 --- a/src/dockerhub.ts +++ b/src/dockerhub.ts @@ -111,17 +111,21 @@ export class DockerHub { const body = await resp.readBody(); resp.message.statusCode = resp.message.statusCode || HttpCodes.InternalServerError; if (resp.message.statusCode < 200 || resp.message.statusCode >= 300) { - if (resp.message.statusCode == HttpCodes.Unauthorized) { - throw new Error(`Docker Hub API: operation not permitted`); - } - const errResp = >JSON.parse(body); - for (const k of ['message', 'detail', 'error']) { - if (errResp[k]) { - throw new Error(`Docker Hub API: bad status code ${resp.message.statusCode}: ${errResp[k]}`); - } - } - throw new Error(`Docker Hub API: bad status code ${resp.message.statusCode}`); + throw DockerHub.parseError(resp, body); } return body; } + + public static parseError(resp: httpm.HttpClientResponse, body: string): Error { + if (resp.message.statusCode == HttpCodes.Unauthorized) { + throw new Error(`Docker Hub API: operation not permitted`); + } + const errResp = >JSON.parse(body); + for (const k of ['message', 'detail', 'error']) { + if (errResp[k]) { + throw new Error(`Docker Hub API: bad status code ${resp.message.statusCode}: ${errResp[k]}`); + } + } + throw new Error(`Docker Hub API: bad status code ${resp.message.statusCode}`); + } } diff --git a/src/hubRepository.ts b/src/hubRepository.ts new file mode 100644 index 0000000..e2a8884 --- /dev/null +++ b/src/hubRepository.ts @@ -0,0 +1,157 @@ +/** + * 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 * as httpm from '@actions/http-client'; +import {Index} from './types/oci'; +import os from 'os'; +import * as core from '@actions/core'; +import {Manifest} from './types/oci/manifest'; +import * as tc from '@actions/tool-cache'; +import fs from 'fs'; +import {MEDIATYPE_IMAGE_INDEX_V1, MEDIATYPE_IMAGE_MANIFEST_V1} from './types/oci/mediatype'; +import {MEDIATYPE_IMAGE_MANIFEST_V2, MEDIATYPE_IMAGE_MANIFEST_LIST_V2} from './types/docker/mediatype'; +import {DockerHub} from './dockerhub'; + +export class HubRepository { + private repo: string; + private token: string; + private static readonly http: httpm.HttpClient = new httpm.HttpClient('setup-docker-action'); + + private constructor(repository: string, token: string) { + this.repo = repository; + this.token = token; + } + + public static async build(repository: string): Promise { + const token = await this.getToken(repository); + return new HubRepository(repository, token); + } + + // Unpacks the image layers and returns the path to the extracted image. + // Only OCI indexes/manifest list are supported for now. + public async extractImage(tag: string, destDir?: string): Promise { + const index = await this.getManifest(tag); + if (index.mediaType != MEDIATYPE_IMAGE_INDEX_V1 && index.mediaType != MEDIATYPE_IMAGE_MANIFEST_LIST_V2) { + throw new Error(`Unsupported image media type: ${index.mediaType}`); + } + const digest = HubRepository.getPlatformManifestDigest(index); + const manifest = await this.getManifest(digest); + + const paths = manifest.layers.map(async layer => { + const url = this.blobUrl(layer.digest); + + return await tc.downloadTool(url, undefined, undefined, { + authorization: `Bearer ${this.token}` + }); + }); + + let files = await Promise.all(paths); + let extractFolder: string; + if (!destDir) { + extractFolder = await tc.extractTar(files[0]); + files = files.slice(1); + } else { + extractFolder = destDir; + } + + await Promise.all( + files.map(async file => { + return await tc.extractTar(file, extractFolder); + }) + ); + + fs.readdirSync(extractFolder).forEach(file => { + core.info(`extractImage(${this.repo}:${tag}) file: ${file}`); + }); + + return extractFolder; + } + + private static async getToken(repo: string): Promise { + const url = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`; + + const resp = await this.http.get(url); + const body = await resp.readBody(); + const statusCode = resp.message.statusCode || 500; + if (statusCode != 200) { + throw DockerHub.parseError(resp, body); + } + + const json = JSON.parse(body); + return json.token; + } + + private blobUrl(digest: string): string { + return `https://registry-1.docker.io/v2/${this.repo}/blobs/${digest}`; + } + + public async getManifest(tagOrDigest: string): Promise { + const url = `https://registry-1.docker.io/v2/${this.repo}/manifests/${tagOrDigest}`; + + const headers = { + Authorization: `Bearer ${this.token}`, + Accept: [MEDIATYPE_IMAGE_INDEX_V1, MEDIATYPE_IMAGE_MANIFEST_LIST_V2, MEDIATYPE_IMAGE_MANIFEST_V1, MEDIATYPE_IMAGE_MANIFEST_V2].join(', ') + }; + const resp = await HubRepository.http.get(url, headers); + const body = await resp.readBody(); + const statusCode = resp.message.statusCode || 500; + if (statusCode != 200) { + throw DockerHub.parseError(resp, body); + } + + return JSON.parse(body); + } + + private static getPlatformManifestDigest(index: Index): string { + // This doesn't handle all possible platforms normalizations, but it's good enough for now. + let pos: string = os.platform(); + if (pos == 'win32') { + pos = 'windows'; + } + let arch = os.arch(); + if (arch == 'x64') { + arch = 'amd64'; + } + let variant = ''; + if (arch == 'arm') { + variant = 'v7'; + } + + const manifest = index.manifests.find(m => { + if (!m.platform) { + return false; + } + if (m.platform.os != pos) { + core.debug(`Skipping manifest ${m.digest} because of os: ${m.platform.os} != ${pos}`); + return false; + } + if (m.platform.architecture != arch) { + core.debug(`Skipping manifest ${m.digest} because of arch: ${m.platform.architecture} != ${arch}`); + return false; + } + if ((m.platform.variant || '') != variant) { + core.debug(`Skipping manifest ${m.digest} because of variant: ${m.platform.variant} != ${variant}`); + return false; + } + + return true; + }); + if (!manifest) { + throw new Error(`Cannot find manifest for ${pos}/${arch}/${variant}`); + } + return manifest.digest; + } +} diff --git a/src/types/docker/mediatype.ts b/src/types/docker/mediatype.ts new file mode 100644 index 0000000..d06d1e9 --- /dev/null +++ b/src/types/docker/mediatype.ts @@ -0,0 +1,19 @@ +/** + * 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. + */ + +export const MEDIATYPE_IMAGE_MANIFEST_LIST_V2 = 'application/vnd.docker.distribution.manifest.list.v2+json'; + +export const MEDIATYPE_IMAGE_MANIFEST_V2 = 'application/vnd.docker.distribution.manifest.v2+json';