/** * @license * Copyright 2016-2021 Balena Ltd. * * 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 { Args, Flags } from '@oclif/core'; import Command from '../../command.js'; import { getBalenaSdk } from '../../utils/lazy.js'; import * as cf from '../../utils/common-flags.js'; import * as compose from '../../utils/compose.js'; import type { ApplicationType, BalenaSDK, DeviceType, PineOptions, PineTypedResult, } from 'balena-sdk'; import { buildArgDeprecation, dockerignoreHelp, registrySecretsHelp, } from '../../utils/messages.js'; import type { ComposeCliFlags, ComposeOpts, } from '../../utils/compose-types.js'; import { buildProject, composeCliFlags } from '../../utils/compose_ts.js'; import type { BuildOpts, DockerCliFlags } from '../../utils/docker.js'; import { dockerCliFlags } from '../../utils/docker.js'; import type Dockerode from 'dockerode'; // TODO: For this special one we can't use Interfaces.InferredFlags/InferredArgs // because of the 'registry-secrets' type which is defined in the actual code // as a path (string | undefined) but then the cli turns it into an object interface FlagsDef extends ComposeCliFlags, DockerCliFlags { arch?: string; deviceType?: string; fleet?: string; source?: string; // Not part of command profile - source param copied here. help: void; } export default class BuildCmd extends Command { public static description = `\ Build a project locally. Use this command to build an image or a complete multicontainer project with the provided docker daemon in your development machine or balena device. (See also the \`balena push\` command for the option of building images in the balenaCloud build servers.) You must specify either a fleet, or the device type and architecture. This command will look into the given source directory (or the current working directory if one isn't specified) for a docker-compose.yml file, and if found, each service defined in the compose file will be built. If a compose file isn't found, it will look for a Dockerfile[.template] file (or alternative Dockerfile specified with the \`--dockerfile\` option), and if no dockerfile is found, it will try to generate one. ${registrySecretsHelp} ${dockerignoreHelp} `; public static examples = [ '$ balena build --fleet myFleet', '$ balena build ./source/ --fleet myorg/myfleet', '$ balena build --deviceType raspberrypi3 --emulated', '$ balena build --deviceType raspberrypi3 --arch armv7hf --emulated', '$ balena build --docker /var/run/docker.sock --fleet myFleet # Linux, Mac', '$ balena build --docker //./pipe/docker_engine --fleet myFleet # Windows', '$ balena build --dockerHost my.docker.host --dockerPort 2376 --ca ca.pem --key key.pem --cert cert.pem -f myFleet', ]; public static args = { source: Args.string({ description: 'path of project source directory' }), }; public static usage = 'build [source]'; public static flags = { arch: Flags.string({ description: 'the architecture to build for', char: 'A', }), deviceType: Flags.string({ description: 'the type of device this build is for', char: 'd', }), fleet: cf.fleet, ...composeCliFlags, ...dockerCliFlags, // NOTE: Not supporting -h for help, because of clash with -h in DockerCliFlags // Revisit this in future release. help: Flags.help({}), }; public static primary = true; public async run() { const { args: params, flags: options } = await this.parse(BuildCmd); await Command.checkLoggedInIf(!!options.fleet); (await import('events')).defaultMaxListeners = 1000; const sdk = getBalenaSdk(); const logger = await Command.getLogger(); logger.logDebug('Parsing input...'); // `build` accepts `source` as a parameter, but compose expects it as an option options.source = params.source; delete params.source; await this.resolveArchFromDeviceType(sdk, options); await this.validateOptions(options, sdk); // Build args are under consideration for removal - warn user if (options.buildArg) { console.log(buildArgDeprecation); } const app = await this.getAppAndResolveArch(options); const { docker, buildOpts, composeOpts } = await this.prepareBuild(options); try { await this.buildProject(docker, logger, composeOpts, { appType: app?.application_type?.[0], arch: options.arch!, deviceType: options.deviceType!, buildEmulated: options.emulated, buildOpts, }); } catch (err) { logger.logError('Build failed.'); throw err; } logger.outputDeferredMessages(); logger.logSuccess('Build succeeded!'); } protected async validateOptions(opts: FlagsDef, sdk: BalenaSDK) { // Validate option combinations if ( (opts.fleet == null && (opts.arch == null || opts.deviceType == null)) || (opts.fleet != null && (opts.arch != null || opts.deviceType != null)) ) { const { ExpectedError } = await import('../../errors.js'); throw new ExpectedError( 'You must specify either a fleet (-f), or the device type (-d) and optionally the architecture (-A)', ); } // Validate project directory const { validateProjectDirectory } = await import( '../../utils/compose_ts.js' ); const { dockerfilePath, registrySecrets } = await validateProjectDirectory( sdk, { dockerfilePath: opts.dockerfile, noParentCheck: opts['noparent-check'] || false, projectPath: opts.source || '.', registrySecretsPath: opts['registry-secrets'], }, ); opts.dockerfile = dockerfilePath; opts['registry-secrets'] = registrySecrets; } protected async resolveArchFromDeviceType(sdk: BalenaSDK, opts: FlagsDef) { if (opts.deviceType != null && opts.arch == null) { try { const deviceTypeOpts = { $select: 'is_of__cpu_architecture', $expand: { is_of__cpu_architecture: { $select: 'slug', }, }, } satisfies PineOptions<DeviceType>; opts.arch = ( (await sdk.models.deviceType.get( opts.deviceType, deviceTypeOpts, )) as PineTypedResult<DeviceType, typeof deviceTypeOpts> ).is_of__cpu_architecture[0].slug; } catch (err) { const { ExpectedError } = await import('../../errors.js'); if (err instanceof sdk.errors.BalenaInvalidDeviceType) { let message = err.message; if (!(await sdk.auth.isLoggedIn())) { message = `${message}. In case you are trying to use a private device type, please try to log in first.`; } throw new ExpectedError(message); } throw new ExpectedError( 'Failed to resolve the architecture of the provided device type. If you are in an air-gapped environment please also define the architecture (-A) parameter.', ); } } } protected async getAppAndResolveArch(opts: FlagsDef) { if (opts.fleet) { const { getAppWithArch } = await import('../../utils/helpers.js'); const app = await getAppWithArch(opts.fleet); opts.arch = app.arch; opts.deviceType = app.is_for__device_type[0].slug; return app; } } protected async prepareBuild(options: FlagsDef): Promise<{ docker: Dockerode; buildOpts: BuildOpts; composeOpts: ComposeOpts; }> { const { getDocker, generateBuildOpts } = await import( '../../utils/docker.js' ); const [docker, buildOpts, composeOpts] = await Promise.all([ getDocker(options), generateBuildOpts(options), compose.generateOpts(options), ]); return { docker, buildOpts, composeOpts, }; } /** * Opts must be an object with the following keys: * app: the app this build is for (optional) * arch: the architecture to build for * deviceType: the device type to build for * buildEmulated * buildOpts: arguments to forward to docker build command * * @param {Dockerode} docker * @param {Logger} logger * @param {ComposeOpts} composeOpts * @param opts */ protected async buildProject( docker: import('dockerode'), logger: import('../../utils/logger.js').default, composeOpts: ComposeOpts, opts: { appType?: Pick<ApplicationType, 'supports_multicontainer'>; arch: string; deviceType: string; buildEmulated: boolean; buildOpts: BuildOpts; }, ) { const { loadProject } = await import('../../utils/compose_ts.js'); const project = await loadProject( logger, composeOpts, undefined, opts.buildOpts.t, ); if ( opts.appType != null && project.descriptors.length > 1 && !opts.appType.supports_multicontainer ) { logger.logWarn( 'Target fleet does not support multiple containers.\n' + 'Continuing with build, but you will not be able to deploy.', ); } await buildProject({ docker, logger, projectPath: project.path, projectName: project.name, composition: project.composition, arch: opts.arch, deviceType: opts.deviceType, emulated: opts.buildEmulated, buildOpts: opts.buildOpts, inlineLogs: composeOpts.inlineLogs, convertEol: composeOpts.convertEol, dockerfilePath: composeOpts.dockerfilePath, multiDockerignore: composeOpts.multiDockerignore, }); } }