diff --git a/automation/build-bin.ts b/automation/build-bin.ts index d5ff6375..290acdcf 100644 --- a/automation/build-bin.ts +++ b/automation/build-bin.ts @@ -31,7 +31,7 @@ import * as rimraf from 'rimraf'; import * as semver from 'semver'; import { promisify } from 'util'; -import { stripIndent } from '../lib/utils/lazy'; +import { stripIndent } from '../build/utils/lazy'; import { diffLines, loadPackageJson, diff --git a/doc/cli.markdown b/doc/cli.markdown index fd2817de..aa284cc4 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -3185,11 +3185,13 @@ Don't convert line endings from CRLF (Windows format) to LF (Unix format). #### -n, --projectName PROJECTNAME -Specify an alternate project name; default is the directory name +Name prefix for locally built images. This is the 'projectName' portion +in 'projectName_serviceName:tag'. The default is the directory name. #### -t, --tag TAG -The alias to the generated image +Tag locally built Docker images. This is the 'tag' portion +in 'projectName_serviceName:tag'. The default is 'latest'. #### -B, --buildArg BUILDARG @@ -3424,11 +3426,13 @@ Don't convert line endings from CRLF (Windows format) to LF (Unix format). #### -n, --projectName PROJECTNAME -Specify an alternate project name; default is the directory name +Name prefix for locally built images. This is the 'projectName' portion +in 'projectName_serviceName:tag'. The default is the directory name. #### -t, --tag TAG -The alias to the generated image +Tag locally built Docker images. This is the 'tag' portion +in 'projectName_serviceName:tag'. The default is 'latest'. #### -B, --buildArg BUILDARG diff --git a/lib/commands/build.ts b/lib/commands/build.ts index 2b528118..8c605085 100644 --- a/lib/commands/build.ts +++ b/lib/commands/build.ts @@ -239,7 +239,12 @@ ${dockerignoreHelp} ) { const { loadProject } = await import('../utils/compose_ts'); - const project = await loadProject(logger, composeOpts); + const project = await loadProject( + logger, + composeOpts, + undefined, + opts.buildOpts.t, + ); const appType = (opts.app?.application_type as ApplicationType[])?.[0]; if ( diff --git a/lib/commands/deploy.ts b/lib/commands/deploy.ts index 75e26185..098e85de 100644 --- a/lib/commands/deploy.ts +++ b/lib/commands/deploy.ts @@ -34,7 +34,7 @@ import type { ComposeOpts, Release as ComposeReleaseInfo, } from '../utils/compose-types'; -import type { DockerCliFlags } from '../utils/docker'; +import type { BuildOpts, DockerCliFlags } from '../utils/docker'; import { applyReleaseTagKeysAndValues, buildProject, @@ -245,7 +245,7 @@ ${dockerignoreHelp} shouldPerformBuild: boolean; shouldUploadLogs: boolean; buildEmulated: boolean; - buildOpts: any; // arguments to forward to docker build command + buildOpts: BuildOpts; createAsDraft: boolean; }, ) { @@ -259,7 +259,12 @@ ${dockerignoreHelp} const appType = (opts.app?.application_type as ApplicationType[])?.[0]; try { - const project = await loadProject(logger, composeOpts, opts.image); + const project = await loadProject( + logger, + composeOpts, + opts.image, + opts.buildOpts.t, + ); if (project.descriptors.length > 1 && !appType?.supports_multicontainer) { throw new ExpectedError( 'Target fleet does not support multiple containers. Aborting!', diff --git a/lib/utils/compose.js b/lib/utils/compose.js index 2ebffff1..96e4fb72 100644 --- a/lib/utils/compose.js +++ b/lib/utils/compose.js @@ -49,10 +49,16 @@ export function generateOpts(options) { /** * @param {string} composePath * @param {string} composeStr - * @param {string | null} projectName + * @param {string | undefined} projectName The --projectName flag (build, deploy) + * @param {string | undefined} imageTag The --tag flag (build, deploy) * @returns {import('./compose-types').ComposeProject} */ -export function createProject(composePath, composeStr, projectName = null) { +export function createProject( + composePath, + composeStr, + projectName = '', + imageTag = '', +) { const yml = require('js-yaml'); const compose = require('resin-compose-parse'); @@ -62,7 +68,7 @@ export function createProject(composePath, composeStr, projectName = null) { }); const composition = compose.normalize(rawComposition); - projectName ??= path.basename(composePath); + projectName ||= path.basename(composePath); const descriptors = compose.parse(composition).map(function (descr) { // generate an image name based on the project and service names @@ -72,9 +78,8 @@ export function createProject(composePath, composeStr, projectName = null) { descr.image.context != null && descr.image.tag == null ) { - descr.image.tag = [projectName, descr.serviceName] - .join('_') - .toLowerCase(); + const { makeImageName } = require('./compose_ts'); + descr.image.tag = makeImageName(projectName, descr.serviceName, imageTag); } return descr; }); diff --git a/lib/utils/compose_ts.ts b/lib/utils/compose_ts.ts index 8658e710..1b76792c 100644 --- a/lib/utils/compose_ts.ts +++ b/lib/utils/compose_ts.ts @@ -118,6 +118,7 @@ export async function loadProject( logger: Logger, opts: ComposeOpts, image?: string, + imageTag?: string, ): Promise { const compose = await import('resin-compose-parse'); const { createProject } = await import('./compose'); @@ -156,7 +157,12 @@ export async function loadProject( } } logger.logDebug('Creating project...'); - return createProject(opts.projectPath, composeStr, opts.projectName); + return createProject( + opts.projectPath, + composeStr, + opts.projectName, + imageTag, + ); } /** @@ -411,6 +417,18 @@ async function installQemuIfNeeded({ return needsQemu; } +export function makeImageName( + projectName: string, + serviceName: string, + tag?: string, +) { + let name = `${projectName}_${serviceName}`; + if (tag) { + name = [name, tag].map((s) => s.replace(/:/g, '_')).join(':'); + } + return name.toLowerCase(); +} + function setTaskAttributes({ tasks, buildOpts, @@ -426,7 +444,7 @@ function setTaskAttributes({ const d = imageDescriptorsByServiceName[task.serviceName]; // multibuild (splitBuildStream) parses the composition internally so // any tags we've set before are lost; re-assign them here - task.tag ??= [projectName, task.serviceName].join('_').toLowerCase(); + task.tag ??= makeImageName(projectName, task.serviceName, buildOpts.t); if (isBuildConfig(d.image)) { d.image.tag = task.tag; } @@ -707,7 +725,7 @@ export async function getServiceDirsFromComposition( * Return true if `image` is actually a docker-compose.yml `services.service.build` * configuration object, rather than an "external image" (`services.service.image`). * - * The `image` argument may therefore refere to either a `build` or `image` property + * The `image` argument may therefore refer to either a `build` or `image` property * of a service in a docker-compose.yml file, which is a bit confusing but it matches * the `ImageDescriptor.image` property as defined by `resin-compose-parse`. * @@ -1666,8 +1684,9 @@ export const composeCliFlags: flags.Input = { "Don't convert line endings from CRLF (Windows format) to LF (Unix format).", }), projectName: flags.string({ - description: - 'Specify an alternate project name; default is the directory name', + description: stripIndent`\ + Name prefix for locally built images. This is the 'projectName' portion + in 'projectName_serviceName:tag'. The default is the directory name.`, char: 'n', }), }; diff --git a/lib/utils/device/deploy.ts b/lib/utils/device/deploy.ts index 0f85d7d7..ce09e81d 100644 --- a/lib/utils/device/deploy.ts +++ b/lib/utils/device/deploy.ts @@ -310,7 +310,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 9aed5909..427f36fa 100644 --- a/lib/utils/docker.ts +++ b/lib/utils/docker.ts @@ -72,7 +72,9 @@ export const dockerConnectionCliFlags: flags.Input = { export const dockerCliFlags: flags.Input = { tag: flags.string({ - description: 'The alias to the generated image', + description: `\ +Tag locally built Docker images. This is the 'tag' portion +in 'projectName_serviceName:tag'. The default is 'latest'.`, char: 't', }), buildArg: flags.string({ @@ -105,7 +107,7 @@ export interface BuildOpts { pull?: boolean; registryconfig?: import('resin-multibuild').RegistrySecrets; squash?: boolean; - t?: string; + t?: string; // only the tag portion of the image name, e.g. 'abc' in 'myimg:abc' } function parseBuildArgs(args: string[]): Dictionary { diff --git a/tests/commands/build.spec.ts b/tests/commands/build.spec.ts index 75ac7df5..7d34d230 100644 --- a/tests/commands/build.spec.ts +++ b/tests/commands/build.spec.ts @@ -517,6 +517,96 @@ describe('balena build', function () { services: ['service1', 'service2'], }); }); + + it('should create the expected tar stream (--projectName and --tag)', async () => { + const projectPath = path.join(projectsPath, 'docker-compose', 'basic'); + const service1Dockerfile = ( + await fs.readFile( + path.join(projectPath, 'service1', 'Dockerfile.template'), + 'utf8', + ) + ).replace('%%BALENA_MACHINE_NAME%%', 'nuc'); + const expectedFilesByService: ExpectedTarStreamFilesByService = { + service1: { + Dockerfile: { + contents: service1Dockerfile, + fileSize: service1Dockerfile.length, + type: 'file', + }, + 'Dockerfile.template': { fileSize: 144, type: 'file' }, + 'file1.sh': { fileSize: 12, type: 'file' }, + 'test-ignore.txt': { fileSize: 12, type: 'file' }, + }, + service2: { + '.dockerignore': { fileSize: 12, type: 'file' }, + 'Dockerfile-alt': { fileSize: 40, type: 'file' }, + 'file2-crlf.sh': { + fileSize: isWindows ? 12 : 14, + testStream: isWindows ? expectStreamNoCRLF : undefined, + type: 'file', + }, + }, + }; + const responseFilename = 'build-POST.json'; + const responseBody = await fs.readFile( + path.join(dockerResponsePath, responseFilename), + 'utf8', + ); + const expectedQueryParamsByService = { + service1: Object.entries( + _.merge({}, commonComposeQueryParams, { + buildargs: { SERVICE1_VAR: 'This is a service specific variable' }, + }), + ), + service2: Object.entries( + _.merge({}, commonComposeQueryParams, { + buildargs: { + COMPOSE_ARG: 'an argument defined in the docker-compose.yml file', + }, + dockerfile: 'Dockerfile-alt', + }), + ), + }; + const expectedResponseLines: string[] = [ + ...commonResponseLines[responseFilename], + ...[ + '[Build] service1 Step 1/4 : FROM busybox', + '[Build] service2 Step 1/4 : FROM busybox', + ], + ...[ + `[Info] ---------------------------------------------------------------------------`, + '[Info] The --multi-dockerignore option is being used, and a .dockerignore file was', + '[Info] found at the project source (root) directory. Note that this file will not', + '[Info] be used to filter service subdirectories. See "balena help build".', + `[Info] ---------------------------------------------------------------------------`, + ], + ]; + if (isWindows) { + expectedResponseLines.push( + `[Info] Converting line endings CRLF -> LF for file: ${path.join( + projectPath, + 'service2', + 'file2-crlf.sh', + )}`, + ); + } + const projectName = 'spectest'; + const tag = 'myTag'; + docker.expectGetInfo({}); + await testDockerBuildStream({ + commandLine: `build ${projectPath} --deviceType nuc --arch amd64 --convert-eol -m --tag ${tag} --projectName ${projectName}`, + dockerMock: docker, + expectedFilesByService, + expectedQueryParamsByService, + expectedResponseLines, + projectName, + projectPath, + responseBody, + responseCode: 200, + services: ['service1', 'service2'], + tag, + }); + }); }); describe('balena build: project validation', function () { diff --git a/tests/commands/device/supported.spec.ts b/tests/commands/device/supported.spec.ts index 487c64ca..50f60584 100644 --- a/tests/commands/device/supported.spec.ts +++ b/tests/commands/device/supported.spec.ts @@ -20,7 +20,7 @@ import { expect } from 'chai'; import { BalenaAPIMock } from '../../nock/balena-api-mock'; import { cleanOutput, runCommand } from '../../helpers'; -import { isV13 } from '../../../lib/utils/version'; +import { isV13 } from '../../../build/utils/version'; describe('balena devices supported', function () { let api: BalenaAPIMock; diff --git a/tests/commands/env/envs.spec.ts b/tests/commands/env/envs.spec.ts index f860425a..1e2d022c 100644 --- a/tests/commands/env/envs.spec.ts +++ b/tests/commands/env/envs.spec.ts @@ -16,7 +16,7 @@ */ import { expect } from 'chai'; -import { stripIndent } from '../../../lib/utils/lazy'; +import { stripIndent } from '../../../build/utils/lazy'; import { BalenaAPIMock } from '../../nock/balena-api-mock'; import { runCommand } from '../../helpers'; diff --git a/tests/docker-build.ts b/tests/docker-build.ts index 0a283754..9bc63263 100644 --- a/tests/docker-build.ts +++ b/tests/docker-build.ts @@ -27,7 +27,8 @@ import * as tar from 'tar-stream'; import { streamToBuffer } from 'tar-utils'; import { URL } from 'url'; -import { stripIndent } from '../lib/utils/lazy'; +import { makeImageName } from '../build/utils/compose_ts'; +import { stripIndent } from '../build/utils/lazy'; import { BuilderMock } from './nock/builder-mock'; import { DockerMock } from './nock/docker-mock'; import { @@ -161,22 +162,24 @@ export async function testDockerBuildStream(o: { expectedErrorLines?: string[]; expectedExitCode?: number; expectedResponseLines: string[]; + projectName?: string; // --projectName command line flag projectPath: string; responseCode: number; responseBody: string; services: string[]; // e.g. ['main'] or ['service1', 'service2'] + tag?: string; // --tag command line flag }) { const expectedErrorLines = deepTemplateReplace(o.expectedErrorLines || [], o); const expectedResponseLines = deepTemplateReplace(o.expectedResponseLines, o); for (const service of o.services) { // tagPrefix is, for example, 'myApp' if the path is 'path/to/myApp' - const tagPrefix = o.projectPath.split(path.sep).pop(); - const tag = `${tagPrefix}_${service}`; + const projectName = o.projectName || path.basename(o.projectPath); + const tag = makeImageName(projectName, service, o.tag); const expectedFiles = o.expectedFilesByService[service]; const expectedQueryParams = deepTemplateReplace( o.expectedQueryParamsByService[service], - { tag, ...o }, + { ...o, tag }, ); const projectPath = service === 'main' ? o.projectPath : path.join(o.projectPath, service); diff --git a/tests/nock/docker-mock.ts b/tests/nock/docker-mock.ts index a324d999..7c3c3ab8 100644 --- a/tests/nock/docker-mock.ts +++ b/tests/nock/docker-mock.ts @@ -15,8 +15,8 @@ * limitations under the License. */ -import * as _ from 'lodash'; import * as path from 'path'; +import * as qs from 'querystring'; import { NockMock, ScopeOpts } from './nock-mock'; @@ -78,7 +78,7 @@ export class DockerMock extends NockMock { checkBuildRequestBody: (requestBody: string) => Promise; }) { this.optPost( - new RegExp(`^/build\\?(|.+&)t=${_.escapeRegExp(opts.tag)}&`), + new RegExp(`^/build\\?(|.+&)${qs.stringify({ t: opts.tag })}&`), opts, ).reply(async function (uri, requestBody, cb) { let error: Error | null = null;