Compare commits

...

21 Commits

Author SHA1 Message Date
7f8106a64b WIP 2022-05-03 21:25:32 +00:00
ba3a3865b5 v13.4.1 2022-04-29 04:45:30 +03:00
f8402bc40c Merge pull request #2444 from balena-io/balena-leave-clearer-message
patch: Tell user that balena leave command does not remove the device…
2022-04-11 16:33:26 +00:00
c667ffa8eb leave: Update log message to advise that device still needs deleting
Change-type: patch
2022-04-11 17:04:45 +01:00
6d6065ddf5 v13.4.0 2022-04-11 17:18:27 +03:00
44f55f8e7b Merge pull request #2473 from balena-io/2337-support-all-valid-sermer-on-releases
deploy: Support all valid semver versions in balena.yml
2022-04-11 14:05:16 +00:00
d2c77760b3 deploy: Support all valid semver versions in balena.yml
Resolves: #2337
Change-type: minor
Depends-on: https://github.com/balena-io/open-balena-api/pull/982
Depends-on: https://github.com/balena-io/balena-api/pull/3584
See: https://jel.ly.fish/product-improvement-draft-releases-and-release-versioning-d0391f45-c2f9-4f4e-b964-1a7e9023a3f4
Signed-off-by: Thodoris Greasidis <thodoris@balena.io>
2022-04-08 18:16:45 +03:00
7496710c85 v13.3.3 2022-04-08 14:39:33 +03:00
be6a468507 Merge pull request #2471 from balena-io/patches-contributing
Document the 'patches' folder in CONTRIBUTING.md
2022-04-08 11:36:49 +00:00
88835e63bd Document the 'patches' folder in CONTRIBUTING.md
Change-type: patch
2022-04-08 01:16:28 +01:00
3572cb3cd6 v13.3.2 2022-04-07 13:25:31 +03:00
7fbd1de063 Merge pull request #2470 from balena-io/2469-build-docker-tls
build: Ensure HTTPS is used with dockerPort 2376 or with ca/cert/key
2022-04-07 10:22:54 +00:00
a4ab07cd08 Skip Alpine tests until Concourse + Alpine v3.14 issues are resolved
See:
* https://github.com/concourse/concourse/issues/7905
* https://github.com/product-os/balena-concourse/issues/631
* https://github.com/product-os/ci-images/pull/116/files#r844508619

