diff --git a/lib/utils/compose_ts.ts b/lib/utils/compose_ts.ts index ad9bc42dbd..03d9eed158 100644 --- a/lib/utils/compose_ts.ts +++ b/lib/utils/compose_ts.ts @@ -146,7 +146,7 @@ export async function buildProject(opts: { projectPath: string; projectName: string; composition: Composition; - arch: string; + arch: string; // --arch option or application's architecture deviceType: string; emulated: boolean; buildOpts: import('./docker').BuildOpts; @@ -168,6 +168,7 @@ export async function buildProject(opts: { ); const renderer = await startRenderer({ imageDescriptors, ...opts }); try { + await checkDockerPlatformCompatibility(opts); await checkBuildSecretsRequirements(opts.docker, opts.projectPath); const needsQemu = await installQemuIfNeeded({ ...opts, imageDescriptors }); @@ -810,6 +811,72 @@ export function printGitignoreWarn( } } +/** + * Conditionally print hint messages regarding the --emulated and --pull + * options depending on a comparison between the app architecture and the + * architecture of the CPU where Docker or balenaEngine is running. + * @param arch App architecture, or --arch flag + * @param buildOpts Build options + * @param docker Dockerode instance + * @param emulated The --emulated flag + */ +async function checkDockerPlatformCompatibility({ + arch, // --arch option or application's architecture + buildOpts, + docker, + emulated, +}: { + arch: string; + buildOpts: import('./docker').BuildOpts; + docker: Dockerode; + emulated: boolean; +}) { + const { + asBalenaArch, + getDockerVersion, + isCompatibleArchitecture, + } = await import('./docker'); + const { platformNeedsQemu } = await import('./qemu'); + const { Arch: engineArch } = await getDockerVersion(docker); + + // --emulated specifically means ARM emulation on x86 CPUs, so only useful + // if the Docker daemon is running on an x86 CPU and the app is ARM + const needsEmulatedOption = + ['amd64', '386'].includes(engineArch) && + (await platformNeedsQemu(docker, emulated)); + + const isCompatibleArch = isCompatibleArchitecture(arch, engineArch); + const pull = !!buildOpts.pull; + + // Print hints regarding the --emulated and --pull options if their usage + // is likely to be helpful based on best-effort detection. + if ( + !isCompatibleArch && + (pull !== true || (needsEmulatedOption && emulated !== true)) + ) { + const balenaArch = asBalenaArch(engineArch); + const msg = [ + `Note: Host architecture '${balenaArch}' (where Docker or balenaEngine is running)`, + `does not match the balena application architecture '${arch}'.`, + ]; + // TODO: improve on `--pull` suggestion by querying the architecture of + // any cached base image and comparing it with the app architecture. + if (pull !== true) { + msg.push( + 'If multiarch base images are being used, the `--pull` option may be used to', + 'ensure that cached base images are pulled again for a different architecture.', + ); + } + if (needsEmulatedOption && emulated !== true) { + msg.push( + 'The `--emulated` option may be used to enable ARM architecture emulation', + 'with QEMU during the image build.', + ); + } + Logger.getLogger().logInfo(msg.join('\n ')); + } +} + /** * Check whether the "build secrets" feature is being used and, if so, * verify that the target docker daemon is balenaEngine. If the diff --git a/lib/utils/device/deploy.ts b/lib/utils/device/deploy.ts index c0d374bf2c..871cadf714 100644 --- a/lib/utils/device/deploy.ts +++ b/lib/utils/device/deploy.ts @@ -305,7 +305,7 @@ function connectToDocker(host: string, port: number): Docker { }); } -export async function performBuilds( +async function performBuilds( composition: Composition, tarStream: Readable, docker: Docker, diff --git a/lib/utils/docker.ts b/lib/utils/docker.ts index 8cc56a8d88..216117c1a9 100644 --- a/lib/utils/docker.ts +++ b/lib/utils/docker.ts @@ -164,12 +164,85 @@ export function generateBuildOpts(options: { return opts; } +/** Detect whether the docker daemon is balenaEngine */ export async function isBalenaEngine(docker: dockerode): Promise { - // dockerVersion.Engine should equal 'balena-engine' for the current/latest - // version of balenaEngine, but it was at one point (mis)spelt 'balaena': - // https://github.com/balena-os/balena-engine/pull/32/files - const dockerVersion = (await docker.version()) as BalenaEngineVersion; + const dockerVersion = await getDockerVersion(docker); return !!( - dockerVersion.Engine && dockerVersion.Engine.match(/balena|balaena/) + // dockerVersion.Engine should be 'balena-engine' for the current + // version of balenaEngine, but at one point it was spelt 'balaena': + // https://github.com/balena-os/balena-engine/pull/32/files + (dockerVersion.Engine && dockerVersion.Engine.match(/balena|balaena/)) ); } + +/** Detect whether the docker daemon is Docker Desktop (Windows or Mac) */ +export async function isDockerDesktop( + docker: dockerode, +): Promise<[boolean, any]> { + // Docker Desktop (Windows and Mac) with Docker Engine 19.03 reports: + // OperatingSystem: Docker Desktop + // OSType: linux + // Docker for Mac with Docker Engine 18.06 reports: + // OperatingSystem: Docker for Mac + // OSType: linux + // On Ubuntu (standard Docker installation): + // OperatingSystem: Ubuntu 18.04.2 LTS (containerized) + // OSType: linux + // https://stackoverflow.com/questions/38223965/how-can-i-detect-if-docker-for-mac-is-installed + // + const dockerInfo = await getDockerInfo(docker); + const isDD = /(?:Docker Desktop)|(?:Docker for Mac)/i.test( + dockerInfo.OperatingSystem, + ); + return [isDD, dockerInfo]; +} + +/** + * Convert a Docker arch identifier to a balena arch identifier. + * @param engineArch One of the GOARCH values (used by Docker) listed at: + * https://golang.org/doc/install/source#environment + */ +export function asBalenaArch(engineArch: string): string { + const archs: { [arch: string]: string } = { + arm: 'armv7hf', // could also be 'rpi' though + arm64: 'aarch64', + amd64: 'amd64', + '386': 'i386', + }; + return archs[engineArch] || ''; +} + +/** + * Determine whether the given balena arch identifier and the given + * Docker arch identifier represent compatible architectures. + * @param balenaArch One of: rpi, armv7hf, amd64, i386 + * @param engineArch One of the GOARCH values: arm, arm64, amd64, 386 + */ +export function isCompatibleArchitecture( + balenaArch: string, + engineArch: string, +): boolean { + return ( + (balenaArch === 'rpi' && engineArch === 'arm') || + balenaArch === asBalenaArch(engineArch) + ); +} + +let cachedDockerInfo: any; +let cachedDockerVersion: BalenaEngineVersion; + +export async function getDockerInfo(docker: dockerode): Promise { + if (cachedDockerInfo == null) { + cachedDockerInfo = await docker.info(); + } + return cachedDockerInfo; +} + +export async function getDockerVersion( + docker: dockerode, +): Promise { + if (cachedDockerVersion == null) { + cachedDockerVersion = await docker.version(); + } + return cachedDockerVersion; +} diff --git a/lib/utils/qemu.ts b/lib/utils/qemu.ts index ba5e1f55b3..4611a97ac0 100644 --- a/lib/utils/qemu.ts +++ b/lib/utils/qemu.ts @@ -18,7 +18,7 @@ import type * as Dockerode from 'dockerode'; import { ExpectedError } from '../errors'; -import { getBalenaSdk, stripIndent } from './lazy'; +import { getBalenaSdk } from './lazy'; import Logger = require('./logger'); export const QEMU_VERSION = 'v4.0.0+balena2'; @@ -155,7 +155,7 @@ export async function installQemuIfNeeded( ): Promise { // call platformNeedsQemu() regardless of whether emulation is required, // because it logs useful information - const needsQemu = await platformNeedsQemu(docker, logger); + const needsQemu = await platformNeedsQemu(docker, emulated, logger); if (!emulated || !needsQemu) { return false; } @@ -188,30 +188,27 @@ export async function installQemuIfNeeded( * - https://stackoverflow.com/questions/55388725/run-linux-arm-container-via-qemu-binfmt-misc-on-docker-lcow * * @param docker Dockerode instance + * @param emulated The --emulated command-line option + * @param logger Logger instance */ -async function platformNeedsQemu( +export async function platformNeedsQemu( docker: Dockerode, - logger: Logger, + emulated: boolean, + logger?: Logger, ): Promise { - const dockerInfo = await docker.info(); - // Docker Desktop (Windows and Mac) with Docker Engine 19.03 reports: - // OperatingSystem: Docker Desktop - // OSType: linux - // Docker for Mac with Docker Engine 18.06 reports: - // OperatingSystem: Docker for Mac - // OSType: linux - // On Ubuntu (standard Docker installation): - // OperatingSystem: Ubuntu 18.04.2 LTS (containerized) - // OSType: linux - // https://stackoverflow.com/questions/38223965/how-can-i-detect-if-docker-for-mac-is-installed - const isDockerDesktop = /(?:Docker Desktop)|(?:Docker for Mac)/i.test( - dockerInfo.OperatingSystem, - ); - if (isDockerDesktop) { - logger.logInfo(stripIndent` - Docker Desktop detected (daemon architecture: "${dockerInfo.Architecture}") - Docker itself will determine and enable architecture emulation if required, - without balena-cli intervention and regardless of the --emulated option.`); + const { isDockerDesktop } = await import('./docker'); + const [isDD, dockerInfo] = await isDockerDesktop(docker); + if (logger && isDD) { + const msg = [ + `Docker Desktop detected (daemon architecture: "${dockerInfo.Architecture}")`, + ]; + if (emulated) { + msg.push( + 'The --emulated option will be ignored because Docker Desktop has built-in', + '"binfmt_misc" QEMU emulation.', + ); + } + logger.logInfo(msg.join('\n ')); } - return !isDockerDesktop; + return !isDD; } diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 3f1ccb5949..8e753bf1f3 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -3021,9 +3021,9 @@ } }, "tslib": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz", - "integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==" + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, "typed-error": { "version": "2.0.0", @@ -5270,9 +5270,9 @@ "integrity": "sha512-e64Qj9+4aZzjzzFpZC7p5kmm/ccCrbLhAJplhsDXQFs87XTsXwOpH4s1Io2s90Tau/8r2j9f4l/thhDevRjzxw==" }, "errno": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.7.tgz", - "integrity": "sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", "requires": { "prr": "~1.0.1" } @@ -6727,9 +6727,9 @@ "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" }, "fp-ts": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.8.1.tgz", - "integrity": "sha512-HuA/6roEliHoBgEOLCKmGRcM90e2trW/ITZZ9d9P/ra7PreqQagC3Jg6OzqWkai13KUbG90b8QO9rHPBGK/ckw==" + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.9.1.tgz", + "integrity": "sha512-9++IpEtF2blK7tfSV+iHxO3KXdAGO/bPPQtUYqzC6XKzGOWNctqvlf13SpXxcu2mYaibOvneh/m9vAPLAHdoRQ==" }, "fragment-cache": { "version": "0.2.1", @@ -8476,9 +8476,9 @@ "dev": true }, "io-ts": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.2.9.tgz", - "integrity": "sha512-Q9ob1VnpwyNoMam/BO6hm2dF4uu+to8NWSZNsRW6Q2Ni38PadgLZSQDo0hW7CJFgpJkQw4BXGwXzjr7c47c+fw==" + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.2.13.tgz", + "integrity": "sha512-BYJgE/BanovJKDvCnAkrr7f3gTucSyk+Sr5VtpouBO1/YfBKUyIn2z1ODG8LEF+1D4sjKZ3Bd/A5/v8JrJe5UQ==" }, "io-ts-reporters": { "version": "1.2.2", @@ -13448,9 +13448,9 @@ } }, "resin-docker-build": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/resin-docker-build/-/resin-docker-build-1.1.5.tgz", - "integrity": "sha512-Ri9bzY9mGO6Ctw5MO6EUsQNl1jMSQ6dKg4z6acE7hvxiWjNxUUqbA0Qwu8rfVU+vSswFUy8LCjcQOD9XkrNcDA==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/resin-docker-build/-/resin-docker-build-1.1.6.tgz", + "integrity": "sha512-657lmKN1SEbaALSb5n1Mr11fze/msSOKH2aFOPBb+L7BxueC7nat5FZ0Jv07ZD0GDTiJo5Z885l6tegMC5+eaQ==", "requires": { "@types/bluebird": "^3.5.30", "@types/dockerode": "^2.5.24", @@ -13561,9 +13561,9 @@ } }, "resin-multibuild": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/resin-multibuild/-/resin-multibuild-4.7.2.tgz", - "integrity": "sha512-2Nn3wN09uQRuDrR0uOkK7bCKheSZ94rpY6ePt7IBVyxw/6EE0GfqSj/3y2l4lxzMFRfT5K4VDHlj5DUiNCKYkA==", + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/resin-multibuild/-/resin-multibuild-4.10.0.tgz", + "integrity": "sha512-Eti1HGSzTAUmpQErD9Oz0NAj9yGFzWSM3aRDMGlK1W3GVggZsI+CZ/lM3+0ffMmPbDtkQxWqBoEtP3jUl36rIw==", "requires": { "@types/bluebird": "^3.5.32", "@types/dockerode": "^2.5.34", @@ -13583,6 +13583,7 @@ "resin-bundle-resolve": "^4.3.0", "resin-compose-parse": "^2.1.2", "resin-docker-build": "^1.1.5", + "semver": "^7.3.2", "tar-stream": "^2.1.3", "tar-utils": "^2.1.0", "typed-error": "^3.2.1" diff --git a/package.json b/package.json index 0178568115..2ea8c77b4f 100644 --- a/package.json +++ b/package.json @@ -256,7 +256,7 @@ "resin-compose-parse": "^2.1.2", "resin-doodles": "^0.1.1", "resin-image-fs": "^5.0.9", - "resin-multibuild": "^4.7.2", + "resin-multibuild": "^4.10.0", "resin-stream-logger": "^0.1.2", "rimraf": "^3.0.2", "semver": "^7.3.2", diff --git a/tests/commands/build.spec.ts b/tests/commands/build.spec.ts index 05c74361be..f481f0e816 100644 --- a/tests/commands/build.spec.ts +++ b/tests/commands/build.spec.ts @@ -38,8 +38,6 @@ const commonResponseLines: { [key: string]: string[] } = { 'build-POST.json': [ '[Info] Building for amd64/nuc', '[Info] Docker Desktop detected (daemon architecture: "x86_64")', - '[Info] Docker itself will determine and enable architecture emulation if required,', - '[Info] without balena-cli intervention and regardless of the --emulated option.', '[Success] Build succeeded!', ], }; @@ -48,6 +46,7 @@ const commonQueryParams = { t: '${tag}', buildargs: {}, labels: '', + platform: 'linux/amd64', }; const commonComposeQueryParams = { @@ -57,6 +56,7 @@ const commonComposeQueryParams = { MY_VAR_2: 'Also a variable', }, labels: '', + platform: 'linux/amd64', }; const hr = @@ -76,7 +76,10 @@ describe('balena build', function () { api.expectGetWhoAmI({ optional: true, persist: true }); api.expectGetMixpanel({ optional: true }); docker.expectGetPing(); - docker.expectGetVersion(); + // Docker version is cached by the CLI, hence optional: true + // Docker version is also called by resin-multibuild, hence persist: true + docker.expectGetVersion({ optional: true, persist: true }); + docker.expectGetImages(); }); this.afterEach(() => { @@ -122,7 +125,7 @@ describe('balena build', function () { ); } } - docker.expectGetInfo({}); + docker.expectGetInfo({ optional: true }); // cached, hence optional await testDockerBuildStream({ commandLine: `build ${projectPath} --deviceType nuc --arch amd64 -g`, dockerMock: docker, @@ -178,7 +181,7 @@ describe('balena build', function () { ); } } - docker.expectGetInfo({}); + docker.expectGetInfo({ optional: true }); // cached, hence optional await testDockerBuildStream({ commandLine: `build ${projectPath} --deviceType nuc --arch amd64 -B BARG1=b1 -B barg2=B2 --cache-from my/img1,my/img2`, dockerMock: docker, @@ -257,18 +260,24 @@ describe('balena build', function () { const qemuMod = require(qemuModPath); const qemuBinPath = await qemuMod.getQemuPath(arch); try { + // patch fs.access and fs.stat to pretend that a copy of the Qemu binary + // already exists locally, thus preventing a download during tests mock(fsModPath, { ...fsMod, promises: { ...fsMod.promises, access: async (p: string) => p === qemuBinPath ? undefined : fsMod.promises.access(p), + stat: async (p: string) => + p === qemuBinPath ? { size: 1 } : fsMod.promises.stat(p), }, }); mock(qemuModPath, { ...qemuMod, copyQemu: async () => '', }); + // Forget cached values by re-requiring the modules + mock.reRequire('../../build/utils/docker'); mock.reRequire('../../build/utils/qemu'); docker.expectGetInfo({ OperatingSystem: 'balenaOS 2.44.0+rev1' }); await testDockerBuildStream({ @@ -276,7 +285,10 @@ describe('balena build', function () { dockerMock: docker, expectedFilesByService: { main: expectedFiles }, expectedQueryParamsByService: { - main: Object.entries(commonQueryParams), + main: Object.entries({ + ...commonQueryParams, + platform: 'linux/arm/v6', + }), }, expectedResponseLines, projectPath, @@ -287,6 +299,8 @@ describe('balena build', function () { } finally { mock.stop(fsModPath); mock.stop(qemuModPath); + // Forget cached values by re-requiring the modules + mock.reRequire('../../build/utils/docker'); } }); @@ -330,7 +344,7 @@ describe('balena build', function () { '[Warn] Windows-format line endings were detected in some files, but were not converted due to `--noconvert-eol` option.', ); } - docker.expectGetInfo({}); + docker.expectGetInfo({ optional: true }); // cached, hence optional await testDockerBuildStream({ commandLine: `build ${projectPath} --deviceType nuc --arch amd64 --noconvert-eol -m`, dockerMock: docker, @@ -426,7 +440,7 @@ describe('balena build', function () { )}`, ); } - docker.expectGetInfo({}); + docker.expectGetInfo({ optional: true }); // cached, hence optional await testDockerBuildStream({ commandLine: `build ${projectPath} --deviceType nuc --arch amd64 --convert-eol -G -B COMPOSE_ARG=A -B barg=b --cache-from my/img1,my/img2`, dockerMock: docker, @@ -512,7 +526,7 @@ describe('balena build', function () { )}`, ); } - docker.expectGetInfo({}); + docker.expectGetInfo({ optional: true }); // cached, hence optional await testDockerBuildStream({ commandLine: `build ${projectPath} --deviceType nuc --arch amd64 --convert-eol -m`, dockerMock: docker, diff --git a/tests/commands/deploy.spec.ts b/tests/commands/deploy.spec.ts index bbea817b00..726dec2a70 100644 --- a/tests/commands/deploy.spec.ts +++ b/tests/commands/deploy.spec.ts @@ -37,9 +37,6 @@ const commonResponseLines = { 'build-POST.json': [ '[Info] Building for armv7hf/raspberrypi3', '[Info] Docker Desktop detected (daemon architecture: "x86_64")', - '[Info] Docker itself will determine and enable architecture emulation if required,', - '[Info] without balena-cli intervention and regardless of the --emulated option.', - // '[Build] main Step 1/4 : FROM busybox', '[Info] Creating release...', '[Info] Pushing images to registry...', '[Info] Saving release...', @@ -52,6 +49,7 @@ const commonQueryParams = [ ['t', '${tag}'], ['buildargs', '{}'], ['labels', ''], + ['platform', 'linux/arm/v7'], ]; const commonComposeQueryParams = { @@ -61,6 +59,7 @@ const commonComposeQueryParams = { MY_VAR_2: 'Also a variable', }, labels: '', + platform: 'linux/arm/v7', }; const hr = @@ -89,8 +88,10 @@ describe('balena deploy', function () { docker.expectGetImages(); docker.expectGetPing(); - docker.expectGetInfo({}); - docker.expectGetVersion({ persist: true }); + // optional because docker.info() and docker.version() are cached + docker.expectGetInfo({ optional: true }); + // docker.version() is also called by resin-multibuild, hence persist: true + docker.expectGetVersion({ optional: true, persist: true }); docker.expectPostImagesTag(); docker.expectPostImagesPush(); docker.expectDeleteImages(); @@ -307,7 +308,6 @@ describe('balena deploy', function () { ); } - // docker.expectGetImages(); api.expectPatchImage({}); api.expectPatchRelease({}); diff --git a/tests/docker-build.ts b/tests/docker-build.ts index a7c0b79028..87d9885bcd 100644 --- a/tests/docker-build.ts +++ b/tests/docker-build.ts @@ -195,9 +195,6 @@ export async function testDockerBuildStream(o: { inspectTarStream(buildRequestBody, expectedFiles, projectPath), tag, }); - if (o.commandLine.startsWith('build')) { - o.dockerMock.expectGetImages(); - } } resetDockerignoreCache();