Skip to content

Stream logs to file using process.start. #227

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Oct 16, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions packages/fuchsia_ctl/bin/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ Future<void> main(List<String> args) async {
..addOption('identity-file',
defaultsTo: '.ssh/pkey', help: 'The key to use when SSHing.')
..addOption('timeout-seconds',
defaultsTo: '120', help: 'Ssh command timeout in 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.')
Expand Down Expand Up @@ -176,17 +178,21 @@ Future<OperationResult> ssh(
const SshClient sshClient = SshClient();
final String targetIp = await devFinder.getTargetAddress(deviceName);
final String identityFile = args['identity-file'];
final String outputFile = args['log-file'];
if (args['interactive']) {
return await sshClient.interactive(
targetIp,
identityFilePath: identityFile,
);
}
final OperationResult result = await sshClient.runCommand(targetIp,
identityFilePath: identityFile,
command: args['command'].split(' '),
timeoutMs:
Duration(milliseconds: int.parse(args['timeout-seconds']) * 1000));
final OperationResult result = await sshClient.runCommand(
targetIp,
identityFilePath: identityFile,
command: args['command'].split(' '),
timeoutMs:
Duration(milliseconds: int.parse(args['timeout-seconds']) * 1000),
logFilePath: outputFile,
);
stdout.writeln(
'==================================== STDOUT ====================================');
stdout.writeln(result.info);
Expand Down
125 changes: 125 additions & 0 deletions packages/fuchsia_ctl/lib/src/logger.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import 'dart:io';

/// Defines the available log levels.
class LogLevel {
const LogLevel._(this._level, this.name);

final int _level;

/// String name for the log level.
final String name;

/// LogLevel for messages instended for debugging.
static const LogLevel debug = LogLevel._(0, 'DEBUG');

/// LogLevel for messages instended to provide information about the
/// execution.
static const LogLevel info = LogLevel._(1, 'INFO');

/// LogLevel for messages instended to flag potential problems.
static const LogLevel warning = LogLevel._(2, 'WARN');

/// LogLevel for errors in the execution.
static const LogLevel error = LogLevel._(3, 'ERROR');
}

/// Abstract class for loggers.
abstract class Logger {
/// Processes a debug message.
void debug(Object message);

/// Processes an info message.
void info(Object message);

/// Processes a warning message.
void warning(Object message);

/// Processes an error message.
void error(Object message);
}

/// Logger to print message to standard output.
class PrintLogger implements Logger {
/// Creates a logger instance to print messages to standard output.
PrintLogger({
IOSink out,
bool prependLogData,
this.level = LogLevel.info,
}) : out = out ?? stdout,
prependLogData = prependLogData ?? true;

/// The [IOSink] to print to.
final IOSink out;

/// Available log levels.
final LogLevel level;

/// Wether to prepend datetime and log level or not.
final bool prependLogData;

/// Stdout buffer.
final StringBuffer stdoutBuffer = StringBuffer();

/// Stderr buffer.
final StringBuffer stderrBuffer = StringBuffer();

/// Returns all the content logged as info, debug and warning without the
/// datetime and log level prepended to lines.
String outputLog() {
return stdoutBuffer.toString();
}

/// Returns all the content logged error without the
/// datetime and log level prepended to lines.
String errorLog() {
return stderrBuffer.toString();
}

@override
void debug(Object message) {
_log(LogLevel.debug, message);
stdoutBuffer.writeln(message);
}

@override
void info(Object message) {
_log(LogLevel.info, message);
stdoutBuffer.writeln(message);
}

@override
void warning(Object message) {
_log(LogLevel.warning, message);
stdoutBuffer.writeln(message);
}

@override
void error(Object message) {
_log(LogLevel.error, message);
stderrBuffer.writeln(message);
}

void _log(LogLevel level, Object message) {
if (prependLogData) {
if (level._level >= this.level._level) {
out.writeln(toLogString('$message', level: level));
}
} else {
out.writeln('$message');
}
}
}

/// Transforms a [message] with [level] to a string that contains the DateTime,
/// level and message.
String toLogString(String message, {LogLevel level}) {
final StringBuffer buffer = StringBuffer();
buffer.write(DateTime.now().toUtc().toIso8601String());
buffer.write(': ');
if (level != null) {
buffer.write(level.name);
buffer.write(' ');
}
buffer.write(message);
return buffer.toString();
}
76 changes: 62 additions & 14 deletions packages/fuchsia_ctl/lib/src/ssh_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:fuchsia_ctl/src/logger.dart';
import 'package:meta/meta.dart';
import 'package:process/process.dart';

Expand Down Expand Up @@ -89,24 +92,69 @@ class SshClient {
/// [DevFinder] class.
///
/// All arguments must not be null.
Future<OperationResult> runCommand(String targetIp,
{@required String identityFilePath,
@required List<String> command,
Duration timeoutMs = defaultSshTimeoutMs}) async {
Future<OperationResult> runCommand(
String targetIp, {
@required String identityFilePath,
@required List<String> command,
Duration timeoutMs = defaultSshTimeoutMs,
String logFilePath,
FileSystem fs,
}) async {
assert(targetIp != null);
assert(identityFilePath != null);
assert(command != null);

return OperationResult.fromProcessResult(
await processManager
.run(
getSshArguments(
identityFilePath: identityFilePath,
targetIp: targetIp,
command: command,
),
)
.timeout(timeoutMs),
final bool logToFile = !(logFilePath == null || logFilePath.isEmpty);
final FileSystem fileSystem = fs ?? const LocalFileSystem();
PrintLogger logger;

// If no file is passed to this method we create a memoryfile to keep to
// return the stdout in OperationResult.
if (logToFile) {
fileSystem.file(logFilePath).existsSync() ??
fileSystem.file(logFilePath).deleteSync();
fileSystem.file(logFilePath).createSync();
final IOSink data = fileSystem.file(logFilePath).openWrite();
logger = PrintLogger(out: data);
} else {
logger = PrintLogger();
}

final Process process = await processManager.start(
getSshArguments(
identityFilePath: identityFilePath,
targetIp: targetIp,
command: command,
),
);
final StreamSubscription<String> stdoutSubscription = process.stdout
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((String log) {
logger.info(log);
});
final StreamSubscription<String> stderrSubscription = process.stderr
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((String log) {
logger.error(log);
});

// Wait for stdout and stderr to be fully processed because proc.exitCode
// may complete first.
await Future.wait<void>(<Future<void>>[
stdoutSubscription.asFuture<void>(),
stderrSubscription.asFuture<void>(),
]);
// The streams as futures have already completed, so waiting for the
// potentially async stream cancellation to complete likely has no benefit.
stdoutSubscription.cancel();
stderrSubscription.cancel();

final int exitCode = await process.exitCode.timeout(timeoutMs);

return exitCode != 0
? OperationResult.error('Failed', info: logger.errorLog())
: OperationResult.success(info: logger.outputLog());
}
}
3 changes: 2 additions & 1 deletion packages/fuchsia_ctl/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ dependencies:
uuid: ^2.0.2

dev_dependencies:
test: ^1.6.9
mockito: ^4.1.1
pedantic: ^1.9.2
test: ^1.6.9
58 changes: 58 additions & 0 deletions packages/fuchsia_ctl/test/logger_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright 2020 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:file/memory.dart';
import 'package:fuchsia_ctl/src/logger.dart';
import 'package:test/test.dart';

void main() {
test('PrintLogger with file logs data correctly', () async {
final MemoryFileSystem fs = MemoryFileSystem();
fs.file('log.txt').createSync();
final IOSink data = fs.file('log.txt').openWrite();
final PrintLogger logger = PrintLogger(out: data, level: LogLevel.debug);
logger.debug('abc');
logger.info('cdf');
logger.warning('gh');
logger.error('jk');
await data.flush();
final String content = fs.file('log.txt').readAsStringSync();
expect(content, contains('ERROR jk'));
expect(content, contains('INFO cdf'));
expect(content, contains('WARN gh'));
expect(content, contains('DEBUG abc'));
});
test('PrintLogger with no file logs data correctly', () async {
final PrintLogger logger = PrintLogger();
logger.debug('abc');
logger.info('cdf');
logger.warning('gh');
logger.error('jk');
final String outContent = logger.outputLog();
final String errContent = logger.errorLog();
expect(errContent, contains('jk\n'));
expect(outContent, contains('cdf\n'));
expect(outContent, contains('gh\n'));
expect(outContent, contains('abc\n'));
});

test('PrintLogger with file logs logs only data above level', () async {
final MemoryFileSystem fs = MemoryFileSystem();
fs.file('log.txt').createSync();
final IOSink data = fs.file('log.txt').openWrite();
final PrintLogger logger = PrintLogger(out: data, level: LogLevel.info);
logger.debug('abc');
logger.info('cdf');
logger.warning('gh');
logger.error('jk');
await data.flush();
final String content = fs.file('log.txt').readAsStringSync();
expect(content, contains('ERROR jk'));
expect(content, contains('INFO cdf'));
expect(content, contains('WARN gh'));
expect(content, isNot(contains('DEBUG abc')));
});
}
38 changes: 32 additions & 6 deletions packages/fuchsia_ctl/test/ssh_client_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
// found in the LICENSE file.

import 'dart:async';
import 'dart:io' show ProcessResult;

import 'package:file/file.dart';
import 'package:file/memory.dart';
import 'package:fuchsia_ctl/fuchsia_ctl.dart';
import 'package:fuchsia_ctl/src/ssh_client.dart';
import 'package:fuchsia_ctl/src/operation_result.dart';
Expand Down Expand Up @@ -51,8 +52,8 @@ void main() {
const List<String> command = <String>['ls', '-al'];
final MockProcessManager processManager = MockProcessManager();

when(processManager.run(any)).thenAnswer((_) async {
return ProcessResult(0, 0, 'Good job', '');
when(processManager.start(any)).thenAnswer((_) async {
return FakeProcess(0, <String>['abc'], <String>['cdf']);
});

final SshClient ssh = SshClient(processManager: processManager);
Expand All @@ -64,7 +65,7 @@ void main() {
);

final List<String> capturedStartArgs =
verify(processManager.run(captureAny))
verify(processManager.start(captureAny))
.captured
.cast<List<String>>()
.single;
Expand All @@ -76,6 +77,31 @@ void main() {
targetIp: targetIp,
command: command,
));
expect(result.info, 'abc\n');
expect(result.success, true);
});

test('Command output is written to a log file', () async {
const List<String> command = <String>['ls', '-al'];
final MockProcessManager processManager = MockProcessManager();

when(processManager.start(any)).thenAnswer((_) async {
return FakeProcess(0, <String>['ef'], <String>['abc']);
});

final SshClient ssh = SshClient(processManager: processManager);
final FileSystem fs = MemoryFileSystem();
final OperationResult result = await ssh.runCommand(
targetIp,
identityFilePath: identityFilePath,
command: command,
fs: fs,
logFilePath: 'myfile.txt',
);

final String content = await fs.file('myfile.txt').readAsString();
expect(content, contains('ERROR abc'));
expect(content, contains('INFO ef'));
expect(result.success, true);
});

Expand All @@ -92,9 +118,9 @@ void main() {
test('sshCommand times out', () async {
final MockProcessManager processManager = MockProcessManager();

when(processManager.run(any)).thenAnswer((_) async {
when(processManager.start(any)).thenAnswer((_) async {
await Future<void>.delayed(const Duration(milliseconds: 3));
return ProcessResult(0, 0, 'Good job', '');
return FakeProcess(0, <String>[''], <String>['']);
});

final SshClient ssh = SshClient(processManager: processManager);
Expand Down