Update push command for organizations

Change-type: patch
Connects-to: #2119
Signed-off-by: Scott Lowe <scott@balena.io>
This commit is contained in:
Scott Lowe 2020-12-18 16:56:47 +01:00
parent 358acbd2c8
commit 27e2b03702
5 changed files with 130 additions and 171 deletions

View File

@ -2439,7 +2439,7 @@ Docker host TLS key file
## push &#60;applicationOrDevice&#62; ## push &#60;applicationOrDevice&#62;
Start a build on the remote balenaCloud builders, or a local mode balena device. Start a build on the remote balenaCloud build servers, or a local mode device.
When building on the balenaCloud servers, the given source directory will be When building on the balenaCloud servers, the given source directory will be
sent to the remote server. This can be used as a drop-in replacement for the sent to the remote server. This can be used as a drop-in replacement for the
@ -2545,6 +2545,7 @@ Examples:
$ balena push myApp --source <source directory> $ balena push myApp --source <source directory>
$ balena push myApp -s <source directory> $ balena push myApp -s <source directory>
$ balena push myApp --release-tag key1 "" key2 "value2 with spaces" $ balena push myApp --release-tag key1 "" key2 "value2 with spaces"
$ balena push myorg/myapp
$ balena push 10.0.0.1 $ balena push 10.0.0.1
$ balena push 10.0.0.1 --source <source directory> $ balena push 10.0.0.1 --source <source directory>
@ -2559,7 +2560,7 @@ Examples:
#### APPLICATIONORDEVICE #### APPLICATIONORDEVICE
application name, or device address (for local pushes) application name or slug, or local device IP address or hostname
### Options ### Options

View File

