balena-cli 13.2.1 → 13.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,53 +13,88 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */
16
- import type { ContainerInfo } from 'dockerode';
17
16
 
18
17
  import { ExpectedError } from '../../errors';
19
18
  import { stripIndent } from '../lazy';
20
19
 
21
- export interface DeviceSSHOpts {
22
- address: string;
23
- port?: number;
20
+ import {
21
+ findBestUsernameForDevice,
22
+ getRemoteCommandOutput,
23
+ runRemoteCommand,
24
+ SshRemoteCommandOpts,
25
+ } from '../ssh';
26
+
27
+ export interface DeviceSSHOpts extends SshRemoteCommandOpts {
24
28
  forceTTY?: boolean;
25
- verbose: boolean;
26
29
  service?: string;
27
30
  }
28
31
 
29
- export const deviceContainerEngineBinary = `$(if [ -f /usr/bin/balena ]; then echo "balena"; else echo "docker"; fi)`;
32
+ const deviceContainerEngineBinary = `$(if [ -f /usr/bin/balena ]; then echo "balena"; else echo "docker"; fi)`;
30
33
 
31
34
  /**
32
- * List the running containers on the device with dockerode, and return the
33
- * container ID that matches the given service name.
35
+ * List the running containers on the device over ssh, and return the full
36
+ * container name that matches the given service name.
37
+ *
38
+ * Note: In the past, two other approaches were implemented for this function:
39
+ *
40
+ * - Obtaining container IDs through a supervisor API call:
41
+ * '/supervisor/v2/containerId' endpoint, via cloud.
42
+ * - Obtaining container IDs using 'dockerode' connected directly to
43
+ * balenaEngine on a device, TCP port 2375.
44
+ *
45
+ * The problem with using the supervisor API is that it means that 'balena ssh'
46
+ * becomes dependent on the supervisor being up an running, but sometimes ssh
47
+ * is needed to investigate devices issues where the supervisor has got into
48
+ * trouble (e.g. supervisor in restart loop). This is the subject of CLI issue
49
+ * https://github.com/balena-io/balena-cli/issues/1560 .
50
+ *
51
+ * The problem with using dockerode to connect directly to port 2375 (balenaEngine)
52
+ * is that it only works with development variants of balenaOS. Production variants
53
+ * block access to port 2375 for security reasons. 'balena ssh' should support
54
+ * production variants as well, especially after balenaOS v2.44.0 that introduced
55
+ * support for using the cloud account username for ssh authentication.
56
+ *
57
+ * Overall, the most reliable approach is to run 'balena-engine ps' over ssh.
58
+ * It is OK to depend on balenaEngine because ssh to a container is implemented
59
+ * through 'balena-engine exec' anyway, and of course it is OK to depend on ssh
60
+ * itself.
34
61
  */
35
- async function getContainerIdForService(
36
- service: string,
37
- deviceAddress: string,
62
+ export async function getContainerIdForService(
63
+ opts: SshRemoteCommandOpts & { service: string; deviceUuid?: string },
38
64
  ): Promise<string> {
39
- const { escapeRegExp, reduce } = await import('lodash');
40
- const Docker = await import('dockerode');
41
- const docker = new Docker({
42
- host: deviceAddress,
43
- port: 2375,
44
- });
45
- const regex = new RegExp(`(^|\\/)${escapeRegExp(service)}_\\d+_\\d+`);
46
- const nameRegex = /\/?([a-zA-Z0-9_-]+)_\d+_\d+/;
47
- let allContainers: ContainerInfo[];
48
- try {
49
- allContainers = await docker.listContainers();
50
- } catch (_e) {
51
- throw new ExpectedError(stripIndent`
52
- Could not access docker daemon on device ${deviceAddress}.
53
- Please ensure the device is in local mode.`);
65
+ opts.cmd = `"${deviceContainerEngineBinary}" ps --format "{{.ID}} {{.Names}}"`;
66
+ if (opts.deviceUuid) {
67
+ // If a device UUID is given, perform ssh via cloud proxy 'host' command
68
+ opts.cmd = `host ${opts.deviceUuid} ${opts.cmd}`;
54
69
  }
55
70
 
71
+ const psLines: string[] = (
72
+ await getRemoteCommandOutput({ ...opts, stderr: 'inherit' })
73
+ ).stdout
74
+ .toString()
75
+ .split('\n')
76
+ .filter((l) => l);
77
+
78
+ const { escapeRegExp } = await import('lodash');
79
+ const regex = new RegExp(`(?:^|\\/)${escapeRegExp(opts.service)}_\\d+_\\d+`);
80
+ // Old balenaOS container name pattern:
81
+ // main_1234567_2345678
82
+ // New balenaOS container name patterns:
83
+ // main_1234567_2345678_a000b111c222d333e444f555a666b777
84
+ // main_1_1_localrelease
85
+ const nameRegex = /(?:^|\/)([a-zA-Z0-9_-]+)_\d+_\d+(?:_.+)?$/;
86
+
56
87
  const serviceNames: string[] = [];
57
- const containers: Array<{ id: string; name: string }> = [];
58
- for (const container of allContainers) {
59
- for (const name of container.Names) {
88
+ const containerNames: string[] = [];
89
+ let containerId: string | undefined;
90
+
91
+ // sample psLine: 'b603c74e951e bar_4587562_2078151_3261c9d4c22f2c53a5267be459c89990'
92
+ for (const psLine of psLines) {
93
+ const [cId, name] = psLine.split(' ');
94
+ if (cId && name) {
60
95
  if (regex.test(name)) {
61
- containers.push({ id: container.Id, name });
62
- break;
96
+ containerNames.push(name);
97
+ containerId = cId;
63
98
  }
64
99
  const match = name.match(nameRegex);
65
100
  if (match) {
@@ -67,23 +102,21 @@ async function getContainerIdForService(
67
102
  }
68
103
  }
69
104
  }
70
- if (containers.length > 1) {
105
+
106
+ if (containerNames.length > 1) {
107
+ const [s, d] = [opts.service, opts.deviceUuid || opts.hostname];
71
108
  throw new ExpectedError(stripIndent`
72
- Found more than one container matching service name "${service}":
73
- ${containers.map((container) => container.name).join(', ')}
109
+ Found more than one container matching service name "${s}" on device "${d}":
110
+ ${containerNames.join(', ')}
74
111
  Use different service names to avoid ambiguity.
75
112
  `);
76
113
  }
77
- const containerId = containers.length ? containers[0].id : '';
78
114
  if (!containerId) {
115
+ const [s, d] = [opts.service, opts.deviceUuid || opts.hostname];
79
116
  throw new ExpectedError(
80
- `Could not find a service on device with name ${service}. ${
117
+ `Could not find a container matching service name "${s}" on device "${d}".${
81
118
  serviceNames.length > 0
82
- ? `Available services:\n${reduce(
83
- serviceNames,
84
- (str, name) => `${str}\t${name}\n`,
85
- '',
86
- )}`
119
+ ? `\nAvailable services:\n\t${serviceNames.join('\n\t')}`
87
120
  : ''
88
121
  }`,
89
122
  );
@@ -94,13 +127,25 @@ async function getContainerIdForService(
94
127
  export async function performLocalDeviceSSH(
95
128
  opts: DeviceSSHOpts,
96
129
  ): Promise<void> {
130
+ // Before we started using `findBestUsernameForDevice`, we tried the approach
131
+ // of attempting ssh with the 'root' username first and, if that failed, then
132
+ // attempting ssh with a regular user (balenaCloud username). The problem with
133
+ // that approach was that it would print the following message to the console:
134
+ // "root@192.168.1.36: Permission denied (publickey)"
135
+ // ... right before having success as a regular user, which looked broken or
136
+ // confusing from users' point of view. Capturing stderr to prevent that
137
+ // message from being printed is tricky because the messages printed to stderr
138
+ // may include the stderr output of remote commands that are of interest to
139
+ // the user.
140
+ const username = await findBestUsernameForDevice(opts.hostname, opts.port);
97
141
  let cmd = '';
98
142
 
99
143
  if (opts.service) {
100
- const containerId = await getContainerIdForService(
101
- opts.service,
102
- opts.address,
103
- );
144
+ const containerId = await getContainerIdForService({
145
+ ...opts,
146
+ service: opts.service,
147
+ username,
148
+ });
104
149
 
105
150
  const shellCmd = `/bin/sh -c "if [ -e /bin/bash ]; then exec /bin/bash; else exec /bin/sh; fi"`;
106
151
  // stdin (fd=0) is not a tty when data is piped in, for example
@@ -112,29 +157,5 @@ export async function performLocalDeviceSSH(
112
157
  cmd = `${deviceContainerEngineBinary} exec -i ${ttyFlag} ${containerId} ${shellCmd}`;
113
158
  }
114
159
 
115
- const { findBestUsernameForDevice, runRemoteCommand } = await import(
116
- '../ssh'
117
- );
118
-
119
- // Before we started using `findBestUsernameForDevice`, we tried the approach
120
- // of attempting ssh with the 'root' username first and, if that failed, then
121
- // attempting ssh with a regular user (balenaCloud username). The problem with
122
- // that approach was that it would print the following message to the console:
123
- // "root@192.168.1.36: Permission denied (publickey)"
124
- // ... right before having success as a regular user, which looked broken or
125
- // confusing from users' point of view. Capturing stderr to prevent that
126
- // message from being printed is tricky because the messages printed to stderr
127
- // may include the stderr output of remote commands that are of interest to
128
- // the user. Workarounds based on delays (timing) are tricky too because a
129
- // ssh session length may vary from a fraction of a second (non interactive)
130
- // to hours or days.
131
- const username = await findBestUsernameForDevice(opts.address);
132
-
133
- await runRemoteCommand({
134
- cmd,
135
- hostname: opts.address,
136
- port: Number(opts.port) || 'local',
137
- username,
138
- verbose: opts.verbose,
139
- });
160
+ await runRemoteCommand({ ...opts, cmd, username });
140
161
  }
package/lib/utils/ssh.ts CHANGED
@@ -247,14 +247,15 @@ export async function getLocalDeviceCmdStdout(
247
247
  cmd: string,
248
248
  stdout: 'capture' | 'ignore' | 'inherit' | NodeJS.WritableStream = 'capture',
249
249
  ): Promise<Buffer> {
250
+ const port = 'local';
250
251
  return (
251
252
  await getRemoteCommandOutput({
252
253
  cmd,
253
254
  hostname,
254
- port: 'local',
255
+ port,
255
256
  stdout,
256
257
  stderr: 'inherit',
257
- username: await findBestUsernameForDevice(hostname),
258
+ username: await findBestUsernameForDevice(hostname, port),
258
259
  })
259
260
  ).stdout;
260
261
  }
@@ -267,16 +268,14 @@ export async function getLocalDeviceCmdStdout(
267
268
  * added to the device's 'config.json' file.
268
269
  * @return True if succesful, false on any errors.
269
270
  */
270
- export const isRootUserGood = _.memoize(
271
- async (hostname: string, port = 'local') => {
272
- try {
273
- await runRemoteCommand({ cmd: 'exit 0', hostname, port, ...stdioIgnore });
274
- } catch (e) {
275
- return false;
276
- }
277
- return true;
278
- },
279
- );
271
+ export const isRootUserGood = _.memoize(async (hostname: string, port) => {
272
+ try {
273
+ await runRemoteCommand({ cmd: 'exit 0', hostname, port, ...stdioIgnore });
274
+ } catch (e) {
275
+ return false;
276
+ }
277
+ return true;
278
+ });
280
279
 
281
280
  /**
282
281
  * Determine whether the given local device (hostname or IP address) should be
@@ -291,7 +290,7 @@ export const isRootUserGood = _.memoize(
291
290
  * universally possible.
292
291
  */
293
292
  export const findBestUsernameForDevice = _.memoize(
294
- async (hostname: string, port = 'local'): Promise<string> => {
293
+ async (hostname: string, port): Promise<string> => {
295
294
  let username: string | undefined;
296
295
  if (await isRootUserGood(hostname, port)) {
297
296
  username = 'root';
@@ -299,7 +298,13 @@ export const findBestUsernameForDevice = _.memoize(
299
298
  const { getCachedUsername } = await import('./bootstrap');
300
299
  username = (await getCachedUsername())?.username;
301
300
  }
302
- return username || 'root';
301
+ if (!username) {
302
+ const { stripIndent } = await import('./lazy');
303
+ throw new ExpectedError(stripIndent`
304
+ SSH authentication failed for 'root@${hostname}'.
305
+ Please login with 'balena login' for alternative authentication.`);
306
+ }
307
+ return username;
303
308
  },
304
309
  );
305
310
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balena-cli",
3
- "version": "13.2.1",
3
+ "version": "13.3.0",
4
4
  "lockfileVersion": 1,
5
5
  "requires": true,
6
6
  "dependencies": {