mirror of
https://github.com/balena-io/balena-cli.git
synced 2025-06-24 18:45:07 +00:00
Compare commits
21 Commits
v13.2.1
...
custom-doc
Author | SHA1 | Date | |
---|---|---|---|
7f8106a64b | |||
ba3a3865b5 | |||
f8402bc40c | |||
c667ffa8eb | |||
6d6065ddf5 | |||
44f55f8e7b | |||
d2c77760b3 | |||
7496710c85 | |||
be6a468507 | |||
88835e63bd | |||
3572cb3cd6 | |||
7fbd1de063 | |||
a4ab07cd08 | |||
9185eaa2b7 | |||
ff3abe1fba | |||
1ac3b70b81 | |||
e946178953 | |||
6589589bee | |||
6ae598b55e | |||
915f7e3763 | |||
cd17d79067 |
17
.resinci.yml
17
.resinci.yml
@ -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"
|
||||
|
@ -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
|
||||
|
26
CHANGELOG.md
26
CHANGELOG.md
@ -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]
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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:
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
1
lib/utils/compose-types.d.ts
vendored
1
lib/utils/compose-types.d.ts
vendored
@ -61,6 +61,7 @@ export interface ComposeOpts {
|
||||
export interface ComposeCliFlags {
|
||||
emulated: boolean;
|
||||
dockerfile?: string;
|
||||
dockercompose?: string;
|
||||
logs: boolean;
|
||||
nologs: boolean;
|
||||
'multi-dockerignore': boolean;
|
||||
|
@ -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.',
|
||||
|
@ -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 });
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -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
23
npm-shrinkwrap.json
generated
@ -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": "*",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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
137
tests/utils/docker.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
79
tests/utils/update.spec.ts
Normal file
79
tests/utils/update.spec.ts
Normal 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('');
|
||||
});
|
||||
});
|
Reference in New Issue
Block a user