@ -22,7 +22,9 @@ import { getBalenaSdk, stripIndent } from '../utils/lazy';
import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages'; import { dockerignoreHelp, registrySecretsHelp } from '../utils/messages';
import type { BalenaSDK, Application, Organization } from 'balena-sdk'; import type { BalenaSDK, Application, Organization } from 'balena-sdk';
import { ExpectedError, instanceOf } from '../errors'; import { ExpectedError, instanceOf } from '../errors';
import type { RegistrySecrets } from 'resin-multibuild'; import { isV13 } from '../utils/version';
import { RegistrySecrets } from 'resin-multibuild';
import { lowercaseIfSlug } from '../utils/normalization';
enum BuildTarget { enum BuildTarget {
Cloud, Cloud,
@ -30,23 +32,23 @@ enum BuildTarget {
} }
interface FlagsDef { interface FlagsDef {
source?: string; source: string;
emulated: boolean; emulated: boolean;
dockerfile?: string; // DeviceDeployOptions.dockerfilePath (alternative Dockerfile) dockerfile?: string; // DeviceDeployOptions.dockerfilePath (alternative Dockerfile)
nocache?: boolean; nocache: boolean;
pull?: boolean; pull: boolean;
'noparent-check'?: boolean; 'noparent-check': boolean;
'registry-secrets'?: string; 'registry-secrets'?: string;
gitignore?: boolean; gitignore?: boolean;
nogitignore?: boolean; nogitignore?: boolean;
nolive?: boolean; nolive: boolean;
detached?: boolean; detached: boolean;
service?: string[]; service?: string[];
system?: boolean; system: boolean;
env?: string[]; env?: string[];
'convert-eol'?: boolean; 'convert-eol'?: boolean;
'noconvert-eol'?: boolean; 'noconvert-eol': boolean;
'multi-dockerignore'?: boolean; 'multi-dockerignore': boolean;
'release-tag'?: string[]; 'release-tag'?: string[];
help: void; help: void;
} }
@ -57,9 +59,9 @@ interface ArgsDef {
export default class PushCmd extends Command { export default class PushCmd extends Command {
public static description = stripIndent` public static description = stripIndent`
Start a remote build on the balenaCloud build servers or a local mode device. Start a build on the remote balenaCloud build servers, or a local mode device.
Start a build on the remote balenaCloud builders, or a local mode balena device. Start a build on the remote balenaCloud build servers, or a local mode device.
When building on the balenaCloud servers, the given source directory will be When building on the balenaCloud servers, the given source directory will be
sent to the remote server. This can be used as a drop-in replacement for the sent to the remote server. This can be used as a drop-in replacement for the
@ -95,6 +97,7 @@ export default class PushCmd extends Command {
'$ balena push myApp --source <source directory>', '$ balena push myApp --source <source directory>',
'$ balena push myApp -s <source directory>', '$ balena push myApp -s <source directory>',
'$ balena push myApp --release-tag key1 "" key2 "value2 with spaces"', '$ balena push myApp --release-tag key1 "" key2 "value2 with spaces"',
'$ balena push myorg/myapp',
'', '',
'$ balena push 10.0.0.1', '$ balena push 10.0.0.1',
'$ balena push 10.0.0.1 --source <source directory>', '$ balena push 10.0.0.1 --source <source directory>',
@ -109,8 +112,10 @@ export default class PushCmd extends Command {
public static args = [ public static args = [
{ {
name: 'applicationOrDevice', name: 'applicationOrDevice',
description: 'application name, or device address (for local pushes)', description:
'application name or slug, or local device IP address or hostname',
required: true, required: true,
parse: lowercaseIfSlug,
}, },
]; ];
@ -122,12 +127,14 @@ export default class PushCmd extends Command {
Source directory to be sent to balenaCloud or balenaOS device Source directory to be sent to balenaCloud or balenaOS device
(default: current working dir)`, (default: current working dir)`,
char: 's', char: 's',
default: '.',
}), }),
emulated: flags.boolean({ emulated: flags.boolean({
description: stripIndent` description: stripIndent`
Don't use native ARM servers; force QEMU ARM emulation on Intel x86-64 Don't use native ARM servers; force QEMU ARM emulation on Intel x86-64
servers during the image build (balenaCloud).`, servers during the image build (balenaCloud).`,
char: 'e', char: 'e',
default: false,
}), }),
dockerfile: flags.string({ dockerfile: flags.string({
description: description:
@ -142,15 +149,18 @@ export default class PushCmd extends Command {
updates), but the logs will not display the "Using cache" lines for each updates), but the logs will not display the "Using cache" lines for each
build step of a Dockerfile.`, build step of a Dockerfile.`,
char: 'c', char: 'c',
default: false,
}), }),
pull: flags.boolean({ pull: flags.boolean({
description: stripIndent` description: stripIndent`
When pushing to a local device, force the base images to be pulled again. When pushing to a local device, force the base images to be pulled again.
Currently this option is ignored when pushing to the balenaCloud builders.`, Currently this option is ignored when pushing to the balenaCloud builders.`,
default: false,
}), }),
'noparent-check': flags.boolean({ 'noparent-check': flags.boolean({
description: stripIndent` description: stripIndent`
Disable project validation check of 'docker-compose.yml' file in parent folder`, Disable project validation check of 'docker-compose.yml' file in parent folder`,
default: false,
}), }),
'registry-secrets': flags.string({ 'registry-secrets': flags.string({
description: stripIndent` description: stripIndent`
@ -166,6 +176,7 @@ export default class PushCmd extends Command {
and changes will not be synchronized to any running containers. Note that both and changes will not be synchronized to any running containers. Note that both
this flag and --detached and required to cause the process to end once the this flag and --detached and required to cause the process to end once the
initial build has completed.`, initial build has completed.`,
default: false,
}), }),
detached: flags.boolean({ detached: flags.boolean({
description: stripIndent` description: stripIndent`
@ -174,6 +185,7 @@ export default class PushCmd extends Command {
applicable). When pushing to a local mode device, this option will cause applicable). When pushing to a local mode device, this option will cause
the command to not tail application logs when the build has completed.`, the command to not tail application logs when the build has completed.`,
char: 'd', char: 'd',
default: false,
}), }),
service: flags.string({ service: flags.string({
description: stripIndent` description: stripIndent`
@ -186,6 +198,7 @@ export default class PushCmd extends Command {
description: stripIndent` description: stripIndent`
Only show system logs. This can be used in combination with --service. Only show system logs. This can be used in combination with --service.
Only valid when pushing to a local mode device.`, Only valid when pushing to a local mode device.`,
default: false,
}), }),
env: flags.string({ env: flags.string({
description: stripIndent` description: stripIndent`
@ -199,25 +212,37 @@ export default class PushCmd extends Command {
`, `,
multiple: true, multiple: true,
}), }),
...(isV13()
? {}
: {
'convert-eol': flags.boolean({ 'convert-eol': flags.boolean({
description: 'No-op and deprecated since balena CLI v12.0.0', description: 'No-op and deprecated since balena CLI v12.0.0',
char: 'l', char: 'l',
hidden: true, hidden: true,
default: false,
}),
}), }),
'noconvert-eol': flags.boolean({ 'noconvert-eol': flags.boolean({
description: `Don't convert line endings from CRLF (Windows format) to LF (Unix format).`, description: `Don't convert line endings from CRLF (Windows format) to LF (Unix format).`,
default: false,
}), }),
'multi-dockerignore': flags.boolean({ 'multi-dockerignore': flags.boolean({
description: description:
'Have each service use its own .dockerignore file. See "balena help push".', 'Have each service use its own .dockerignore file. See "balena help push".',
char: 'm', char: 'm',
default: false,
exclusive: ['gitignore'], exclusive: ['gitignore'],
}), }),
...(isV13()
? {}
: {
nogitignore: flags.boolean({ nogitignore: flags.boolean({
description: description:
'No-op (default behavior) since balena CLI v12.0.0. See "balena help push".', 'No-op (default behavior) since balena CLI v12.0.0. See "balena help push".',
char: 'G', char: 'G',
hidden: true, hidden: true,
default: false,
}),
}), }),
gitignore: flags.boolean({ gitignore: flags.boolean({
description: stripIndent` description: stripIndent`
@ -225,6 +250,7 @@ export default class PushCmd extends Command {
to the CLI v11 behavior/implementation (deprecated) if compatibility is to the CLI v11 behavior/implementation (deprecated) if compatibility is
required until your project can be adapted.`, required until your project can be adapted.`,
char: 'g', char: 'g',
default: false,
exclusive: ['multi-dockerignore'], exclusive: ['multi-dockerignore'],
}), }),
'release-tag': flags.string({ 'release-tag': flags.string({
@ -246,75 +272,59 @@ export default class PushCmd extends Command {
PushCmd, PushCmd,
); );
const logger = await Command.getLogger();
logger.logDebug(`Using build source directory: ${options.source} `);
const sdk = getBalenaSdk(); const sdk = getBalenaSdk();
const { validateProjectDirectory } = await import('../utils/compose_ts'); const { validateProjectDirectory } = await import('../utils/compose_ts');
const source = options.source || '.';
if (process.env.DEBUG) {
console.error(`[debug] Using ${source} as build source`);
}
const { dockerfilePath, registrySecrets } = await validateProjectDirectory( const { dockerfilePath, registrySecrets } = await validateProjectDirectory(
sdk, sdk,
{ {
dockerfilePath: options.dockerfile, dockerfilePath: options.dockerfile,
noParentCheck: options['noparent-check'] || false, noParentCheck: options['noparent-check'],
projectPath: source, projectPath: options.source,
registrySecretsPath: options['registry-secrets'], registrySecretsPath: options['registry-secrets'],
}, },
); );
const nogitignore = !options.gitignore; switch (await this.getBuildTarget(params.applicationOrDevice)) {
const convertEol = !options['noconvert-eol'];
const appOrDevice = params.applicationOrDevice;
const buildTarget = await this.getBuildTarget(appOrDevice);
switch (buildTarget) {
case BuildTarget.Cloud: case BuildTarget.Cloud:
logger.logDebug(
`Pushing to cloud for application: ${params.applicationOrDevice}`,
);
await this.pushToCloud( await this.pushToCloud(
params.applicationOrDevice,
options, options,
sdk, sdk,
appOrDevice,
dockerfilePath, dockerfilePath,
registrySecrets, registrySecrets,
convertEol,
source,
nogitignore,
); );
break; break;
case BuildTarget.Device: case BuildTarget.Device:
logger.logDebug(
`Pushing to local device: ${params.applicationOrDevice}`,
);
await this.pushToDevice( await this.pushToDevice(
params.applicationOrDevice,
options, options,
sdk,
appOrDevice,
dockerfilePath, dockerfilePath,
registrySecrets, registrySecrets,
convertEol,
source,
nogitignore,
); );
break; break;
default:
throw new ExpectedError(stripIndent`
Build target not recognized. Please provide either an application name or
device IP address.`);
} }
} }
async pushToCloud( protected async pushToCloud(
appNameOrSlug: string,
options: FlagsDef, options: FlagsDef,
sdk: BalenaSDK, sdk: BalenaSDK,
appOrDevice: string,
dockerfilePath: string, dockerfilePath: string,
registrySecrets: RegistrySecrets, registrySecrets: RegistrySecrets,
convertEol: boolean,
source: string,
nogitignore: boolean,
) { ) {
const _ = await import('lodash');
const remote = await import('../utils/remote-build'); const remote = await import('../utils/remote-build');
const { getApplication } = await import('../utils/sdk');
// Check for invalid options // Check for invalid options
const localOnlyOptions: Array<keyof FlagsDef> = [ const localOnlyOptions: Array<keyof FlagsDef> = [
@ -347,36 +357,46 @@ export default class PushCmd extends Command {
releaseTagValues.push(''); releaseTagValues.push('');
} }
const app = appOrDevice;
await Command.checkLoggedIn(); await Command.checkLoggedIn();
const [token, baseUrl, owner] = await Promise.all([ const [token, baseUrl] = await Promise.all([
sdk.auth.getToken(), sdk.auth.getToken(),
sdk.settings.get('balenaUrl'), sdk.settings.get('balenaUrl'),
this.getAppOwner(sdk, app),
]); ]);
const application = (await getApplication(sdk, appNameOrSlug, {
$expand: {
organization: {
$select: ['handle'],
},
},
$select: ['app_name'],
})) as Application & {
organization: [Organization];
};
const opts = { const opts = {
dockerfilePath, dockerfilePath,
emulated: options.emulated || false, emulated: options.emulated,
multiDockerignore: options['multi-dockerignore'] || false, multiDockerignore: options['multi-dockerignore'],
nocache: options.nocache || false, nocache: options.nocache,
registrySecrets, registrySecrets,
headless: options.detached || false, headless: options.detached,
convertEol, convertEol: !options['noconvert-eol'],
}; };
const args = { const args = {
app, app: application.app_name,
owner, owner: application.organization[0].handle,
source, source: options.source,
auth: token, auth: token,
baseUrl, baseUrl,
nogitignore, nogitignore: !options.gitignore,
sdk, sdk,
opts, opts,
}; };
const releaseId = await remote.startRemoteBuild(args); const releaseId = await remote.startRemoteBuild(args);
if (releaseId) { if (releaseId) {
// Above we have checked that releaseTagKeys and releaseTagValues are of the same size // Above we have checked that releaseTagKeys and releaseTagValues are of the same size
const _ = await import('lodash');
await Promise.all( await Promise.all(
(_.zip(releaseTagKeys, releaseTagValues) as Array< (_.zip(releaseTagKeys, releaseTagValues) as Array<
[string, string] [string, string]
@ -391,15 +411,11 @@ export default class PushCmd extends Command {
} }
} }
async pushToDevice( protected async pushToDevice(
localDeviceAddress: string,
options: FlagsDef, options: FlagsDef,
_sdk: BalenaSDK,
appOrDevice: string,
dockerfilePath: string, dockerfilePath: string,
registrySecrets: RegistrySecrets, registrySecrets: RegistrySecrets,
convertEol: boolean,
source: string,
nogitignore: boolean,
) { ) {
// Check for invalid options // Check for invalid options
const remoteOnlyOptions: Array<keyof FlagsDef> = ['release-tag']; const remoteOnlyOptions: Array<keyof FlagsDef> = ['release-tag'];
@ -410,27 +426,24 @@ export default class PushCmd extends Command {
); );
const deviceDeploy = await import('../utils/device/deploy'); const deviceDeploy = await import('../utils/device/deploy');
const device = appOrDevice;
const servicesToDisplay = options.service;
// TODO: Support passing a different port
try { try {
await deviceDeploy.deployToDevice({ await deviceDeploy.deployToDevice({
source, source: options.source,
deviceHost: device, deviceHost: localDeviceAddress,
dockerfilePath, dockerfilePath,
registrySecrets, registrySecrets,
multiDockerignore: options['multi-dockerignore'] || false, multiDockerignore: options['multi-dockerignore'],
nocache: options.nocache || false, nocache: options.nocache,
pull: options.pull || false, pull: options.pull,
nogitignore, nogitignore: !options.gitignore,
noParentCheck: options['noparent-check'] || false, noParentCheck: options['noparent-check'],
nolive: options.nolive || false, nolive: options.nolive,
detached: options.detached || false, detached: options.detached,
services: servicesToDisplay, services: options.service,
system: options.system || false, system: options.system,
env: options.env || [], env: options.env || [],
convertEol, convertEol: !options['noconvert-eol'],
}); });
} catch (e) { } catch (e) {
const { BuildError } = await import('../utils/device/errors'); const { BuildError } = await import('../utils/device/errors');
@ -442,80 +455,15 @@ export default class PushCmd extends Command {
} }
} }
async getBuildTarget(appOrDevice: string): Promise<BuildTarget | null> { protected async getBuildTarget(appOrDevice: string): Promise<BuildTarget> {
const { const { validateLocalHostnameOrIp } = await import('../utils/validation');
validateApplicationName,
validateDotLocalUrl,
validateIPAddress,
} = await import('../utils/validation');
// First try the application regex from the api return validateLocalHostnameOrIp(appOrDevice)
if (validateApplicationName(appOrDevice)) { ? BuildTarget.Device
return BuildTarget.Cloud; : BuildTarget.Cloud;
} }
if (validateIPAddress(appOrDevice) || validateDotLocalUrl(appOrDevice)) { protected checkInvalidOptions(
return BuildTarget.Device;
}
return null;
}
async getAppOwner(sdk: BalenaSDK, appName: string) {
const _ = await import('lodash');
const applications = (await sdk.models.application.getAll({
$expand: {
organization: {
$select: ['handle'],
},
},
$filter: {
$eq: [{ $tolower: { $: 'app_name' } }, appName.toLowerCase()],
},
$select: ['id'],
})) as Array<
Application & {
organization: [Organization];
}
>;
if (applications == null || applications.length === 0) {
throw new ExpectedError(
stripIndent`
No applications found with name: ${appName}.
This could mean that the application does not exist, or you do
not have the permissions required to access it.`,
);
}
if (applications.length === 1) {
return applications[0].organization[0].handle;
}
// If we got more than one application with the same name it means that the
// user has access to a collab app with the same name as a personal app. We
// present a list to the user which shows the fully qualified application
// name (user/appname) and allows them to select
const entries = _.map(applications, (app) => {
const username = app.organization[0].handle;
return {
name: `${username}/${appName}`,
extra: username,
};
});
const { selectFromList } = await import('../utils/patterns');
const selected = await selectFromList(
`${entries.length} applications found with that name, please select the application you would like to push to`,
entries,
);
return selected.extra;
}
checkInvalidOptions(
invalidOptions: Array<keyof FlagsDef>, invalidOptions: Array<keyof FlagsDef>,
options: FlagsDef, options: FlagsDef,
errorMessage: string, errorMessage: string,

View File

@ -117,3 +117,8 @@ export function parseAsLocalHostnameOrIp(input: string, paramName?: string) {
return input; return input;
} }
export function looksLikeAppSlug(input: string) {
// One or more non whitespace chars, /, 4 or more non whitespace chars
return /[\S]+\/[\S]{4,}/.test(input);
}

View File

@ -66,7 +66,7 @@ const commonResponseLines = {
}; };
const commonQueryParams = [ const commonQueryParams = [
['owner', 'bob'], ['owner', 'gh_user'],
['app', 'testApp'], ['app', 'testApp'],
['dockerfilePath', ''], ['dockerfilePath', ''],
['emulated', 'false'], ['emulated', 'false'],
@ -87,7 +87,7 @@ describe('balena push', function () {
builder = new BuilderMock(); builder = new BuilderMock();
api.expectGetWhoAmI({ optional: true, persist: true }); api.expectGetWhoAmI({ optional: true, persist: true });
api.expectGetMixpanel({ optional: true }); api.expectGetMixpanel({ optional: true });
api.expectGetMyApplication(); api.expectGetApplication();
}); });
this.afterEach(() => { this.afterEach(() => {
@ -145,7 +145,7 @@ describe('balena push', function () {
await testPushBuildStream({ await testPushBuildStream({
builderMock: builder, builderMock: builder,
commandLine: `push testApp --source ${projectPath} -R ${regSecretsPath} -G`, commandLine: `push testApp --source ${projectPath} -R ${regSecretsPath}`,
expectedFiles, expectedFiles,
expectedQueryParams: commonQueryParams, expectedQueryParams: commonQueryParams,
expectedResponseLines, expectedResponseLines,
@ -345,7 +345,7 @@ describe('balena push', function () {
await testPushBuildStream({ await testPushBuildStream({
builderMock: builder, builderMock: builder,
commandLine: `push testApp -s ${projectPath} -R ${regSecretsPath} -G`, commandLine: `push testApp -s ${projectPath} -R ${regSecretsPath}`,
expectedFiles, expectedFiles,
expectedQueryParams: commonQueryParams, expectedQueryParams: commonQueryParams,
expectedResponseLines: commonResponseLines[responseFilename], expectedResponseLines: commonResponseLines[responseFilename],

View File

@ -17,6 +17,11 @@
}, },
"__id": 43699 "__id": 43699
}, },
"organization": [
{
"handle": "gh_user"
}
],
"depends_on__application": null, "depends_on__application": null,
"actor": 3423895, "actor": 3423895,
"app_name": "testApp", "app_name": "testApp",