2019-04-24 17:45:19 +00:00
|
|
|
/*
|
2020-03-27 01:26:37 +00:00
|
|
|
Copyright 2016-2020 Balena
|
2019-04-24 17:45:19 +00:00
|
|
|
|
|
|
|
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 { ContainerInfo } from 'dockerode';
|
|
|
|
|
|
|
|
export interface DeviceSSHOpts {
|
|
|
|
address: string;
|
|
|
|
port?: number;
|
2020-03-31 13:50:09 +00:00
|
|
|
forceTTY?: boolean;
|
2019-04-24 17:45:19 +00:00
|
|
|
verbose: boolean;
|
|
|
|
service?: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
export const deviceContainerEngineBinary = `$(if [ -f /usr/bin/balena ]; then echo "balena"; else echo "docker"; fi)`;
|
|
|
|
|
|
|
|
export async function performLocalDeviceSSH(
|
|
|
|
opts: DeviceSSHOpts,
|
|
|
|
): Promise<void> {
|
|
|
|
const reduce = await import('lodash/reduce');
|
2020-03-31 12:43:58 +00:00
|
|
|
const { spawnSshAndExitOnError } = await import('../ssh');
|
2020-03-27 01:26:37 +00:00
|
|
|
const { ExpectedError } = await import('../../errors');
|
2019-04-24 17:45:19 +00:00
|
|
|
const { stripIndent } = await import('common-tags');
|
|
|
|
|
|
|
|
let command = '';
|
|
|
|
|
|
|
|
if (opts.service != null) {
|
|
|
|
// Get the containers which are on-device. Currently we
|
|
|
|
// are single application, which means we can assume any
|
|
|
|
// container which fulfills the form of
|
|
|
|
// $serviceName_$appId_$releaseId is what we want. Once
|
|
|
|
// we have multi-app, we should show a dialog which
|
|
|
|
// allows the user to choose the correct container
|
|
|
|
|
|
|
|
const Docker = await import('dockerode');
|
|
|
|
const escapeRegex = await import('lodash/escapeRegExp');
|
|
|
|
const docker = new Docker({
|
|
|
|
host: opts.address,
|
|
|
|
port: 2375,
|
|
|
|
});
|
|
|
|
|
|
|
|
const regex = new RegExp(`\\/?${escapeRegex(opts.service)}_\\d+_\\d+`);
|
|
|
|
const nameRegex = /\/?([a-zA-Z0-9_]+)_\d+_\d+/;
|
|
|
|
let allContainers: ContainerInfo[];
|
|
|
|
try {
|
|
|
|
allContainers = await docker.listContainers();
|
|
|
|
} catch (_e) {
|
2020-03-27 01:26:37 +00:00
|
|
|
throw new ExpectedError(stripIndent`
|
2019-04-24 17:45:19 +00:00
|
|
|
Could not access docker daemon on device ${opts.address}.
|
|
|
|
Please ensure the device is in local mode.`);
|
|
|
|
}
|
|
|
|
|
|
|
|
const serviceNames: string[] = [];
|
|
|
|
const containers = allContainers
|
|
|
|
.map(container => {
|
|
|
|
for (const name of container.Names) {
|
|
|
|
if (regex.test(name)) {
|
|
|
|
return { id: container.Id, name };
|
|
|
|
}
|
|
|
|
const match = name.match(nameRegex);
|
|
|
|
if (match) {
|
|
|
|
serviceNames.push(match[1]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
})
|
|
|
|
.filter(c => c != null);
|
|
|
|
|
|
|
|
if (containers.length === 0) {
|
2020-03-27 01:26:37 +00:00
|
|
|
throw new ExpectedError(
|
2019-04-24 17:45:19 +00:00
|
|
|
`Could not find a service on device with name ${opts.service}. ${
|
|
|
|
serviceNames.length > 0
|
|
|
|
? `Available services:\n${reduce(
|
|
|
|
serviceNames,
|
|
|
|
(str, name) => `${str}\t${name}\n`,
|
|
|
|
'',
|
|
|
|
)}`
|
|
|
|
: ''
|
|
|
|
}`,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
if (containers.length > 1) {
|
2020-03-27 01:26:37 +00:00
|
|
|
throw new ExpectedError(stripIndent`
|
2019-04-24 17:45:19 +00:00
|
|
|
Found more than one container with a service name ${opts.service}.
|
|
|
|
This state is not supported, please contact support.
|
|
|
|
`);
|
|
|
|
}
|
|
|
|
|
2020-03-29 23:19:07 +00:00
|
|
|
const containerId = containers[0]!.id;
|
2020-03-27 01:26:37 +00:00
|
|
|
const shellCmd = `/bin/sh -c "if [ -e /bin/bash ]; then exec /bin/bash; else exec /bin/sh; fi"`;
|
2020-03-29 23:19:07 +00:00
|
|
|
// stdin (fd=0) is not a tty when data is piped in, for example
|
|
|
|
// echo 'ls -la; exit;' | balena ssh 192.168.0.20 service1
|
|
|
|
// See https://www.balena.io/blog/balena-monthly-roundup-january-2020/#charliestipsntricks
|
|
|
|
// https://assets.balena.io/newsletter/2020-01/pipe.png
|
2020-03-31 13:50:09 +00:00
|
|
|
const isTTY = !!opts.forceTTY || (await import('tty')).isatty(0);
|
|
|
|
const ttyFlag = isTTY ? '-t' : '';
|
2020-03-29 23:19:07 +00:00
|
|
|
command = `${deviceContainerEngineBinary} exec -i ${ttyFlag} ${containerId} ${shellCmd}`;
|
2019-04-24 17:45:19 +00:00
|
|
|
}
|
|
|
|
|
2020-03-31 12:43:58 +00:00
|
|
|
return spawnSshAndExitOnError([
|
2020-03-27 01:26:37 +00:00
|
|
|
...(opts.verbose ? ['-vvv'] : []),
|
|
|
|
'-t',
|
|
|
|
...['-p', opts.port ? opts.port.toString() : '22222'],
|
|
|
|
...['-o', 'LogLevel=ERROR'],
|
|
|
|
...['-o', 'StrictHostKeyChecking=no'],
|
|
|
|
...['-o', 'UserKnownHostsFile=/dev/null'],
|
|
|
|
`root@${opts.address}`,
|
|
|
|
...(command ? [command] : []),
|
|
|
|
]);
|
2019-04-24 17:45:19 +00:00
|
|
|
}
|