Several improvements to fuchsia_ctl. (#134)

* Several improvements to fuchsia_ctl.

* Adds timeouts for paving process.
* Adds timeouts to ssh commands and tests.
* Support to generate authorized_keys with existing pub key.
* Add flags to pass public key to pave subcommand.

https://github.com/flutter/flutter/issues/54416

* Link CLI flags to command implementations.

* Enables command help.

* Address code review comments.
This commit is contained in:
godofredoc
2020-04-10 21:20:24 -07:00
committed by GitHub
parent c35c226ec8
commit 5859d7756c
7 changed files with 234 additions and 51 deletions

View File

@ -39,7 +39,8 @@ Future<void> main(List<String> args) async {
'If not specified, the first discoverable device will be used.')
..addOption('dev-finder-path',
defaultsTo: './dev_finder',
help: 'The path to the dev_finder executable.');
help: 'The path to the dev_finder executable.')
..addFlag('help', defaultsTo: false, help: 'Prints help.');
parser.addCommand('ssh')
..addFlag('interactive',
abbr: 'i',
@ -50,8 +51,12 @@ Future<void> main(List<String> args) async {
help: 'The command to run on the device. '
'If specified, --interactive is ignored.')
..addOption('identity-file',
defaultsTo: '.ssh/pkey', help: 'The key to use when SSHing.');
defaultsTo: '.ssh/pkey', help: 'The key to use when SSHing.')
..addOption('timeout-seconds',
defaultsTo: '120', help: 'Ssh command timeout in seconds.');
parser.addCommand('pave')
..addOption('public-key',
abbr: 'p', help: 'The public key to add to authorized_keys.')
..addOption('image',
abbr: 'i', help: 'The system image tgz to unpack and pave.');
@ -89,7 +94,9 @@ Future<void> main(List<String> args) async {
abbr: 'a',
help: 'Command line arguments to pass when invoking the tests')
..addMultiOption('far',
abbr: 'f', help: 'The .far files to include for the test.');
abbr: 'f', help: 'The .far files to include for the test.')
..addOption('timeout-seconds',
defaultsTo: '120', help: 'Test timeout in seconds.');
final ArgResults results = parser.parse(args);
@ -98,6 +105,12 @@ Future<void> main(List<String> args) async {
stderr.writeln(parser.usage);
exit(-1);
}
if (results['help']) {
stderr.writeln(parser.commands[results.command.name].usage);
exit(0);
}
final AsyncResult command = commands[results.command.name];
if (command == null) {
stderr.writeln('Unkown command ${results.command.name}.');
@ -129,11 +142,11 @@ Future<OperationResult> ssh(
identityFilePath: identityFile,
);
}
final OperationResult result = await sshClient.runCommand(
targetIp,
identityFilePath: identityFile,
command: args['command'].split(' '),
);
final OperationResult result = await sshClient.runCommand(targetIp,
identityFilePath: identityFile,
command: args['command'].split(' '),
timeoutMs:
Duration(milliseconds: int.parse(args['timeout-seconds']) * 1000));
stdout.writeln(
'==================================== STDOUT ====================================');
stdout.writeln(result.info);
@ -150,7 +163,11 @@ Future<OperationResult> pave(
ArgResults args,
) async {
const ImagePaver paver = ImagePaver();
return await paver.pave(args['image'], deviceName);
return await paver.pave(
args['image'],
deviceName,
publicKeyPath: args['public-key'],
);
}
@visibleForTesting
@ -271,6 +288,8 @@ Future<OperationResult> test(
'fuchsia-pkg://fuchsia.com/$target#meta/$target.cmx',
arguments
],
timeoutMs:
Duration(milliseconds: int.parse(args['timeout-seconds']) * 1000),
);
stdout.writeln('Test results (passed: ${testResult.success}):');
if (result.info != null) {

View File

@ -27,17 +27,19 @@ class ImagePaver {
this.processManager = const LocalProcessManager(),
this.fs = const LocalFileSystem(),
this.tar = const SystemTar(processManager: LocalProcessManager()),
this.sshKeyManager =
const SystemSshKeyManager(processManager: LocalProcessManager()),
this.sshKeyManagerProvider = SystemSshKeyManager.defaultProvider,
}) : assert(processManager != null),
assert(fs != null),
assert(tar != null),
assert(sshKeyManager != null);
assert(tar != null);
/// The [ProcessManager] used to launch the boot server, `tar`,
/// and `ssh-keygen`.
final ProcessManager processManager;
/// The default pave timeout as [Duration] in milliseconds.
static const Duration defaultPaveTimeoutMs =
Duration(milliseconds: 5 * 60 * 1000);
/// The [FileSystem] implementation used to
final FileSystem fs;
@ -45,7 +47,7 @@ class ImagePaver {
final Tar tar;
/// The implementation to use for creating SSH keys.
final SshKeyManager sshKeyManager;
final SshKeyManagerProvider sshKeyManagerProvider;
/// Paves an image (in .tgz format) to the specified device.
///
@ -54,7 +56,9 @@ class ImagePaver {
Future<OperationResult> pave(
String imageTgzPath,
String deviceName, {
String publicKeyPath,
bool verbose = true,
Duration timeoutMs = defaultPaveTimeoutMs,
}) async {
assert(imageTgzPath != null);
if (deviceName == null) {
@ -62,6 +66,11 @@ class ImagePaver {
'If multiple devices are attached, this may result in paving '
'an unexpected device.');
}
final SshKeyManager sshKeyManager = sshKeyManagerProvider(
processManager: processManager,
publicKeyPath: publicKeyPath,
fs: fs,
);
final String uuid = Uuid().v4();
final Directory imageDirectory = fs.directory('image_$uuid');
if (verbose) {
@ -98,7 +107,7 @@ class ImagePaver {
if (deviceName != null) ...<String>['-n', deviceName],
'--authorized-keys', '.ssh/authorized_keys',
],
);
).timeout(timeoutMs);
final StringBuffer paveStdout = StringBuffer();
final StringBuffer paveStderr = StringBuffer();
paveProcess.stdout.transform(utf8.decoder).forEach((String s) {

View File

@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@ -25,6 +26,10 @@ class SshClient {
/// The [ProcessManager] to use for spawning `ssh`.
final ProcessManager processManager;
/// The default ssh timeout as [Duration] in milliseconds.
static const Duration defaultSshTimeoutMs =
Duration(milliseconds: 5 * 60 * 1000);
/// Creates a list of arguments to pass to ssh.
///
/// This method is not intended for use outside of this library, except for
@ -84,23 +89,24 @@ class SshClient {
/// [DevFinder] class.
///
/// All arguments must not be null.
Future<OperationResult> runCommand(
String targetIp, {
@required String identityFilePath,
@required List<String> command,
}) async {
Future<OperationResult> runCommand(String targetIp,
{@required String identityFilePath,
@required List<String> command,
Duration timeoutMs = defaultSshTimeoutMs}) async {
assert(targetIp != null);
assert(identityFilePath != null);
assert(command != null);
return OperationResult.fromProcessResult(
await processManager.run(
getSshArguments(
identityFilePath: identityFilePath,
targetIp: targetIp,
command: command,
),
),
await processManager
.run(
getSshArguments(
identityFilePath: identityFilePath,
targetIp: targetIp,
command: command,
),
)
.timeout(timeoutMs),
);
}
}

View File

@ -2,7 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:io';
import 'dart:io' as io;
import 'package:file/file.dart';
import 'package:file/local.dart';
@ -11,6 +11,13 @@ import 'package:process/process.dart';
import 'operation_result.dart';
/// Function signature for a [SshKeyManager] provider.
typedef SshKeyManagerProvider = SshKeyManager Function({
ProcessManager processManager,
FileSystem fs,
String publicKeyPath,
});
/// A wrapper for managing SSH key generation.
///
/// Implemented by [SystemSshKeyManager].
@ -32,9 +39,23 @@ class SystemSshKeyManager implements SshKeyManager {
const SystemSshKeyManager({
this.processManager = const LocalProcessManager(),
this.fs = const LocalFileSystem(),
this.pkeyPubPath,
}) : assert(processManager != null),
assert(fs != null);
/// Creates a static provider that returns a SystemSshKeyManager.
static SshKeyManager defaultProvider({
ProcessManager processManager,
FileSystem fs,
String publicKeyPath,
}) {
return SystemSshKeyManager(
processManager: processManager ?? const LocalProcessManager(),
fs: fs,
pkeyPubPath: publicKeyPath,
);
}
/// The [ProcessManager] implementation to use when spawning ssh-keygen.
final ProcessManager processManager;
@ -42,6 +63,16 @@ class SystemSshKeyManager implements SshKeyManager {
/// file.
final FileSystem fs;
/// The [String] with the path to a public key.
final String pkeyPubPath;
/// Populates [authorizedKeys] file with the public key in [pKeyPub].
Future<void> createAuthorizedKeys(File authorizedKeys, File pkeyPub) async {
final List<String> pkeyPubParts = pkeyPub.readAsStringSync().split(' ');
await authorizedKeys
.writeAsString('${pkeyPubParts[0]} ${pkeyPubParts[1]}\n');
}
@override
Future<OperationResult> createKeys({
String destinationPath = '.ssh',
@ -58,9 +89,14 @@ class SystemSshKeyManager implements SshKeyManager {
}
await sshDir.create();
if (pkeyPubPath != null) {
await createAuthorizedKeys(authorizedKeys, fs.file(pkeyPubPath));
return OperationResult.success(info: 'Using previously generated keys.');
}
final File pkey = sshDir.childFile('pkey');
final File pkeyPub = sshDir.childFile('pkey.pub');
final ProcessResult result = await processManager.run(
final io.ProcessResult result = await processManager.run(
<String>[
'ssh-keygen',
'-t', 'ed25519', //
@ -72,10 +108,7 @@ class SystemSshKeyManager implements SshKeyManager {
if (result.exitCode != 0) {
return OperationResult.fromProcessResult(result);
}
final List<String> pkeyPubParts = pkeyPub.readAsStringSync().split(' ');
await authorizedKeys
.writeAsString('${pkeyPubParts[0]} ${pkeyPubParts[1]}\n');
await createAuthorizedKeys(authorizedKeys, pkeyPub);
return OperationResult.fromProcessResult(result);
}
}

View File

@ -2,6 +2,9 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:io';
import 'package:file/file.dart';
import 'package:file/memory.dart';
import 'package:fuchsia_ctl/fuchsia_ctl.dart';
@ -17,20 +20,33 @@ import 'fakes.dart';
void main() {
const String deviceName = 'some-name-to-use';
FakeSshKeyManager sshKeyManager;
final MemoryFileSystem fs = MemoryFileSystem(style: FileSystemStyle.posix);
FakeTar tar;
MockProcessManager processManager;
SshKeyManagerProvider sshKeyManagerProvider;
setUp(() {
processManager = MockProcessManager();
sshKeyManager = const FakeSshKeyManager(true);
sshKeyManagerProvider = ({
ProcessManager processManager,
FileSystem fs,
String publicKeyPath,
}) {
return sshKeyManager;
};
});
test('Tar fails', () async {
const FakeSshKeyManager sshKeyManager = FakeSshKeyManager(true);
final MemoryFileSystem fs = MemoryFileSystem(style: FileSystemStyle.posix);
final FakeTar tar = FakeTar(false, fs);
final MockProcessManager processManager = MockProcessManager();
tar = FakeTar(false, fs);
when(processManager.start(any)).thenAnswer((_) async {
return FakeProcess(0, <String>['Good job'], <String>['']);
});
final ImagePaver paver = ImagePaver(
tar: tar,
sshKeyManager: sshKeyManager,
sshKeyManagerProvider: sshKeyManagerProvider,
fs: fs,
processManager: processManager,
);
@ -47,18 +63,15 @@ void main() {
});
test('Ssh fails', () async {
const FakeSshKeyManager sshKeyManager = FakeSshKeyManager(false);
final MemoryFileSystem fs = MemoryFileSystem(style: FileSystemStyle.posix);
final FakeTar tar = FakeTar(true, fs);
final MockProcessManager processManager = MockProcessManager();
sshKeyManager = const FakeSshKeyManager(false);
tar = FakeTar(true, fs);
when(processManager.start(any)).thenAnswer((_) async {
return FakeProcess(0, <String>['Good job'], <String>['']);
});
final ImagePaver paver = ImagePaver(
tar: tar,
sshKeyManager: sshKeyManager,
sshKeyManagerProvider: sshKeyManagerProvider,
fs: fs,
processManager: processManager,
);
@ -74,19 +87,40 @@ void main() {
expect(result.error, 'ssh failed');
});
test('Happy path', () async {
const FakeSshKeyManager sshKeyManager = FakeSshKeyManager(true);
final MemoryFileSystem fs = MemoryFileSystem(style: FileSystemStyle.posix);
final FakeTar tar = FakeTar(true, fs);
final MockProcessManager processManager = MockProcessManager();
test('Pave times out', () async {
tar = FakeTar(true, fs);
when(processManager.start(any)).thenAnswer((_) async {
await Future<void>.delayed(const Duration(milliseconds: 3));
return null;
});
final ImagePaver paver = ImagePaver(
tar: tar,
sshKeyManagerProvider: sshKeyManagerProvider,
fs: fs,
processManager: processManager,
);
expect(
paver.pave(
'generic-x64.tgz',
deviceName,
verbose: false,
timeoutMs: const Duration(milliseconds: 1),
),
throwsA(const TypeMatcher<TimeoutException>()));
});
test('Happy path', () async {
tar = FakeTar(true, fs);
when(processManager.start(any)).thenAnswer((_) async {
return FakeProcess(0, <String>['Good job'], <String>['']);
});
final ImagePaver paver = ImagePaver(
tar: tar,
sshKeyManager: sshKeyManager,
sshKeyManagerProvider: sshKeyManagerProvider,
fs: fs,
processManager: processManager,
);

View File

@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:io' show ProcessResult;
import 'package:fuchsia_ctl/fuchsia_ctl.dart';
@ -87,6 +88,25 @@ void main() {
);
expect(args.last, 'ls -al');
});
test('sshCommand times out', () {
final MockProcessManager processManager = MockProcessManager();
when(processManager.run(any)).thenAnswer((_) async {
await Future<void>.delayed(const Duration(milliseconds: 3));
return ProcessResult(0, 0, 'Good job', '');
});
final SshClient ssh = SshClient(processManager: processManager);
expect(
ssh.runCommand(
targetIp,
identityFilePath: identityFilePath,
command: const <String>['ls', '-al'],
timeoutMs: const Duration(milliseconds: 1),
),
throwsA(const TypeMatcher<TimeoutException>()));
});
}
class MockProcessManager extends Mock implements ProcessManager {}

View File

@ -0,0 +1,62 @@
import 'dart:io';
import 'package:file/file.dart';
import 'package:path/path.dart' as path;
import 'package:file/memory.dart';
import 'package:fuchsia_ctl/src/ssh_key_manager.dart';
import 'package:mockito/mockito.dart';
import 'package:process/process.dart';
import 'package:test/test.dart';
void main() {
group('SystemSshKeyManager', () {
MemoryFileSystem fs;
final MockProcessManager processManager = MockProcessManager();
setUp(() async {
fs = MemoryFileSystem();
});
test('CreateAuthorizedKeys', () async {
const SystemSshKeyManager systemSshKeyManager = SystemSshKeyManager();
final File authorizedKeys = fs.file('authorized_keys');
final File pkeyPub = fs.file('key.pub');
pkeyPub.writeAsString('ssh-rsa AAAA== abc@cde.com');
systemSshKeyManager.createAuthorizedKeys(authorizedKeys, pkeyPub);
final String result = await authorizedKeys.readAsString();
expect(result, equals('ssh-rsa AAAA==\n'));
});
test('KeysNotGenerated_PubKeyPassed', () async {
final File pkeyPub = fs.file('key.pub');
pkeyPub.writeAsString('ssh-rsa AAAA== abc@cde.com');
final SystemSshKeyManager systemSshKeyManager = SystemSshKeyManager(
processManager: processManager, fs: fs, pkeyPubPath: pkeyPub.path);
await systemSshKeyManager.createKeys();
final File authorizedKeys = fs.file(path.join('.ssh', 'authorized_keys'));
final String result = await authorizedKeys.readAsString();
expect(result, equals('ssh-rsa AAAA==\n'));
verifyNever(processManager.run(any));
});
test('KeysGenerated', () async {
when(processManager.run(any)).thenAnswer((_) async {
final File pkeyPub = fs.file(path.join('.ssh', 'pkey.pub'));
pkeyPub.writeAsString('ssh-rsa AAAA== abc@cde.com');
final File pkey = fs.file(path.join('.ssh', 'pkey'));
pkey.create();
return ProcessResult(0, 0, 'Good job', '');
});
final SystemSshKeyManager systemSshKeyManager =
SystemSshKeyManager(processManager: processManager, fs: fs);
await systemSshKeyManager.createKeys();
final File authorizedKeys = fs.file(path.join('.ssh', 'authorized_keys'));
expect(await authorizedKeys.exists(), isTrue);
final File pkey = fs.file(path.join('.ssh', 'pkey'));
expect(await pkey.exists(), isTrue);
final File pkeyPub = fs.file(path.join('.ssh', 'pkey.pub'));
expect(await pkeyPub.exists(), isTrue);
});
});
}
class MockProcessManager extends Mock implements ProcessManager {}