diff --git a/.vscode/launch.json b/.vscode/launch.json index 4de5ac4..6a8f517 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -80,6 +80,27 @@ "type": "dart", "program": "./bin/main.dart", "args": ["exe", "-f", "C:/Work/automation/iot/script/windows/startup.yaml"] + }, + { + "name": "scheduler regist", + "request": "launch", + "type": "dart", + "program": "./bin/main.dart", + "args": ["scheduler", "-r", "./assets/scheduler.yaml"] + }, + { + "name": "scheduler unregist", + "request": "launch", + "type": "dart", + "program": "./bin/main.dart", + "args": ["scheduler", "-u", "test"] + }, + { + "name": "scheduler background", + "request": "launch", + "type": "dart", + "program": "./bin/main.dart", + "args": ["scheduler", "-s"] } ] } \ No newline at end of file diff --git a/assets/scheduler.yaml b/assets/scheduler.yaml new file mode 100644 index 0000000..65d14ed --- /dev/null +++ b/assets/scheduler.yaml @@ -0,0 +1,20 @@ +--- +scheduler: + alias: test + cron: '*/5 * * * *' + enableLog: false # (Optional) default: false + +property: + workspace: . + +pipeline: + id: main + workflow: + - exe: print + +commands: +### Print +- command: Print + id: print + param: + message: test \ No newline at end of file diff --git a/bin/main.dart b/bin/main.dart index acbdb38..5c10e48 100644 --- a/bin/main.dart +++ b/bin/main.dart @@ -7,8 +7,9 @@ import 'package:oto_cli/cli/commands/command_exe.dart'; // import 'package:oto_cli/cli/commands/command_start.dart'; void main(List arguments) async { - initialize(); - CLI.initialize('OTO', arguments, [ + await initialize('oto', 'com.toki-labs.oto'); + + CLI.initialize('oto', arguments, [ CommandTemplate(), CommandExe(), CommandScheduler() diff --git a/lib/cli/cli.dart b/lib/cli/cli.dart index e3b698a..56e0ed2 100644 --- a/lib/cli/cli.dart +++ b/lib/cli/cli.dart @@ -39,6 +39,10 @@ class CLIConfig { CLIConfig(this.serviceName, this.arguments, this.commands) { var fileName = path.basename(Platform.resolvedExecutable); fileName = fileName.replaceAll(path.extension(fileName), ''); + if(fileName == 'dart') { + //debug + fileName = 'dart bin/main.dart'; + } executableFileName = fileName; } } diff --git a/lib/cli/commands/command_exe.dart b/lib/cli/commands/command_exe.dart index 4cbe2ef..30a1f71 100644 --- a/lib/cli/commands/command_exe.dart +++ b/lib/cli/commands/command_exe.dart @@ -1,10 +1,8 @@ // ignore_for_file: avoid_init_to_null import 'dart:async'; -import 'dart:convert'; import 'dart:io'; -import 'package:dart_framework/charset/euc_kr.dart'; import 'package:dart_framework/data/isolate_data.dart'; import 'package:dart_framework/platform/isolate_manager.dart'; import 'package:oto_cli/cli/cli.dart'; @@ -33,12 +31,11 @@ class CommandExe extends CommandBase { _handler = IsolateManager.create(IsolateExe(BuildType.jenkins)); await isComplete(); break; - } - if (item.startsWith('-t')) { + } else if (item.startsWith('-t')) { _handler = IsolateManager.create(IsolateExe(BuildType.test)); await isComplete(); - } - if (item.startsWith('-f')) { + break; + } else if (item.startsWith('-f')) { var file = ''; if (parameters.length > i + 1) { file = parameters[i + 1]; @@ -47,6 +44,8 @@ class CommandExe extends CommandBase { } await startYaml(file); break; + } else { + printHelp(); } } return simpleFuture; diff --git a/lib/cli/commands/command_manager.dart b/lib/cli/commands/command_manager.dart index 5511a7b..57af4bb 100644 --- a/lib/cli/commands/command_manager.dart +++ b/lib/cli/commands/command_manager.dart @@ -35,9 +35,20 @@ class CommandManager { } if (!printHelp) { - await CLI.println('Execute command: $command$argStr', + if(parameters.isEmpty) { + commandItem.printHelp(); + } else { + await CLI.println('Execute command: $command$argStr', color: Color.green); - await commandItem.execute(parameters); + try { + await commandItem.execute(parameters); + } on Exception catch(e, stacktace) { + print(''); + var message = e.toString().replaceAll('Exception:', ''); + await CLI.println('${CLI.style('Error:', background: Color.red)} $message'); + print(''); + } + } } } else { printHelp(append: [ diff --git a/lib/cli/commands/command_scheduler.dart b/lib/cli/commands/command_scheduler.dart index 679d39b..5d5f0f8 100644 --- a/lib/cli/commands/command_scheduler.dart +++ b/lib/cli/commands/command_scheduler.dart @@ -1,33 +1,55 @@ import 'dart:async'; +import 'dart:io'; import 'package:oto_cli/cli/commands/command_base.dart'; import 'package:dart_framework/utils/system_util.dart'; -import 'package:oto_cli/cli/commands/scheduler/scheduler.dart'; +import 'package:oto_cli/cli/commands/scheduler/scheduler_manager.dart'; class CommandScheduler extends CommandBase { @override Map> get arguments => { '-l': ["Shows the currently operational schedulers."], - '-k {alias}': ["Shut down the active scheduler."] + '-r {file}': ["Register the defined file (yaml) with the scheduler."], + '-u {alias}': ["Unregister schedules registered with the scheduler based on alias."] }; @override String get name => 'scheduler'; - final Scheduler _scheduler = Scheduler(); + final SchedulerManager _scheduler = SchedulerManager(); @override Future execute(List parameters) async { for (var i = 0; i < parameters.length; ++i) { var item = parameters[i]; if (item.startsWith('-l')) { - } else if (item.startsWith('-k')) { + _scheduler.showList(); + break; + } else if (item.startsWith('-r')) { + var file = ''; + if (parameters.length > i + 1) { + file = parameters[i + 1]; + } else { + file = item.replaceFirst('-r', '').trimLeft(); + } + await _scheduler.regist(file); + exit(0); + } else if (item.startsWith('-u')) { var alias = ''; if (parameters.length > i + 1) { alias = parameters[i + 1]; } else { - alias = item.replaceFirst('-f', '').trimLeft(); + alias = item.replaceFirst('-u', '').trimLeft(); } - print('======================> $alias'); + await _scheduler.unregist(alias); + break; + } else if (item.startsWith('-s')) { //hidden: start process + await _scheduler.start(); + break; + } else if (item.startsWith('-t')) { //hidden: terminate process + await _scheduler.terminate(); + break; + } else { + printHelp(); } } return simpleFuture; diff --git a/lib/cli/commands/scheduler/scheduler.dart b/lib/cli/commands/scheduler/scheduler.dart deleted file mode 100644 index fbd3c53..0000000 --- a/lib/cli/commands/scheduler/scheduler.dart +++ /dev/null @@ -1,20 +0,0 @@ - -import 'dart:io'; - -import 'package:oto_cli/cli/commands/scheduler/scheduler_linux.dart'; -import 'package:oto_cli/cli/commands/scheduler/scheduler_osx.dart'; -import 'package:oto_cli/cli/commands/scheduler/scheduler_windows.dart'; - -abstract class Scheduler { - factory Scheduler() { - if(Platform.isMacOS) { - return SchedulerOsx(); - } else if(Platform.isWindows) { - return SchedulerWindows(); - } else if(Platform.isLinux) { - return SchedulerLinux(); - } else { - throw UnsupportedError('This platform not supported.'); - } - } -} \ No newline at end of file diff --git a/lib/cli/commands/scheduler/scheduler_linux.dart b/lib/cli/commands/scheduler/scheduler_linux.dart index 42a3279..ffb0984 100644 --- a/lib/cli/commands/scheduler/scheduler_linux.dart +++ b/lib/cli/commands/scheduler/scheduler_linux.dart @@ -1,6 +1,5 @@ -import 'package:oto_cli/cli/commands/scheduler/scheduler.dart'; +import 'package:oto_cli/cli/commands/scheduler/scheduler_manager.dart'; -class SchedulerLinux implements Scheduler { - +class SchedulerLinux extends SchedulerManager { } \ No newline at end of file diff --git a/lib/cli/commands/scheduler/scheduler_manager.dart b/lib/cli/commands/scheduler/scheduler_manager.dart new file mode 100644 index 0000000..197813d --- /dev/null +++ b/lib/cli/commands/scheduler/scheduler_manager.dart @@ -0,0 +1,325 @@ + +import 'dart:collection'; +import 'dart:io'; + +import 'package:cron/cron.dart'; +import 'package:dart_framework/data/isolate_data.dart'; +import 'package:dart_framework/platform/isolate_manager.dart'; +import 'package:dart_framework/platform/process.dart'; +import 'package:dart_framework/utils/os_startup.dart'; +import 'package:dart_framework/utils/string_util.dart'; +import 'package:dart_framework/utils/system_util.dart'; +import 'package:file_hasher/file_hasher.dart'; +import 'package:oto_cli/cli/commands/scheduler/scheduler_linux.dart'; +import 'package:oto_cli/cli/commands/scheduler/scheduler_osx.dart'; +import 'package:oto_cli/cli/commands/scheduler/scheduler_windows.dart'; +import 'package:oto_cli/oto/application.dart'; +import 'package:oto_cli/oto/data/command_data.dart'; +import 'package:path/path.dart' as path; + +class SchedulerManager { + + static String get schedulerPath { + var debugPath = isDebug ? '/debug' : ''; + return '$dataPath$debugPath/scheduler'; + } + + static String get logsPath { + return '$schedulerPath/logs'; + } + + ProcessInfo? currentScheduler; + final Map _map = {}; + + HashSet get schedulers { + HashSet hash = HashSet(); + var dir = Directory(schedulerPath); + if(dir.existsSync()) { + var fileList = dir.listSync(); + for(var item in fileList) { + if(item is File) { + if(path.extension(item.path).toLowerCase().contains('.yaml')) { + hash.add(path.basenameWithoutExtension(item.path)); + } + } + } + } + return hash; + } + + SchedulerManager(); + + factory SchedulerManager.create() { + if(Platform.isMacOS) { + return SchedulerOsx(); + } else if(Platform.isWindows) { + return SchedulerWindows(); + } else if(Platform.isLinux) { + return SchedulerLinux(); + } else { + throw UnsupportedError('This platform not supported.'); + } + } + + Future regist(String filePath) async { + var file = File(filePath); + if(!file.existsSync()) { + return throw Exception("The file '$filePath' does not exist."); + } + Map? map; + DataBuild? build; + try { + map = Application.getMapFromYamlA(await file.readAsString()); + build = DataBuild.fromJson(map!); + } on Exception catch(e, stacktace) { + print(stacktace); + return throw Exception("The file '$filePath' is not in yaml format or is an unsupported build format."); + } + + if(build.scheduler == null) { + return throw Exception('Scheduler entry does not exist within yaml.'); + } + + var dirLogs = Directory(logsPath); + var alias = build.scheduler!.alias; + var hash = schedulers; + if(hash.contains(alias)) { + return throw Exception('A scheduler with the same alias is registered. Please do it with a different alias.'); + } + await checkStartup(); + await checkProcess(); + await startProcess(); + + if(!dirLogs.existsSync()) { + dirLogs.createSync(recursive: true); + } + await file.copy('$schedulerPath/$alias.yaml'); + return simpleFuture; + } + + Future unregist(String alias) async { + if(schedulers.contains(alias)) { + var file = File('$schedulerPath/$alias.yaml'); + file.deleteSync(); + } else { + return throw Exception("There is no scheduler called '$alias'."); + } + + if(schedulers.isEmpty) { + await terminate(); + await unregistStartup(); + } + } + + void showList() { + print(''); + var scheduleList = schedulers; + if(scheduleList.isEmpty) { + print(':::: There are no registered schedulers.'); + print(''); + } else { + print(':::: List of currently registered schedulers.'); + print(''); + print('${' [Enable]'.padRight(20)}${'[Alias]'.padRight(20)}[Path]'); + for(var item in schedulers) { + print('${' - true'.padRight(20)}${item.padRight(20)}$schedulerPath/$item.yaml'); + } + print(''); + } + } + + Future terminate() async { + await checkProcess(); + if(currentScheduler != null) { + Process.killPid(currentScheduler!.pid); + print('Scheduler operation stopped.'); + } + return simpleFuture; + } + + Future startProcess() async { + //Launchd로 자동 실행이 되지 않은경우 직접실행 + if(currentScheduler == null) { + var script = isDebug ? ' $scriptPath' : ''; + var shell = StringBuffer(); + shell.writeln('nohup $executablePath$script scheduler -s &'); + shell.writeln('disown'); + ProcessExecutor.run(shell, workspace: Directory.systemTemp.path, printStdout: false); + await checkProcess(); + } + return simpleFuture; + } + + Future checkSchedules() async { + return simpleFuture; + } + + Future checkProcess() async { + //process check + var processes = await findProcess('scheduler -s'); + for(var process in processes) { + print(process.name); + var processName = process.name; + if(processName.contains('main.dart scheduler -s') || processName.contains('$appName scheduler -s')) { + currentScheduler = process; + print('Scheduler is running on pid ${currentScheduler!.pid}.'); + break; + } + } + return simpleFuture; + } + + Future checkStartup() async { + if(!await OSStartup.isRegistedStartup(appIdendifier)) { + List param = []; + if(isDebug) { + param.add(scriptPath); + } + param.add('scheduler'); + param.add('-s'); + await OSStartup.registStartup(executablePath, appIdendifier, arguments: param); + } + return simpleFuture; + } + + Future unregistStartup() async { + if(await OSStartup.isRegistedStartup(appIdendifier)) { + await OSStartup.unregistStartup(appIdendifier); + } + return simpleFuture; + } + + //======================== other process ========================// + final logFile = File('$logsPath/scheduler.txt'); + Future start() async { + while(true) { + //매초 마다 등록된 파일이 있나 체크 + await Future.delayed(Duration(seconds: 1)); + var scheduleList = schedulers; + for(var alias in scheduleList) { + if(!_map.containsKey(alias)) { + await logFile.writeAsString('Schduler started: $alias\n', mode: FileMode.append); + var handler = IsolateManager.create(IsolateScheduler({'alias': alias, 'root': schedulerPath, 'logs': logsPath})); + //To-do: 종료시 사유(by user, by error)에 따라 json에 내용을 기입하여 재실행 안하도록 + handler.addListener('terminated', onTerminated ); + _map[alias] = handler; + } + } + } + } + + void onTerminated(Object? data) { + var map = data as Map; + var alias = data['alias'] as String; + var type = data['type'] as String; + var message = data['message'] as String; + _map.remove(alias); + } +} + +class IsolateScheduler extends IsolateBase { + late String schedulerPath; + late String logsPath; + late String alias; + late File file; + late File logFile; + int fileHash = 0; + + bool logEnable = false; + bool notProgress = true; + bool cronClosed = true; + late DataBuild build; + late String cronExpress; + + Cron? cron; + int nextStartTime = 0; + + IsolateScheduler(Map data) { + alias = data['alias']; + schedulerPath = data['root']; + logsPath = data['logs']; + file = File('$schedulerPath/$alias.yaml'); + } + + @override + void onReady(Object? initData) async { + while(true) { + await Future.delayed(Duration(seconds: 1)); + + if(file.existsSync()) { + var hash = await FileHasher.hash(file); + if(hash != fileHash) { + fileHash = hash; + //파일 내용이 바뀔때 마다 새로 읽어 들임 + var map = Application.getMapFromYamlA(await file.readAsString()); + + //Validate yaml format + try { + build = DataBuild.fromJson(map!); + } on Exception catch(e, stacktace) { + onError('Failed to parse yaml file.'); + logError(e, stacktace); + return; + } + if(build.scheduler == null) { + onError('Scheduler entry does not exist within yaml.'); + return; + } + + try { + Schedule.parse(build.scheduler!.cron); + cronExpress = build.scheduler!.cron; + } on Error catch(e, stacktace) { + onError("Cron express '${build.scheduler!.cron}' is not a suitable format."); + return; + } + } + + if(cronClosed) { + cronClosed = false; + cron = Cron(); + cron!.schedule(Schedule.parse(cronExpress), () async { + if(notProgress) { + await execute(build); + cronClosed = true; + await cron!.close(); + } + }); + } + } else { + //file이 없을시 종료 + onError("The file '${file.path}' does not exist."); + return; + } + } + } + + Future execute(DataBuild data) async { + notProgress = false; + logFile = File('$logsPath/${alias}_${getDate()}_${getTime().replaceAll(':', '.')}.txt'); + log('Execute task: ${DateTime.now().toString()}'); + await Future.delayed(Duration(minutes: 1, seconds: 30)); + notProgress = true; + return simpleFuture; + } + + void log(String message) { + logFile.writeAsStringSync('[${getDates()}] $message\n', mode: FileMode.append, flush: true); + } + + void logError(Exception e, StackTrace stacktace) { + log('[${e.toString()}]\n${stacktace.toString()}'); + } + + void onErrorStacktrace(Exception e, StackTrace stacktrace) { + onError('$e\n$stacktrace'); + } + + void onError(String message) { + sendTerminated('error', message); + } + + void sendTerminated(String type, String message) { + send(IsoMessege('terminated', data: {'type': type, 'message': message, 'alias': alias})); + } +} diff --git a/lib/cli/commands/scheduler/scheduler_osx.dart b/lib/cli/commands/scheduler/scheduler_osx.dart index 73a25f2..56fdf6f 100644 --- a/lib/cli/commands/scheduler/scheduler_osx.dart +++ b/lib/cli/commands/scheduler/scheduler_osx.dart @@ -1,6 +1,5 @@ +import 'package:oto_cli/cli/commands/scheduler/scheduler_manager.dart'; -import 'package:oto_cli/cli/commands/scheduler/scheduler.dart'; - -class SchedulerOsx implements Scheduler { +class SchedulerOsx extends SchedulerManager { } \ No newline at end of file diff --git a/lib/cli/commands/scheduler/scheduler_windows.dart b/lib/cli/commands/scheduler/scheduler_windows.dart index ea771ae..e893c45 100644 --- a/lib/cli/commands/scheduler/scheduler_windows.dart +++ b/lib/cli/commands/scheduler/scheduler_windows.dart @@ -1,6 +1,6 @@ -import 'package:oto_cli/cli/commands/scheduler/scheduler.dart'; +import 'package:oto_cli/cli/commands/scheduler/scheduler_manager.dart'; -class SchedulerWindows implements Scheduler { +class SchedulerWindows extends SchedulerManager { } \ No newline at end of file diff --git a/lib/oto/application.dart b/lib/oto/application.dart index e07ca05..7c4c91d 100644 --- a/lib/oto/application.dart +++ b/lib/oto/application.dart @@ -119,7 +119,7 @@ class Application { return simpleFuture; } - Map? getMapFromYamlA(String yamlStr) { + static Map? getMapFromYamlA(String yamlStr) { Map? map; if (yamlStr.length > 5) { YamlMap data = loadYaml(yamlStr); diff --git a/lib/oto/data/command_data.dart b/lib/oto/data/command_data.dart index aed27a0..e1bfc42 100644 --- a/lib/oto/data/command_data.dart +++ b/lib/oto/data/command_data.dart @@ -14,6 +14,7 @@ part 'command_data.g.dart'; /// ***************************** Base Data ****************************/// @JsonSerializable() class DataBuild { + late DataScheduler? scheduler; late Map? property; late DataPipeline? pipeline; late List commands; @@ -25,6 +26,19 @@ class DataBuild { Map toJson() => _$DataBuildToJson(this); } +@JsonSerializable() +class DataScheduler { + late String alias; + late String cron; + late bool? enableLog; + + DataScheduler(); + + factory DataScheduler.fromJson(Map json) => + _$DataSchedulerFromJson(json); + Map toJson() => _$DataSchedulerToJson(this); +} + @JsonSerializable() class DataCommand { DataCommand(); diff --git a/lib/oto/data/command_data.g.dart b/lib/oto/data/command_data.g.dart index 6ac5a2b..c26e1f2 100644 --- a/lib/oto/data/command_data.g.dart +++ b/lib/oto/data/command_data.g.dart @@ -7,6 +7,9 @@ part of 'command_data.dart'; // ************************************************************************** DataBuild _$DataBuildFromJson(Map json) => DataBuild() + ..scheduler = json['scheduler'] == null + ? null + : DataScheduler.fromJson(json['scheduler'] as Map) ..property = json['property'] as Map? ..pipeline = json['pipeline'] == null ? null @@ -16,11 +19,25 @@ DataBuild _$DataBuildFromJson(Map json) => DataBuild() .toList(); Map _$DataBuildToJson(DataBuild instance) => { + 'scheduler': instance.scheduler, 'property': instance.property, 'pipeline': instance.pipeline, 'commands': instance.commands, }; +DataScheduler _$DataSchedulerFromJson(Map json) => + DataScheduler() + ..alias = json['alias'] as String + ..cron = json['cron'] as String + ..enableLog = json['enableLog'] as bool?; + +Map _$DataSchedulerToJson(DataScheduler instance) => + { + 'alias': instance.alias, + 'cron': instance.cron, + 'enableLog': instance.enableLog, + }; + DataCommand _$DataCommandFromJson(Map json) => DataCommand() ..command = $enumDecode(_$CommandTypeEnumMap, json['command']) ..id = json['id'] as String diff --git a/pubspec.yaml b/pubspec.yaml index 9957913..a23bd68 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: ref: master color: ^3.0.0 file_hasher: ^0.1.0+0 + cron: ^0.6.1 dev_dependencies: lints: ^5.0.0