Change-type: patch
2022-04-07 00:29:55 +01:00
9185eaa2b7 build: Ensure HTTPS is used with dockerPort 2376 or with ca/cert/key
Change-type: patch
2022-04-07 00:14:03 +01:00
ff3abe1fba v13.3.1 2022-03-08 22:33:20 +02:00
1ac3b70b81 Merge pull request #2463 from balena-io/update-notifier-release-notes
Include link to Wiki release notes in version update notifications
2022-03-08 20:31:39 +00:00
e946178953 Include link to Wiki release notes in version update notifications
Change-type: patch
2022-03-08 18:25:08 +00:00
6589589bee v13.3.0 2022-03-01 00:35:10 +02:00
6ae598b55e Merge pull request #2461 from balena-io/2458-ssh-ipaddr-service
ssh: Allow ssh to service with IP address and production balenaOS image
2022-02-28 22:33:02 +00:00
915f7e3763 ssh: Allow ssh to service with IP address and production balenaOS image
Also remove 'balena ssh' dependency on the device supervisor (that may
be down because of device issues or a supervisor bug) when opening a
ssh shell on a container (#1560).

Resolves: #2458
Resolves: #1560
Change-type: minor
2022-02-28 21:39:49 +00:00
cd17d79067 ssh: Advise use of 'balena login' if root authentication fails
Change-type: patch
2022-02-24 21:48:40 +00:00
22 changed files with 639 additions and 262 deletions

View File

@ -7,9 +7,14 @@ npm:
node_versions:
- "12"
- "14"
- name: linux
os: alpine
architecture: x86_64
node_versions:
- "12"
- "14"
##
## Temporarily skip Alpine tests until the following issues are resolved:
## * https://github.com/concourse/concourse/issues/7905
## * https://github.com/product-os/balena-concourse/issues/631
##
# - name: linux
# os: alpine
# architecture: x86_64
# node_versions:
# - "12"
# - "14"

View File

@ -1,3 +1,108 @@
- commits:
- subject: "leave: Update log message to advise that device still needs deleting"
hash: c667ffa8eb60e321308bcf0cf474781cdb70e29c
body: ""
footer:
Change-type: patch
change-type: patch
author: Taro Murao
nested: []
version: 13.4.1
title: "'patch: Tell user that balena leave command does not remove the device…'"
date: 2022-04-11T16:10:46.871Z
- commits:
- subject: "deploy: Support all valid semver versions in balena.yml"
hash: d2c77760b3e2001e5d18b48e5abf218d0998a4cd
body: ""
footer:
Resolves: "#2337"
resolves: "#2337"
Change-type: minor
change-type: minor
Depends-on: https://github.com/balena-io/balena-api/pull/3584
depends-on: https://github.com/balena-io/balena-api/pull/3584
See: https://jel.ly.fish/product-improvement-draft-releases-and-release-versioning-d0391f45-c2f9-4f4e-b964-1a7e9023a3f4
see: https://jel.ly.fish/product-improvement-draft-releases-and-release-versioning-d0391f45-c2f9-4f4e-b964-1a7e9023a3f4
Signed-off-by: Thodoris Greasidis <thodoris@balena.io>
signed-off-by: Thodoris Greasidis <thodoris@balena.io>
author: Thodoris Greasidis
nested: []
version: 13.4.0
title: "'deploy: Support all valid semver versions in balena.yml'"
date: 2022-04-08T15:19:35.411Z
- commits:
- subject: Document the 'patches' folder in CONTRIBUTING.md
hash: 88835e63bd0c37b7be99f63214d17a429d937035
body: ""
footer:
Change-type: patch
change-type: patch
author: Paulo Castro
nested: []
version: 13.3.3
title: "'Document the 'patches' folder in CONTRIBUTING.md'"
date: 2022-04-08T10:36:01.636Z
- commits:
- subject: Skip Alpine tests until Concourse + Alpine v3.14 issues are resolved
hash: a4ab07cd085d84d75bc668390823bf72604730e0
body: |
See:
* https://github.com/concourse/concourse/issues/7905
* https://github.com/product-os/balena-concourse/issues/631
* https://github.com/product-os/ci-images/pull/116/files#r844508619
footer:
Change-type: patch
change-type: patch
author: Paulo Castro
nested: []
- subject: "build: Ensure HTTPS is used with dockerPort 2376 or with ca/cert/key"
hash: 9185eaa2b742bb694abe8b300221bf7437e7e93f
body: ""
footer:
Change-type: patch
change-type: patch
author: Paulo Castro
nested: []
version: 13.3.2
title: "'build: Ensure HTTPS is used with dockerPort 2376 or with ca/cert/key'"
date: 2022-04-07T09:15:48.137Z
- commits:
- subject: Include link to Wiki release notes in version update notifications
hash: e9461789531df561165ea2ca90a00d6fe9a0f9b6
body: ""
footer:
Change-type: patch
change-type: patch
author: Paulo Castro
nested: []
version: 13.3.1
title: "'Include link to Wiki release notes in version update notifications'"
date: 2022-03-08T18:34:21.932Z
- commits:
- subject: "ssh: Allow ssh to service with IP address and production balenaOS image"
hash: 915f7e3763991700d4746e3581099d5793a58648
body: |
Also remove 'balena ssh' dependency on the device supervisor (that may
be down because of device issues or a supervisor bug) when opening a
ssh shell on a container (#1560).
footer:
Resolves: "#1560"
resolves: "#1560"
Change-type: minor
change-type: minor
author: Paulo Castro
nested: []
- subject: "ssh: Advise use of 'balena login' if root authentication fails"
hash: cd17d790673229ca0dfa42666a1800916a987578
body: ""
footer:
Change-type: patch
change-type: patch
author: Paulo Castro
nested: []
version: 13.3.0
title: "'ssh: Allow ssh to service with IP address and production balenaOS image'"
date: 2022-02-28T21:42:42.420Z
- commits:
- subject: Remove unnecessary fetch of device info in `balena tunnel`
hash: bd1bf8153d5c58be31a9fef44da4b13c20a3e036

View File

@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file
automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY!
This project adheres to [Semantic Versioning](http://semver.org/).
## 13.4.1 - 2022-04-11
* leave: Update log message to advise that device still needs deleting [Taro Murao]
## 13.4.0 - 2022-04-08
* deploy: Support all valid semver versions in balena.yml [Thodoris Greasidis]
## 13.3.3 - 2022-04-08
* Document the 'patches' folder in CONTRIBUTING.md [Paulo Castro]
## 13.3.2 - 2022-04-07
* Skip Alpine tests until Concourse + Alpine v3.14 issues are resolved [Paulo Castro]
* build: Ensure HTTPS is used with dockerPort 2376 or with ca/cert/key [Paulo Castro]
## 13.3.1 - 2022-03-08
* Include link to Wiki release notes in version update notifications [Paulo Castro]
## 13.3.0 - 2022-02-28
* ssh: Allow ssh to service with IP address and production balenaOS image [Paulo Castro]
* ssh: Advise use of 'balena login' if root authentication fails [Paulo Castro]
## 13.2.1 - 2022-02-24
* Remove unnecessary fetch of device info in `balena tunnel` [Pagan Gazzard]

View File

@ -125,6 +125,39 @@ The README file is manually edited, but subsections are automatically extracted
The `INSTALL*.md` and `TROUBLESHOOTING.md` files are also manually edited.
## Patches folder
The `patches` folder contains patch files created with the
[patch-package](https://www.npmjs.com/package/patch-package) tool. Small code changes to
third-party modules can be made by directly editing Javascript files under the `node_modules`
folder and then running `patch-package` to create the patch files. The patch files are then
applied immediately after `npm install`, through the `postinstall` script defined in
`package.json`.
The subfolders of the `patches` folder are documented in the
[apply-patches.js](https://github.com/balena-io/balena-cli/blob/master/patches/apply-patches.js)
script.
To make changes to the patch files under the `patches` folder, **do not edit them directly,**
not even for a "single character change" because the hash values in the patch files also need
to be recomputed by `patch-packages`. Instead, edit the relevant files under `node_modules`
directly, and then run `patch-packages` with the `--patch-dir` option to specify the subfolder
where the patch should be saved. For example, edit `node_modules/exit-hook/index.js` and then
run:
```sh
$ npx patch-package --patch-dir patches/all exit-hook
```
That said, these kinds of patches should be avoided in favour of creating pull requests
upstream. Patch files create additional maintenance work over time as the patches need to be
updated when the dependencies are updated, and they prevent the compounding community benefit
that sharing fixes upstream have on open source projects like the balena CLI. The typical
scenario where these patches are used is when the upstream maintainers are unresponsive or
unwilling to merge the required fixes, the fixes are very small and specific to the balena CLI,
and creating a fork of the upstream repo is likely to be more long-term effort than maintaining
the patches.
## Windows
Besides the regular npm installation dependencies, the `npm run build:installer` script

View File

@ -3020,6 +3020,10 @@ Use QEMU for ARM architecture emulation during the image build
Alternative Dockerfile name/path, relative to the source folder
#### --dockercompose DOCKERCOMPOSE
Alternative docker-compose.yml name in the source root folder
#### --logs
No-op and deprecated since balena CLI v12.0.0. Build logs are now shown by default.
@ -3244,6 +3248,10 @@ Use QEMU for ARM architecture emulation during the image build
Alternative Dockerfile name/path, relative to the source folder
#### --dockercompose DOCKERCOMPOSE
Alternative docker-compose.yml name in the source root folder
#### --logs
No-op and deprecated since balena CLI v12.0.0. Build logs are now shown by default.

View File

@ -114,7 +114,7 @@ ${dockerignoreHelp}
];
public static usage = 'deploy <fleet> [image]';
// TODO: docker-compose naming
public static flags: flags.Input<FlagsDef> = {
source: flags.string({
description:

View File

@ -138,6 +138,7 @@ export default class PushCmd extends Command {
char: 'e',
default: false,
}),
// TODO: docker-compose naming
dockerfile: flags.string({
description:
'Alternative Dockerfile name/path, relative to the source folder',

View File

@ -20,7 +20,6 @@ import Command from '../command';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../utils/lazy';
import { parseAsInteger, validateLocalHostnameOrIp } from '../utils/validation';
import * as BalenaSdk from 'balena-sdk';
interface FlagsDef {
port?: number;
@ -128,8 +127,8 @@ export default class SshCmd extends Command {
if (validateLocalHostnameOrIp(params.fleetOrDevice)) {
const { performLocalDeviceSSH } = await import('../utils/device/ssh');
return await performLocalDeviceSSH({
address: params.fleetOrDevice,
port: options.port,
hostname: params.fleetOrDevice,
port: options.port || 'local',
forceTTY: options.tty,
verbose: options.verbose,
service: params.service,
@ -152,12 +151,6 @@ export default class SshCmd extends Command {
params.fleetOrDevice,
);
const device = await sdk.models.device.get(deviceUuid, {
$select: ['id', 'supervisor_version', 'is_online'],
});
const deviceId = device.id;
const supervisorVersion = device.supervisor_version;
const { which } = await import('../utils/which');
const [whichProxytunnel, username, proxyUrl] = await Promise.all([
@ -209,19 +202,15 @@ export default class SshCmd extends Command {
// that we know exists and is accessible
let containerId: string | undefined;
if (params.service != null) {
containerId = await this.getContainerId(
sdk,
const { getContainerIdForService } = await import('../utils/device/ssh');
containerId = await getContainerIdForService({
deviceUuid,
params.service,
{
port: options.port,
proxyCommand,
proxyUrl: proxyUrl || '',
username: username!,
},
supervisorVersion,
deviceId,
);
hostname: `ssh.${proxyUrl}`,
port: options.port || 'cloud',
proxyCommand,
service: params.service,
username: username!,
});
}
let accessCommand: string;
@ -234,101 +223,10 @@ export default class SshCmd extends Command {
await runRemoteCommand({
cmd: accessCommand,
hostname: `ssh.${proxyUrl}`,
port: options.port,
port: options.port || 'cloud',
proxyCommand,
username,
verbose: options.verbose,
});
}
async getContainerId(
sdk: BalenaSdk.BalenaSDK,
uuid: string,
serviceName: string,
sshOpts: {
port?: number;
proxyCommand?: string[];
proxyUrl: string;
username: string;
},
version?: string,
id?: number,
): Promise<string> {
const semver = await import('balena-semver');
if (version == null || id == null) {
const device = await sdk.models.device.get(uuid, {
$select: ['id', 'supervisor_version'],
});
version = device.supervisor_version;
id = device.id;
}
let containerId: string | undefined;
if (semver.gte(version, '8.6.0')) {
const apiUrl = await sdk.settings.get('apiUrl');
// TODO: Move this into the SDKs device model
const request = await sdk.request.send({
method: 'POST',
url: '/supervisor/v2/containerId',
baseUrl: apiUrl,
body: {
method: 'GET',
deviceId: id,
},
});
if (request.status !== 200) {
throw new Error(
`There was an error connecting to device ${uuid}, HTTP response code: ${request.status}.`,
);
}
const body = request.body;
if (body.status !== 'success') {
throw new Error(
`There was an error communicating with device ${uuid}.\n\tError: ${body.message}`,
);
}
containerId = body.services[serviceName];
} else {
console.error(stripIndent`
Using slow legacy method to determine container IDs. To speed up
this process, update the device supervisor to v8.6.0 or later.
`);
// We need to execute a balena ps command on the device,
// and parse the output, looking for a specific
// container
const { escapeRegExp } = await import('lodash');
const { deviceContainerEngineBinary } = await import(
'../utils/device/ssh'
);
const { getRemoteCommandOutput } = await import('../utils/ssh');
const containers: string = (
await getRemoteCommandOutput({
cmd: `host ${uuid} "${deviceContainerEngineBinary}" ps --format "{{.ID}} {{.Names}}"`,
hostname: `ssh.${sshOpts.proxyUrl}`,
port: sshOpts.port,
proxyCommand: sshOpts.proxyCommand,
stderr: 'inherit',
username: sshOpts.username,
})
).stdout.toString();
const lines = containers.split('\n');
const regex = new RegExp(`\\/?${escapeRegExp(serviceName)}_\\d+_\\d+`);
for (const container of lines) {
const [cId, name] = container.split(' ');
if (regex.test(name)) {
containerId = cId;
break;
}
}
}
if (containerId == null) {
throw new Error(
`Could not find a service ${serviceName} on device ${uuid}.`,
);
}
return containerId;
}
}

View File

@ -61,6 +61,7 @@ export interface ComposeOpts {
export interface ComposeCliFlags {
emulated: boolean;
dockerfile?: string;
dockercompose?: string;
logs: boolean;
nologs: boolean;
'multi-dockerignore': boolean;

View File

@ -28,6 +28,7 @@ import type {
ImageDescriptor,
} from 'resin-compose-parse';
import type * as MultiBuild from 'resin-multibuild';
import * as semver from 'semver';
import type { Duplex, Readable } from 'stream';
import type { Pack } from 'tar-stream';
@ -1348,9 +1349,6 @@ async function pushServiceImages(
);
}
// TODO: This should be shared between the CLI & the Builder
const PLAIN_SEMVER_REGEX = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/;
export async function deployProject(
docker: Dockerode,
logger: Logger,
@ -1373,10 +1371,10 @@ export async function deployProject(
const contractPath = path.join(projectPath, 'balena.yml');
const contract = await getContractContent(contractPath);
if (contract?.version && !PLAIN_SEMVER_REGEX.test(contract?.version)) {
if (contract?.version && !semver.valid(contract.version)) {
throw new ExpectedError(stripIndent`\
Error: expected the version field in "${contractPath}"
to be a basic semver in the format '1.2.3'. Got '${contract.version}' instead`);
Error: the version field in "${contractPath}"
is not a valid semver`);
}
const $release = await runSpinner(
@ -1641,6 +1639,7 @@ function truncateString(str: string, len: number): string {
return str.slice(0, str.lastIndexOf('\n'));
}
// TODO: docker-compose naming
export const composeCliFlags: flags.Input<ComposeCliFlags> = {
emulated: flags.boolean({
description:
@ -1651,6 +1650,10 @@ export const composeCliFlags: flags.Input<ComposeCliFlags> = {
description:
'Alternative Dockerfile name/path, relative to the source folder',
}),
dockercompose: flags.string({
description:
'Alternative docker-compose.yml name in the source root folder',
}),
logs: flags.boolean({
description:
'No-op and deprecated since balena CLI v12.0.0. Build logs are now shown by default.',

View File

@ -13,53 +13,88 @@ 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 type { ContainerInfo } from 'dockerode';
import { ExpectedError } from '../../errors';
import { stripIndent } from '../lazy';
export interface DeviceSSHOpts {
address: string;
port?: number;
import {
findBestUsernameForDevice,
getRemoteCommandOutput,
runRemoteCommand,
SshRemoteCommandOpts,
} from '../ssh';
export interface DeviceSSHOpts extends SshRemoteCommandOpts {
forceTTY?: boolean;
verbose: boolean;
service?: string;
}
export const deviceContainerEngineBinary = `$(if [ -f /usr/bin/balena ]; then echo "balena"; else echo "docker"; fi)`;
const deviceContainerEngineBinary = `$(if [ -f /usr/bin/balena ]; then echo "balena"; else echo "docker"; fi)`;
/**
* List the running containers on the device with dockerode, and return the
* container ID that matches the given service name.
* List the running containers on the device over ssh, and return the full
* container name that matches the given service name.
*
* Note: In the past, two other approaches were implemented for this function:
*
* - Obtaining container IDs through a supervisor API call:
* '/supervisor/v2/containerId' endpoint, via cloud.
* - Obtaining container IDs using 'dockerode' connected directly to
* balenaEngine on a device, TCP port 2375.
*
* The problem with using the supervisor API is that it means that 'balena ssh'
* becomes dependent on the supervisor being up an running, but sometimes ssh
* is needed to investigate devices issues where the supervisor has got into
* trouble (e.g. supervisor in restart loop). This is the subject of CLI issue
* https://github.com/balena-io/balena-cli/issues/1560 .
*
* The problem with using dockerode to connect directly to port 2375 (balenaEngine)
* is that it only works with development variants of balenaOS. Production variants
* block access to port 2375 for security reasons. 'balena ssh' should support
* production variants as well, especially after balenaOS v2.44.0 that introduced
* support for using the cloud account username for ssh authentication.
*
* Overall, the most reliable approach is to run 'balena-engine ps' over ssh.
* It is OK to depend on balenaEngine because ssh to a container is implemented
* through 'balena-engine exec' anyway, and of course it is OK to depend on ssh
* itself.
*/
async function getContainerIdForService(
service: string,
deviceAddress: string,
export async function getContainerIdForService(
opts: SshRemoteCommandOpts & { service: string; deviceUuid?: string },
): Promise<string> {
const { escapeRegExp, reduce } = await import('lodash');
const Docker = await import('dockerode');
const docker = new Docker({
host: deviceAddress,
port: 2375,
});
const regex = new RegExp(`(^|\\/)${escapeRegExp(service)}_\\d+_\\d+`);
const nameRegex = /\/?([a-zA-Z0-9_-]+)_\d+_\d+/;
let allContainers: ContainerInfo[];
try {
allContainers = await docker.listContainers();
} catch (_e) {
throw new ExpectedError(stripIndent`
Could not access docker daemon on device ${deviceAddress}.
Please ensure the device is in local mode.`);
opts.cmd = `"${deviceContainerEngineBinary}" ps --format "{{.ID}} {{.Names}}"`;
if (opts.deviceUuid) {
// If a device UUID is given, perform ssh via cloud proxy 'host' command
opts.cmd = `host ${opts.deviceUuid} ${opts.cmd}`;
}
const psLines: string[] = (
await getRemoteCommandOutput({ ...opts, stderr: 'inherit' })
).stdout
.toString()
.split('\n')
.filter((l) => l);
const { escapeRegExp } = await import('lodash');
const regex = new RegExp(`(?:^|\\/)${escapeRegExp(opts.service)}_\\d+_\\d+`);
// Old balenaOS container name pattern:
// main_1234567_2345678
// New balenaOS container name patterns:
// main_1234567_2345678_a000b111c222d333e444f555a666b777
// main_1_1_localrelease
const nameRegex = /(?:^|\/)([a-zA-Z0-9_-]+)_\d+_\d+(?:_.+)?$/;
const serviceNames: string[] = [];
const containers: Array<{ id: string; name: string }> = [];
for (const container of allContainers) {
for (const name of container.Names) {
const containerNames: string[] = [];
let containerId: string | undefined;
// sample psLine: 'b603c74e951e bar_4587562_2078151_3261c9d4c22f2c53a5267be459c89990'
for (const psLine of psLines) {
const [cId, name] = psLine.split(' ');
if (cId && name) {
if (regex.test(name)) {
containers.push({ id: container.Id, name });
break;
containerNames.push(name);
containerId = cId;
}
const match = name.match(nameRegex);
if (match) {
@ -67,23 +102,21 @@ async function getContainerIdForService(
}
}
}
if (containers.length > 1) {
if (containerNames.length > 1) {
const [s, d] = [opts.service, opts.deviceUuid || opts.hostname];
throw new ExpectedError(stripIndent`
Found more than one container matching service name "${service}":
${containers.map((container) => container.name).join(', ')}
Found more than one container matching service name "${s}" on device "${d}":
${containerNames.join(', ')}
Use different service names to avoid ambiguity.
`);
}
const containerId = containers.length ? containers[0].id : '';
if (!containerId) {
const [s, d] = [opts.service, opts.deviceUuid || opts.hostname];
throw new ExpectedError(
`Could not find a service on device with name ${service}. ${
`Could not find a container matching service name "${s}" on device "${d}".${
serviceNames.length > 0
? `Available services:\n${reduce(
serviceNames,
(str, name) => `${str}\t${name}\n`,
'',
)}`
? `\nAvailable services:\n\t${serviceNames.join('\n\t')}`
: ''
}`,
);
@ -94,13 +127,25 @@ async function getContainerIdForService(
export async function performLocalDeviceSSH(
opts: DeviceSSHOpts,
): Promise<void> {
// Before we started using `findBestUsernameForDevice`, we tried the approach
// of attempting ssh with the 'root' username first and, if that failed, then
// attempting ssh with a regular user (balenaCloud username). The problem with
// that approach was that it would print the following message to the console:
// "root@192.168.1.36: Permission denied (publickey)"
// ... right before having success as a regular user, which looked broken or
// confusing from users' point of view. Capturing stderr to prevent that
// message from being printed is tricky because the messages printed to stderr
// may include the stderr output of remote commands that are of interest to
// the user.
const username = await findBestUsernameForDevice(opts.hostname, opts.port);
let cmd = '';
if (opts.service) {
const containerId = await getContainerIdForService(
opts.service,
opts.address,
);
const containerId = await getContainerIdForService({
...opts,
service: opts.service,
username,
});
const shellCmd = `/bin/sh -c "if [ -e /bin/bash ]; then exec /bin/bash; else exec /bin/sh; fi"`;
// stdin (fd=0) is not a tty when data is piped in, for example
@ -112,29 +157,5 @@ export async function performLocalDeviceSSH(
cmd = `${deviceContainerEngineBinary} exec -i ${ttyFlag} ${containerId} ${shellCmd}`;
}
const { findBestUsernameForDevice, runRemoteCommand } = await import(
'../ssh'
);
// Before we started using `findBestUsernameForDevice`, we tried the approach
// of attempting ssh with the 'root' username first and, if that failed, then
// attempting ssh with a regular user (balenaCloud username). The problem with
// that approach was that it would print the following message to the console:
// "root@192.168.1.36: Permission denied (publickey)"
// ... right before having success as a regular user, which looked broken or
// confusing from users' point of view. Capturing stderr to prevent that
// message from being printed is tricky because the messages printed to stderr
// may include the stderr output of remote commands that are of interest to
// the user. Workarounds based on delays (timing) are tricky too because a
// ssh session length may vary from a fraction of a second (non interactive)
// to hours or days.
const username = await findBestUsernameForDevice(opts.address);
await runRemoteCommand({
cmd,
hostname: opts.address,
port: Number(opts.port) || 'local',
username,
verbose: opts.verbose,
});
await runRemoteCommand({ ...opts, cmd, username });
}

View File

@ -174,14 +174,8 @@ export async function isBalenaEngine(docker: dockerode): Promise<boolean> {
);
}
export interface ExtendedDockerOptions extends dockerode.DockerOptions {
docker?: string; // socket path, e.g. /var/run/docker.sock
dockerHost?: string; // host name or IP address
dockerPort?: number; // TCP port number, e.g. 2375
}
export async function getDocker(
options: ExtendedDockerOptions,
options: DockerConnectionCliFlags,
): Promise<dockerode> {
const connectOpts = await generateConnectOpts(options);
const client = await createClient(connectOpts);
@ -196,14 +190,18 @@ export async function createClient(
return new Docker(opts);
}
async function generateConnectOpts(opts: ExtendedDockerOptions) {
let connectOpts: dockerode.DockerOptions = {};
// Start with docker-modem defaults which take several env vars into account,
// including DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH, SSH_AUTH_SOCK
// https://github.com/apocas/docker-modem/blob/v3.0.0/lib/modem.js#L15-L70
const Modem = require('docker-modem');
const defaultOpts = new Modem();
/**
* Initialize Docker connection options with the default values from the
* 'docker-modem' package, which takes several env vars into account,
* including DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH, SSH_AUTH_SOCK
* https://github.com/apocas/docker-modem/blob/v3.0.0/lib/modem.js#L15-L70
*
* @param opts Command line options like --dockerHost and --dockerPort
*/
export function getDefaultDockerModemOpts(
opts: DockerConnectionCliFlags,
): dockerode.DockerOptions {
const connectOpts: dockerode.DockerOptions = {};
const optsOfInterest: Array<keyof dockerode.DockerOptions> = [
'ca',
'cert',
@ -215,9 +213,33 @@ async function generateConnectOpts(opts: ExtendedDockerOptions) {
'username',
'timeout',
];
for (const opt of optsOfInterest) {
connectOpts[opt] = defaultOpts[opt];
const Modem = require('docker-modem');
const originalDockerHost = process.env.DOCKER_HOST;
try {
if (opts.dockerHost) {
process.env.DOCKER_HOST ||= opts.dockerPort
? `${opts.dockerHost}:${opts.dockerPort}`
: opts.dockerHost;
}
const defaultOpts = new Modem();
for (const opt of optsOfInterest) {
connectOpts[opt] = defaultOpts[opt];
}
} finally {
// Did you know? Any value assigned to `process.env.XXX` becomes a string.
// For example, `process.env.DOCKER_HOST = undefined` results in
// value 'undefined' (a 9-character string) being assigned.
if (originalDockerHost) {
process.env.DOCKER_HOST = originalDockerHost;
} else {
delete process.env.DOCKER_HOST;
}
}
return connectOpts;
}
export async function generateConnectOpts(opts: DockerConnectionCliFlags) {
let connectOpts = getDefaultDockerModemOpts(opts);
// Now override the default options with any explicit command line options
if (opts.docker != null && opts.dockerHost == null) {
@ -241,9 +263,9 @@ async function generateConnectOpts(opts: ExtendedDockerOptions) {
// These should be file paths (strings)
const tlsOpts = [opts.ca, opts.cert, opts.key];
// If any are set...
// If any tlsOpts are set...
if (tlsOpts.some((opt) => opt)) {
// but not all ()
// but not all
if (!tlsOpts.every((opt) => opt)) {
throw new ExpectedError(
'You must provide a CA, certificate and key in order to use TLS',
@ -258,7 +280,11 @@ async function generateConnectOpts(opts: ExtendedDockerOptions) {
const [ca, cert, key] = await Promise.all(
tlsOpts.map((opt: string) => fs.readFile(opt, 'utf8')),
);
connectOpts = { ...connectOpts, ca, cert, key };
// Also ensure that the protocol is 'https' like 'docker-modem' does:
// https://github.com/apocas/docker-modem/blob/v3.0.0/lib/modem.js#L101-L103
// TODO: delete redundant logic from this function now that similar logic
// exists in the 'docker-modem' package.
connectOpts = { ...connectOpts, ca, cert, key, protocol: 'https' };
}
return connectOpts;

View File

@ -92,6 +92,7 @@ async function readDockerIgnoreFile(projectDir: string): Promise<string> {
return dockerIgnoreStr;
}
// TODO: docker-compose naming
/**
* Create an instance of '@balena/dockerignore', initialized with the contents
* of a .dockerignore file (if any) found at the given directory argument, plus

View File

@ -86,6 +86,7 @@ If the --registry-secrets option is not specified, and a secrets.yml or
secrets.json file exists in the balena directory (usually $HOME/.balena),
this file will be used instead.`;
// TODO: docker-compose naming
export const dockerignoreHelp =
'DOCKERIGNORE AND GITIGNORE FILES \n' +
`By default, the balena CLI will use a single ".dockerignore" file (if any) at

View File

@ -80,7 +80,12 @@ export async function leave(
logger.logDebug('Deconfiguring...');
await deconfigure(deviceHostnameOrIp);
logger.logSuccess('Device successfully left the platform.');
logger.logSuccess(stripIndent`
Device successfully left the platform. The device will still be listed as part
of the fleet, but changes to the fleet will no longer affect the device and its
status will eventually be reported as 'Offline'. To irrecoverably delete the
device from the fleet, use the 'balena device rm' command or delete it through
the balenaCloud web dashboard.`);
}
async function execCommand(

View File

@ -247,14 +247,15 @@ export async function getLocalDeviceCmdStdout(
cmd: string,
stdout: 'capture' | 'ignore' | 'inherit' | NodeJS.WritableStream = 'capture',
): Promise<Buffer> {
const port = 'local';
return (
await getRemoteCommandOutput({
cmd,
hostname,
port: 'local',
port,
stdout,
stderr: 'inherit',
username: await findBestUsernameForDevice(hostname),
username: await findBestUsernameForDevice(hostname, port),
})
).stdout;
}
@ -267,16 +268,14 @@ export async function getLocalDeviceCmdStdout(
* added to the device's 'config.json' file.
* @return True if succesful, false on any errors.
*/
export const isRootUserGood = _.memoize(
async (hostname: string, port = 'local') => {
try {
await runRemoteCommand({ cmd: 'exit 0', hostname, port, ...stdioIgnore });
} catch (e) {
return false;
}
return true;
},
);
export const isRootUserGood = _.memoize(async (hostname: string, port) => {
try {
await runRemoteCommand({ cmd: 'exit 0', hostname, port, ...stdioIgnore });
} catch (e) {
return false;
}
return true;
});
/**
* Determine whether the given local device (hostname or IP address) should be
@ -291,7 +290,7 @@ export const isRootUserGood = _.memoize(
* universally possible.
*/
export const findBestUsernameForDevice = _.memoize(
async (hostname: string, port = 'local'): Promise<string> => {
async (hostname: string, port): Promise<string> => {
let username: string | undefined;
if (await isRootUserGood(hostname, port)) {
username = 'root';
@ -299,7 +298,13 @@ export const findBestUsernameForDevice = _.memoize(
const { getCachedUsername } = await import('./bootstrap');
username = (await getCachedUsername())?.username;
}
return username || 'root';
if (!username) {
const { stripIndent } = await import('./lazy');
throw new ExpectedError(stripIndent`
SSH authentication failed for 'root@${hostname}'.
Please login with 'balena login' for alternative authentication.`);
}
return username;
},
);

View File

@ -1,5 +1,5 @@
/*
Copyright 2016-2019 Balena
Copyright 2016-2022 Balena
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -40,14 +40,35 @@ export function notify() {
}
}
const up = notifier.update;
if (
up &&
(require('semver') as typeof import('semver')).lt(up.current, up.latest)
) {
notifier.notify({
defer: false,
message: `Update available ${up.current}${up.latest}\n
https://github.com/balena-io/balena-cli/blob/master/INSTALL.md`,
});
const message = up && getNotifierMessage(up);
if (message) {
notifier.notify({ defer: false, message });
}
}
export function getNotifierMessage(updateInfo: UpdateNotifier.UpdateInfo) {
const semver = require('semver') as typeof import('semver');
const message: string[] = [];
const [current, latest] = [updateInfo.current, updateInfo.latest];
if (semver.lt(current, latest)) {
message.push(
`Update available ${current}${latest}`,
'https://github.com/balena-io/balena-cli/blob/master/INSTALL.md',
);
const currentMajor = semver.major(current);
const latestMajor = semver.major(latest);
if (currentMajor !== latestMajor) {
message.push(
'',
`Check the v${latestMajor} release notes at:`,
getReleaseNotesUrl(latestMajor),
);
}
}
return message.join('\n');
}
function getReleaseNotesUrl(majorVersion: number) {
return `https://github.com/balena-io/balena-cli/wiki/CLI-v${majorVersion}-Release-Notes`;
}

23
npm-shrinkwrap.json generated
View File

@ -1,6 +1,6 @@
{
"name": "balena-cli",
"version": "13.2.1",
"version": "13.4.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -2557,9 +2557,9 @@
}
},
"@types/dockerode": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.0.tgz",
"integrity": "sha512-3Mc0b2gnypJB8Gwmr+8UVPkwjpf4kg1gVxw8lAI4Y/EzpK50LixU1wBSPN9D+xqiw2Ubb02JO8oM0xpwzvi2mg==",
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.8.tgz",
"integrity": "sha512-/Hip29GzPBWfbSS87lyQDVoB7Ja+kr8oOFWXsySxNFa7jlyj3Yws8LaZRmn1xZl7uJH3Xxsg0oI09GHpT1pIBw==",
"dev": true,
"requires": {
"@types/docker-modem": "*",
@ -2946,12 +2946,9 @@
}
},
"@types/semver": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.1.tgz",
"integrity": "sha512-ooD/FJ8EuwlDKOI6D9HWxgIgJjMg2cuziXm/42npDC8y4NjxplBUn9loewZiBNCt44450lHAU0OSb51/UqXeag==",
"requires": {
"@types/node": "*"
}
"version": "7.3.9",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.9.tgz",
"integrity": "sha512-L/TMpyURfBkf+o/526Zb6kd/tchUP3iBDEPjqjb+U2MAJhVRxxrmr2fwpe08E7QsV7YLcpq0tUaQ9O9x97ZIxQ=="
},
"@types/serve-static": {
"version": "1.13.10",
@ -2989,9 +2986,9 @@
}
},
"@types/ssh2": {
"version": "0.5.49",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.49.tgz",
"integrity": "sha512-ffxhQhJqgTzrw8NxHTgkaDtAmAj2qxCyoves7ztpRgqvzbHcZTpTcm+ATWuuCbPQzxnnF4F3SGGTLGEWTZpwqA==",
"version": "0.5.52",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz",
"integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==",
"dev": true,
"requires": {
"@types/node": "*",

View File

@ -1,6 +1,6 @@
{
"name": "balena-cli",
"version": "13.2.1",
"version": "13.4.1",
"description": "The official balena Command Line Interface",
"main": "./build/app.js",
"homepage": "https://github.com/balena-io/balena-cli",
@ -127,7 +127,7 @@
"@types/chai-as-promised": "^7.1.4",
"@types/cli-truncate": "^2.0.0",
"@types/common-tags": "^1.8.1",
"@types/dockerode": "^3.3.0",
"@types/dockerode": "^3.3.8",
"@types/ejs": "^3.1.0",
"@types/express": "^4.17.13",
"@types/fs-extra": "^9.0.13",
@ -155,6 +155,7 @@
"@types/request": "^2.48.7",
"@types/rewire": "^2.5.28",
"@types/rimraf": "^3.0.2",
"@types/semver": "^7.3.9",
"@types/shell-escape": "^0.2.0",
"@types/sinon": "^10.0.6",
"@types/split": "^1.0.0",
@ -287,6 +288,6 @@
"windosu": "^0.3.0"
},
"versionist": {
"publishedAt": "2022-02-24T21:05:37.132Z"
"publishedAt": "2022-04-11T16:10:47.566Z"
}
}

View File

@ -33,15 +33,17 @@ describe('balena ssh', function () {
let mockedExitCode = 0;
async function mockSpawn({ revert = false } = {}) {
const childProcessPath = 'child_process';
if (revert) {
mock.stopAll();
mock.stop(childProcessPath);
mock.reRequire('../../build/utils/ssh');
mock.reRequire('../../build/utils/device/ssh');
return;
}
const { EventEmitter } = await import('stream');
const childProcessMod = await import('child_process');
const childProcessMod = await import(childProcessPath);
const originalSpawn = childProcessMod.spawn;
mock('child_process', {
mock(childProcessPath, {
...childProcessMod,
spawn: (program: string, ...args: any[]) => {
if (program.includes('ssh')) {
@ -117,7 +119,7 @@ describe('balena ssh', function () {
},
);
it('should fail if device not online (mocked, device UUID)', async () => {
itSS('should fail if device not online (mocked, device UUID)', async () => {
const deviceUUID = 'abc1234';
const expectedErrLines = ['Device with UUID abc1234 is offline'];
api.expectGetWhoAmI({ optional: true, persist: true });
@ -132,6 +134,7 @@ describe('balena ssh', function () {
it('should produce the expected error message (real ssh, device IP address)', async function () {
await mockSpawn({ revert: true });
api.expectGetWhoAmI({ optional: true, persist: true });
const expectedErrLines = [
'SSH: Process exited with non-zero status code "255"',
];
@ -174,7 +177,7 @@ async function startMockSshServer(): Promise<[Server, number]> {
console.error(`mock ssh server error:\n${err}`);
});
return new Promise<[Server, number]>((resolve, reject) => {
return await new Promise<[Server, number]>((resolve, reject) => {
// TODO: remove 'as any' below. According to @types/node v12.20.42, the
// callback type is `() => void`, but our code assumes `(err: Error) => void`
const listener = (server.listen as any)(0, '127.0.0.1', (err: Error) => {

137
tests/utils/docker.spec.ts Normal file
View File

@ -0,0 +1,137 @@
/**
* @license
* Copyright 2022 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 { expect } from 'chai';
import {
DockerConnectionCliFlags,
generateConnectOpts,
getDefaultDockerModemOpts,
} from '../../build/utils/docker';
const defaultSocketPath =
process.platform === 'win32'
? '//./pipe/docker_engine'
: '/var/run/docker.sock';
describe('getDefaultDockerModemOpts() function', function () {
it('should use a Unix socket when --dockerHost is not used', () => {
const cliFlags: DockerConnectionCliFlags = {
dockerPort: 2376,
};
const defaultOps = getDefaultDockerModemOpts(cliFlags);
expect(defaultOps).to.deep.include({
host: undefined,
port: undefined,
protocol: 'http',
socketPath: defaultSocketPath,
});
});
it('should use the HTTP protocol when --dockerPort is 2375', () => {
const cliFlags: DockerConnectionCliFlags = {
dockerHost: 'foo',
dockerPort: 2375,
};
const defaultOps = getDefaultDockerModemOpts(cliFlags);
expect(defaultOps).to.deep.include({
host: 'foo',
port: '2375',
protocol: 'http',
socketPath: undefined,
});
});
it('should use the HTTPS protocol when --dockerPort is 2376', () => {
const cliFlags: DockerConnectionCliFlags = {
dockerHost: 'foo',
dockerPort: 2376,
};
const defaultOps = getDefaultDockerModemOpts(cliFlags);
expect(defaultOps).to.deep.include({
host: 'foo',
port: '2376',
protocol: 'https',
socketPath: undefined,
});
});
});
describe('generateConnectOpts() function', function () {
it('should use a Unix socket when --docker is used', async () => {
const cliFlags: DockerConnectionCliFlags = {
docker: 'foo',
};
const connectOpts = await generateConnectOpts(cliFlags);
expect(connectOpts).to.deep.include({
protocol: 'http',
socketPath: 'foo',
});
expect(connectOpts).to.not.have.any.keys('host', 'port');
});
it('should use the HTTP protocol when --dockerPort is 2375', async () => {
const cliFlags: DockerConnectionCliFlags = {
dockerHost: 'foo',
dockerPort: 2375,
};
const connectOpts = await generateConnectOpts(cliFlags);
expect(connectOpts).to.deep.include({
host: 'foo',
port: 2375,
protocol: 'http',
});
expect(connectOpts).to.not.have.any.keys('socketPath');
});
it('should use the HTTPS protocol when --dockerPort is 2376', async () => {
const cliFlags: DockerConnectionCliFlags = {
dockerHost: 'foo',
dockerPort: 2376,
};
const connectOpts = await generateConnectOpts(cliFlags);
expect(connectOpts).to.deep.include({
host: 'foo',
port: 2376,
protocol: 'https',
});
expect(connectOpts).to.not.have.any.keys('socketPath');
});
it('should use the HTTPS protocol when ca/cert/key are used', async () => {
const path = await import('path');
const aFile = path.join(
__dirname,
'../test-data/projects/no-docker-compose/dockerignore1/a.txt',
);
const cliFlags: DockerConnectionCliFlags = {
ca: aFile,
cert: aFile,
key: aFile,
};
const connectOpts = await generateConnectOpts(cliFlags);
expect(connectOpts).to.deep.include({
ca: 'a',
cert: 'a',
key: 'a',
host: undefined,
port: undefined,
protocol: 'https',
socketPath: defaultSocketPath,
});
});
});

View File

@ -0,0 +1,79 @@
/**
* @license
* Copyright 2022 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 { expect } from 'chai';
import * as stripIndent from 'common-tags/lib/stripIndent';
import { getNotifierMessage } from '../../build/utils/update';
import type { UpdateInfo } from 'update-notifier';
describe('getNotifierMessage() unit test', function () {
const template: UpdateInfo = {
current: '',
latest: '',
type: 'latest',
name: '',
};
it('should return a simple update message including installation instructions', () => {
const mockUpdateInfo = {
...template,
current: '12.1.1',
latest: '12.3.0',
};
const msg = getNotifierMessage(mockUpdateInfo);
expect(msg).to.equal(stripIndent`
Update available 12.1.1 → 12.3.0
https://github.com/balena-io/balena-cli/blob/master/INSTALL.md`);
});
it('should include a release notes link when a new major version is available', () => {
const mockUpdateInfo = {
...template,
current: '12.1.1',
latest: '13.3.0',
};
const msg = getNotifierMessage(mockUpdateInfo);
expect(msg).to.equal(stripIndent`
Update available 12.1.1 → 13.3.0
https://github.com/balena-io/balena-cli/blob/master/INSTALL.md
Check the v13 release notes at:
https://github.com/balena-io/balena-cli/wiki/CLI-v13-Release-Notes`);
});
it('should return an empty string if no updates are available', () => {
const mockUpdateInfo = {
...template,
current: '12.1.1',
latest: '12.1.1',
};
const msg = getNotifierMessage(mockUpdateInfo);
expect(msg).to.equal('');
});
it('should return an empty string if no updates are available', () => {
const mockUpdateInfo = {
...template,
current: '14.1.1',
latest: '12.1.1',
};
const msg = getNotifierMessage(mockUpdateInfo);
expect(msg).to.equal('');
});
});