build, deploy: Fix processing of '--tag' option

Change-type: patch
Resolves: #825
Resolves: #1018
This commit is contained in:
Paulo Castro 2021-09-09 01:22:32 +01:00
parent e03bbb7275
commit 305c9045f0
13 changed files with 164 additions and 31 deletions

View File

@ -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,

View File

@ -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

View File

@ -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 (

View File

@ -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!',

View File

@ -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;
});

View File

@ -118,6 +118,7 @@ export async function loadProject(
logger: Logger,
opts: ComposeOpts,
image?: string,
imageTag?: string,
): Promise<ComposeProject> {
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<ComposeCliFlags> = {
"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',
}),
};

View File

@ -310,7 +310,7 @@ function connectToDocker(host: string, port: number): Docker {
});
}
export async function performBuilds(
async function performBuilds(
composition: Composition,
tarStream: Readable,
docker: Docker,

View File

@ -72,7 +72,9 @@ export const dockerConnectionCliFlags: flags.Input<DockerConnectionCliFlags> = {
export const dockerCliFlags: flags.Input<DockerCliFlags> = {
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<string> {

View File

@ -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 () {

View File

@ -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;

View File

@ -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';

View File

@ -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);

View File

@ -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<void>;
}) {
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;