mirror of
https://github.com/balena-io/balena-cli.git
synced 2024-12-18 21:27:51 +00:00
build, deploy: Fix processing of '--tag' option
Change-type: patch Resolves: #825 Resolves: #1018
This commit is contained in:
parent
e03bbb7275
commit
305c9045f0
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 (
|
||||
|
@ -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!',
|
||||
|
@ -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;
|
||||
});
|
||||
|
@ -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',
|
||||
}),
|
||||
};
|
||||
|
@ -310,7 +310,7 @@ function connectToDocker(host: string, port: number): Docker {
|
||||
});
|
||||
}
|
||||
|
||||
export async function performBuilds(
|
||||
async function performBuilds(
|
||||
composition: Composition,
|
||||
tarStream: Readable,
|
||||
docker: Docker,
|
||||
|
@ -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> {
|
||||
|
@ -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 () {
|
||||
|
@ -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;
|
||||
|
2
tests/commands/env/envs.spec.ts
vendored
2
tests/commands/env/envs.spec.ts
vendored
@ -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';
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user