| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:io'; |
| |
| import 'package:args/args.dart'; |
| import 'package:file/file.dart'; |
| import 'package:file/local.dart'; |
| import 'package:fuchsia_ctl/fuchsia_ctl.dart'; |
| import 'package:path/path.dart' as path; |
| import 'package:retry/retry.dart'; |
| import 'package:uuid/uuid.dart'; |
| |
| typedef AsyncResult = Future<OperationResult> Function(String, FFX, ArgResults); |
| |
| const Map<String, AsyncResult> commands = <String, AsyncResult>{ |
| 'emu': emulator, |
| 'pave': pave, |
| 'pm': pm, |
| 'ssh': ssh, |
| 'test': test, |
| 'push-packages': pushPackages, |
| }; |
| |
| Future<void> main(List<String> args) async { |
| if (!Platform.isLinux) { |
| throw UnsupportedError('This tool only supports Linux.'); |
| } |
| |
| final ArgParser parser = ArgParser(); |
| parser |
| ..addOption('device-name', |
| abbr: 'd', |
| help: 'The device node name to use. ' |
| 'If not specified, the first discoverable device will be used.') |
| ..addOption('ffx-path', |
| defaultsTo: './ffx', help: 'The path to the ffx executable.') |
| ..addFlag('help', defaultsTo: false, help: 'Prints help.'); |
| |
| /// This is a blocking command and will run until exited. |
| parser.addCommand('emu') |
| ..addOption('image', help: 'Fuchsia image to run') |
| ..addOption('zbi', help: 'Bootloader image to sign and run') |
| ..addOption('qemu-kernel', help: 'QEMU kernel to run') |
| ..addOption('window-size', help: 'Emulator window size formatted "WxH"') |
| ..addOption('aemu', help: 'AEMU executable path') |
| ..addOption('sdk', |
| help: 'Location to Fuchsia SDK containing tools and images') |
| ..addOption('public-key', |
| defaultsTo: '.fuchsia/authorized_keys', |
| help: 'Path to the authorized_keys to sign zbi image with') |
| ..addFlag('headless', help: 'Run FEMU without graphical window'); |
| |
| parser.addCommand('ssh') |
| ..addFlag('interactive', |
| abbr: 'i', |
| help: 'Whether to ssh in interactive mode. ' |
| 'If --comand is specified, this is ignored.') |
| ..addOption('command', |
| abbr: 'c', |
| 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.') |
| ..addOption('timeout-seconds', |
| defaultsTo: '120', help: 'Ssh command timeout in seconds.') |
| ..addOption('log-file', |
| defaultsTo: '', help: 'The file to write stdout and stderr.'); |
| 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.'); |
| |
| final ArgParser pmSubCommand = parser.addCommand('pm') |
| ..addOption('pm-path', |
| defaultsTo: './pm', help: 'The path to the pm executable.') |
| ..addOption('repo', |
| abbr: 'r', |
| help: 'The location of the repository folder to create, ' |
| 'publish, or serve.') |
| ..addCommand('serve') |
| ..addCommand('newRepo'); |
| pmSubCommand |
| .addCommand('publishRepo') |
| .addMultiOption('far', abbr: 'f', help: 'The .far files to publish.'); |
| |
| parser.addCommand('push-packages') |
| ..addOption('pm-path', |
| defaultsTo: './pm', help: 'The path to the pm executable.') |
| ..addOption('repoArchive', help: 'The path to the repo tar.gz archive.') |
| ..addOption('identity-file', |
| defaultsTo: '.ssh/pkey', help: 'The key to use when SSHing.') |
| ..addMultiOption('packages', |
| abbr: 'p', |
| help: 'Packages from the repo that need to be pushed to the device.'); |
| |
| parser.addCommand('test') |
| ..addOption('pm-path', |
| defaultsTo: './pm', help: 'The path to the pm executable.') |
| ..addOption('identity-file', |
| defaultsTo: '.ssh/pkey', help: 'The key to use when SSHing.') |
| ..addOption('target', |
| abbr: 't', help: 'The name of the target to pass to runtests.') |
| ..addOption('arguments', |
| 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.') |
| ..addOption('timeout-seconds', |
| defaultsTo: '120', help: 'Test timeout in seconds.') |
| ..addOption('packages-directory', help: 'amber files directory.'); |
| |
| final ArgResults results = parser.parse(args); |
| |
| if (results.command == null) { |
| stderr.writeln('Unknown command, expected one of: ${parser.commands.keys}'); |
| 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('Unknown command ${results.command.name}.'); |
| stderr.writeln(parser.usage); |
| exit(-1); |
| } |
| final OperationResult result = await command( |
| results['device-name'], |
| FFX(results['ffx-path']), |
| results.command, |
| ); |
| if (!result.success) { |
| exit(-1); |
| } |
| } |
| |
| Future<OperationResult> emulator( |
| String deviceName, |
| FFX ffx, |
| ArgResults args, |
| ) async { |
| final Emulator emulator = Emulator( |
| aemuPath: args['aemu'], |
| fuchsiaImagePath: args['image'], |
| fuchsiaSdkPath: args['sdk'], |
| qemuKernelPath: args['qemu-kernel'], |
| sshKeyManager: SystemSshKeyManager.defaultProvider( |
| publicKeyPath: args['public-key'], |
| ), |
| zbiPath: args['zbi'], |
| ); |
| await emulator.prepareEnvironment(); |
| |
| return emulator.start( |
| headless: args['headless'], |
| windowSize: args['window-size'], |
| ); |
| } |
| |
| Future<OperationResult> ssh( |
| String deviceName, |
| FFX ffx, |
| ArgResults args, |
| ) async { |
| const SshClient sshClient = SshClient(); |
| final String targetIp = await ffx.getTargetAddress(deviceName); |
| final String identityFile = args['identity-file']; |
| final String outputFile = args['log-file']; |
| if (args['interactive']) { |
| return sshClient.interactive( |
| targetIp, |
| identityFilePath: identityFile, |
| ); |
| } |
| final OperationResult result = await sshClient.runCommand( |
| targetIp, |
| identityFilePath: identityFile, |
| command: (args['command'] as String).split(' '), |
| timeoutMs: |
| Duration(milliseconds: int.parse(args['timeout-seconds']) * 1000), |
| logFilePath: outputFile, |
| ); |
| stdout.writeln( |
| '==================================== STDOUT ===================================='); |
| stdout.writeln(result.info); |
| stderr.writeln( |
| '==================================== STDERR ===================================='); |
| stderr.writeln(result.error); |
| return result; |
| } |
| |
| Future<OperationResult> pave( |
| String deviceName, |
| FFX ffx, |
| ArgResults args, |
| ) async { |
| const ImagePaver paver = ImagePaver(); |
| const RetryOptions r = RetryOptions( |
| maxAttempts: 3, |
| ); |
| return r.retry(() async { |
| final OperationResult result = await paver.pave( |
| args['image'], |
| deviceName, |
| publicKeyPath: args['public-key'], |
| ); |
| if (!result.success) { |
| throw RetryException('Exit code different from 0', result); |
| } |
| return result; |
| }, retryIf: (Exception e) => e is RetryException); |
| } |
| |
| Future<OperationResult> pm( |
| String deviceName, |
| FFX ffx, |
| ArgResults args, |
| ) async { |
| final PackageServer server = PackageServer(args['pm-path']); |
| switch (args.command.name) { |
| case 'serve': |
| await server.serveRepo(args['repo']); |
| await Future<void>.delayed(const Duration(seconds: 15)); |
| return server.close(); |
| case 'newRepo': |
| return server.newRepo(args['repo']); |
| case 'publishRepo': |
| return server.publishRepo(args['repo'], args['far']); |
| default: |
| throw ArgumentError('Command ${args.command.name} unknown.'); |
| } |
| } |
| |
| Future<OperationResult> pushPackages( |
| String deviceName, |
| FFX ffx, |
| ArgResults args, |
| ) async { |
| final PackageServer server = PackageServer(args['pm-path']); |
| final String repoArchive = args['repoArchive']; |
| final List<String> packages = args['packages']; |
| final String identityFile = args['identity-file']; |
| |
| const FileSystem fs = LocalFileSystem(); |
| final String uuid = const Uuid().v4(); |
| final Directory repo = fs.systemTempDirectory.childDirectory('repo_$uuid'); |
| const Tar tar = SystemTar(); |
| try { |
| final String targetIp = await ffx.getTargetAddress(deviceName); |
| final AmberCtl amberCtl = AmberCtl(targetIp, identityFile); |
| |
| stdout.writeln('Untaring $repoArchive to ${repo.path}'); |
| repo.createSync(recursive: true); |
| final OperationResult result = await tar.untar(repoArchive, repo.path); |
| if (!result.success) { |
| stdout.writeln( |
| 'Error untarring $repoArchive \nstdout: ${result.info} \nstderr: ${result.error}'); |
| exit(-1); |
| } |
| |
| final String repositoryBase = path.join(repo.path, 'amber-files'); |
| stdout.writeln('Serving $repositoryBase to $targetIp'); |
| await server.serveRepo(repositoryBase); |
| await amberCtl.addSrc(server.serverPort); |
| |
| stdout.writeln('Pushing packages $packages to $targetIp'); |
| for (final String packageName in packages) { |
| stdout.writeln('Attempting to add package $packageName.'); |
| await amberCtl.addPackage(packageName); |
| } |
| |
| return OperationResult.success( |
| info: 'Successfully pushed $packages to $targetIp.'); |
| } finally { |
| // We may not have created the repo if ffx errored first. |
| if (repo.existsSync()) { |
| repo.deleteSync(recursive: true); |
| } |
| if (server.serving) { |
| await server.close(); |
| } |
| } |
| } |
| |
| Future<OperationResult> test( |
| String deviceName, |
| FFX ffx, |
| ArgResults args, |
| ) async { |
| const FileSystem fs = LocalFileSystem(); |
| final String identityFile = args['identity-file']; |
| |
| //final PackageServer server = PackageServer(args['pm-path']); |
| PackageServer server; |
| const SshClient ssh = SshClient(); |
| final List<String> farFiles = args['far']; |
| final String target = args['target']; |
| final String arguments = args['arguments']; |
| Directory repo; |
| if (args['packages-directory'] == null) { |
| final String uuid = const Uuid().v4(); |
| repo = fs.systemTempDirectory.childDirectory('repo_$uuid'); |
| server = PackageServer(args['pm-path']); |
| } else { |
| final String amberFilesPath = path.join( |
| args['packages-directory'], |
| 'amber-files', |
| ); |
| final String pmPath = path.join( |
| args['packages-directory'], |
| 'pm', |
| ); |
| repo = fs.directory(amberFilesPath); |
| server = PackageServer(pmPath); |
| } |
| |
| try { |
| final String targetIp = await ffx.getTargetAddress(deviceName); |
| final AmberCtl amberCtl = AmberCtl(targetIp, identityFile); |
| OperationResult result; |
| stdout.writeln('Using ${repo.path} as repo to serve to $targetIp...'); |
| if (!repo.existsSync()) { |
| repo.createSync(recursive: true); |
| result = await server.newRepo(repo.path); |
| if (!result.success) { |
| stderr.writeln('Failed to create repo at $repo.'); |
| return result; |
| } |
| } |
| await server.serveRepo(repo.path); |
| await amberCtl.addSrc(server.serverPort); |
| |
| for (final String farFile in farFiles) { |
| result = await server.publishRepo(repo.path, farFile); |
| if (!result.success) { |
| stderr.writeln('Failed to publish repo at $repo with $farFiles.'); |
| stderr.writeln(result.error); |
| return result; |
| } |
| final RegExp r = RegExp(r'\-0.far|.far'); |
| final String packageName = fs.file(farFile).basename.replaceFirst(r, ''); |
| await amberCtl.addPackage(packageName); |
| } |
| |
| final OperationResult testResult = await ssh.runCommand( |
| targetIp, |
| identityFilePath: identityFile, |
| command: <String>[ |
| 'run', |
| '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) { |
| stdout.writeln(testResult.info); |
| } |
| if (result.error != null) { |
| stderr.writeln(testResult.error); |
| } |
| return testResult; |
| } finally { |
| // We may not have created the repo if ffx errored first. |
| if (repo.existsSync() && args['packages-directory'] != null) { |
| repo.deleteSync(recursive: true); |
| } |
| if (server.serving) { |
| await server.close(); |
| } |
| } |
| } |
| |
| /// The exception thrown when an operation needs a retry. |
| class RetryException implements Exception { |
| /// Creates a new [RetryException] using the specified [cause] and [result] |
| /// to force a retry. |
| const RetryException(this.cause, this.result); |
| |
| /// The user-facing message to display. |
| final String cause; |
| |
| /// Contains the result of the executed target command. |
| final OperationResult result; |
| |
| @override |
| String toString() => |
| '$runtimeType, cause: "$cause", underlying exception: $result.'; |
| } |