diff --git a/cli/cli/App.cs b/cli/cli/App.cs index 05dcc213c5..ce952d1aad 100644 --- a/cli/cli/App.cs +++ b/cli/cli/App.cs @@ -50,6 +50,7 @@ using System.Reflection; using Beamable.Server.Common; using Beamable.Tooling.Common; +using cli.BackendCommands; using cli.CheckCommands; using cli.Commands.OtelCommands.Grafana; using cli.Commands.Project.Logs; @@ -430,6 +431,7 @@ public virtual void Configure( Commands.AddSingleton(NoLogFileOption.Instance); Commands.AddSingleton(UnmaskLogsOption.Instance); Commands.AddSingleton(DockerPathOption.Instance); + Commands.AddSingleton(JavaPathOption.Instance); Commands.AddSingleton(provider => { var root = new RootCommand(); @@ -448,6 +450,7 @@ public virtual void Configure( root.AddGlobalOption(UnmaskLogsOption.Instance); root.AddGlobalOption(NoLogFileOption.Instance); root.AddGlobalOption(DockerPathOption.Instance); + root.AddGlobalOption(JavaPathOption.Instance); root.AddGlobalOption(EmitLogsOption.Instance); root.AddGlobalOption(ExtraProjectPathOptions.Instance); root.AddGlobalOption(provider.GetRequiredService()); @@ -668,6 +671,19 @@ public virtual void Configure( Commands.AddSubCommandWithHandler(); Commands.AddSubCommandWithHandler(); + Commands.AddRootCommand(); + Commands.AddSubCommandWithHandler(); + Commands.AddSubCommandWithHandler(); + Commands.AddSubCommandWithHandler(); + Commands.AddSubCommandWithHandler(); + Commands.AddSubCommandWithHandler(); + Commands.AddSubCommandWithHandler(); + Commands.AddSubCommandWithHandler(); + Commands.AddSubCommandWithHandler(); + Commands.AddSubCommandWithHandler(); + Commands.AddSubCommandWithHandler(); + + Commands.AddRootCommand(); Commands.AddSubCommandWithHandler(); Commands.AddSubCommandWithHandler(); diff --git a/cli/cli/CommandContext.cs b/cli/cli/CommandContext.cs index fcd6ff80f8..55d831f452 100644 --- a/cli/cli/CommandContext.cs +++ b/cli/cli/CommandContext.cs @@ -174,14 +174,7 @@ public override async Task Handle(TArgs args) } } - protected virtual void LogResult(object result) - { - var json = JsonConvert.SerializeObject(result, UnitySerializationSettings.Instance); - AnsiConsole.Write( - new Panel(new JsonText(json)) - .Collapse() - .NoBorder()); - } + public abstract Task GetResult(TArgs args); public Type ResultType => typeof(TResult); @@ -349,7 +342,15 @@ protected AppCommand(string name, string description = null) : base(name) } public override string Description { get => IAppCommand.GetModifiedDescription(IsForInternalUse, _description); set => _description = value; } - + + protected virtual void LogResult(object result) + { + var json = JsonConvert.SerializeObject(result, UnitySerializationSettings.Instance); + AnsiConsole.Write( + new Panel(new JsonText(json)) + .Collapse() + .NoBorder()); + } protected Argument AddArgument(Argument arg, Action binder) { diff --git a/cli/cli/Commands/BackendCommands/AuditRealmConfig.cs b/cli/cli/Commands/BackendCommands/AuditRealmConfig.cs new file mode 100644 index 0000000000..691e621582 --- /dev/null +++ b/cli/cli/Commands/BackendCommands/AuditRealmConfig.cs @@ -0,0 +1,186 @@ +using System.CodeDom.Compiler; +using System.Globalization; +using System.Text.RegularExpressions; +using Csv; + +namespace cli.BackendCommands; + +public class AuditRealmConfigCommandArgs : CommandArgs +{ + public string backendHome; + +} + +public class RealmConfigUsage +{ + public string projectName; + public string filePath; + public int index; + public int line; + public int column; + public string configNamespaceExpression; + public string configKeyExpression; + public string configDefaultExpression; + public string configMethodExpression; +} + +public partial class AuditRealmConfigCommand : AppCommand +{ + [GeneratedRegex("\\.getConfig(.*)\\((.*?),(.*?),(.*?)\\)")] + public static partial Regex ConfigRegex(); + + + + public AuditRealmConfigCommand() : base("audit-realm-config", "Get realm config data") + { + } + + public override void Configure() + { + BackendCommandGroup.AddBackendHomeOption(this, (args, i) => args.backendHome = i); + + } + + public override async Task Handle(AuditRealmConfigCommandArgs args) + { + BackendCommandGroup.ValidateBackendHomeDirectoryExists(args.backendHome); + var list = BackendListToolsCommand.GatherToolList(args.backendHome); + + var coreUsageTask = FindScalaFiles("core", list.coreProjectPath); + var toolUsageTasks = list.tools.Select(t => FindScalaFiles(t.name, t.projectPath)); + + var usages = new List(); + usages.AddRange(await coreUsageTask); + + foreach (var t in toolUsageTasks) + { + usages.AddRange(await t); + } + + var missingNamespace = usages.Where(u => u.configNamespaceExpression.StartsWith("**")).ToList(); + var missingKey = usages.Where(u => u.configKeyExpression.StartsWith("**")).ToList(); + var missingDefault = usages.Where(u => u.configDefaultExpression.StartsWith("**")).ToList(); + + + var columns = new string[] + { + "project", "namespace", "key", "path", "line", "column", "type", "default", "isAlarming" + }; + + var rows = new List(); + foreach (var usage in usages) + { + var isAlarming = usage.configNamespaceExpression.StartsWith("**") + || usage.configKeyExpression.StartsWith("**") + || usage.configDefaultExpression.StartsWith("**"); + rows.Add(new string[] + { + usage.projectName, usage.configNamespaceExpression, usage.configKeyExpression, + usage.filePath.Substring(args.backendHome.Length), + usage.line.ToString(), + usage.column.ToString(), + usage.configMethodExpression, + usage.configDefaultExpression, + isAlarming.ToString() + }); + } + + var csv = CsvWriter.WriteToText(columns, rows); + File.WriteAllText("realmConfigs.csv", csv); + } + + public static async Task> FindScalaFiles(string project, string directory) + { + var output = new List(); + + var tasks = Directory.EnumerateFiles(directory, "*.scala", SearchOption.AllDirectories) + .Select(path => (path, File.ReadAllTextAsync(path))); + + foreach (var (path, task) in tasks) + { + var content = await task; + + var lineNumberIndexes = new List(); + for (var i = 0; i < content.Length; i++) + { + var newLineIndex = content.IndexOf("\n", i, StringComparison.Ordinal); + if (newLineIndex < 0) break; + lineNumberIndexes.Add(newLineIndex); + i = newLineIndex; + } + // + + var matches= ConfigRegex().Matches(content); + var usages = new List(); + for (var i = 0; i < matches.Count; i++) + { + var match = matches[i]; + + var line = 0; + for (var j = 1; j < lineNumberIndexes.Count; j++) + { + if (lineNumberIndexes[j] > match.Index) + { + line = j; + break; + } + } + + usages.Add(new RealmConfigUsage + { + projectName = project, + index = match.Index, + line = line + 1, + column = match.Index - lineNumberIndexes[line - 1], + filePath = path, + configMethodExpression = match.Groups[1].Value.Trim(), + configNamespaceExpression = Resolve("namespace", match.Groups[2].Value.Trim()), + configKeyExpression = Resolve("key", match.Groups[3].Value.Trim()), + configDefaultExpression = Resolve("default", match.Groups[4].Value.Trim()), + }); + + string Resolve(string variable, string expression) + { + try + { + if (expression.StartsWith("\"")) return expression; // string literal is good! + + if (bool.TryParse(expression, out _)) return expression; + if (int.TryParse(expression, out _)) return expression; + if (double.TryParse(expression, out _)) return expression; + + // maybe the variable was used + var namedRegex = new Regex(variable + "\\s+(:\\s+.*)?(:|=)(.*)"); + var namedMatch = namedRegex.Match(expression); + if (namedMatch.Success) + { + return Resolve(variable, namedMatch.Groups[3].Value.Trim()); + } + + // uh oh, we don't know what this is... + // 1. maybe its in the current file? + var exprRegex = new Regex(expression + "\\s+=\\s+(.*)"); + var maybeMatch = exprRegex.Match(content); + if (maybeMatch.Success) + { + return Resolve(variable, maybeMatch.Groups[1].Value.Trim()); + } + + return "**" + expression; + } + catch + { + return "**" + expression; + } + } + } + + lock (output) + { + output.AddRange(usages); + } + } + + return output; + } +} \ No newline at end of file diff --git a/cli/cli/Commands/BackendCommands/BackendCommandGroup.cs b/cli/cli/Commands/BackendCommands/BackendCommandGroup.cs new file mode 100644 index 0000000000..1e52503a6c --- /dev/null +++ b/cli/cli/Commands/BackendCommands/BackendCommandGroup.cs @@ -0,0 +1,140 @@ +using System.CommandLine; +using System.Text; +using Beamable.Server; +using JetBrains.Annotations; + +namespace cli.BackendCommands; + +public interface IBackendCommandArgs +{ + public string BackendHome { get; } +} + +public class BackendCommandGroup : CommandGroup +{ + public override bool IsForInternalUse => true; + + public BackendCommandGroup() : base("local", "commands for managing local backend development") + { + } + + public static void AddBackendRepoOption(AppCommand command, Action binder) + where TArgs : CommandArgs + { + command.AddOption(BackendRepoOption.Instance, binder); + } + public static void AddBackendHomeOption(AppCommand command, Action binder) + where TArgs : CommandArgs + { + command.AddOption(BackendHomeOption.Instance, (args, i) => + { + var absPath = Path.GetFullPath(i); + binder(args, absPath); + }); + } + + public static void ValidateBackendHomeDirectoryExists(string backendHomeDir) + { + if (!TryValidateBackendHomeDirectoryExists(backendHomeDir, out var message)) + { + throw new CliException($"{message}\n" + + $"Pass the --backend-home option, or navigate to the backend directory.\n" + + $"path=[{backendHomeDir}]"); + } + } + public static bool TryValidateBackendHomeDirectoryExists(string backendHomeDir, out string message) + { + message = null; + if (!Directory.Exists(backendHomeDir)) + { + message = "The backend home directory does not exist. "; + return false; + } + + var dirs = Directory.GetDirectories(backendHomeDir).Select(Path.GetFileName).ToList(); + var expectedDirs = new string[] + { + "core", + "bin", + "tools", + }; + + var files = Directory.GetFiles(backendHomeDir).Select(Path.GetFileName).ToList(); + var expectedFiles = new string[] + { + "pom.xml", + "build.xml" + }; + + var missingDirs = expectedDirs.Except(dirs).ToList(); + var missingFiles = expectedFiles.Except(files).ToList(); + + if (missingDirs.Count > 0 || missingFiles.Count > 0) + { + message = "The backend home directory does not look like a valid Beamable backend scala folder. "; + return false; + } + + return true; + } + + +} + + + +public class BackendHomeOption : Option +{ + public static BackendHomeOption Instance { get; } = new BackendHomeOption(); + + private BackendHomeOption() : base( + aliases: new string[]{"--backend-home"}, + description: $"The path to the local checkout of the Beamable Backend codebase. ", + getDefaultValue: GetDefaultValue) + { + + } + + public static string GetDefaultValue() + { + var thisFolder = Path.GetFullPath("."); + var current = thisFolder; + + while (!BackendCommandGroup.TryValidateBackendHomeDirectoryExists(current, out _)) + { + current = Path.GetDirectoryName(current); + if (current == null) + { + return thisFolder; + } + } + + return Path.GetFullPath(current); + } +} + + +public class BackendRepoOption : Option +{ + public const string ENV_VAR = "BEAM_BACKEND_REPO"; + + public static BackendRepoOption Instance { get; } = new BackendRepoOption(); + + private BackendRepoOption() : base( + aliases: new string[]{"--backend-repo"}, + description: $"The github repository for your Beamable backend. Control the default value with the {ENV_VAR} environment variable.", + getDefaultValue: GetDefaultValue) + { + + } + + public static string GetDefaultValue() + { + var env = Environment.GetEnvironmentVariable(ENV_VAR); + if (!string.IsNullOrEmpty(env)) + { + return env; + } + return "beamable/BeamableBackend"; + } +} diff --git a/cli/cli/Commands/BackendCommands/BackendCompileCommand.cs b/cli/cli/Commands/BackendCommands/BackendCompileCommand.cs new file mode 100644 index 0000000000..307957f9fc --- /dev/null +++ b/cli/cli/Commands/BackendCommands/BackendCompileCommand.cs @@ -0,0 +1,64 @@ +using CliWrap; + +namespace cli.BackendCommands; + +public class BackendCompileCommandArgs : CommandArgs +{ + public string backendHome; + +} + +public class BackendCompileCommand + : AppCommand + , ISkipManifest + ,IStandaloneCommand +{ + public BackendCompileCommand() : base("compile", "Compile a scala project") + { + } + + public override void Configure() + { + BackendCommandGroup.AddBackendHomeOption(this, (args, i) => args.backendHome = i); + } + + public override async Task Handle(BackendCompileCommandArgs args) + { + BackendCommandGroup.ValidateBackendHomeDirectoryExists(args.backendHome); + await CompileProject(args.backendHome); + } + + + public static async Task CompileProject(string projectPath) + { + await JavaUtility.RunMaven("install -DskipTests=true", projectPath); + } + + public static bool CheckSourceAndTargets(string projectPath, out DateTimeOffset latestSourceTime) + { + var srcFolder = Path.Combine(projectPath, "src"); + var targetFolder = Path.Combine(projectPath, "target", "classes"); + + latestSourceTime = GetLatestFileWriteTimeFromFolder(srcFolder); + var latestBuildTime = GetLatestFileWriteTimeFromFolder(targetFolder); + + if (latestSourceTime > latestBuildTime) + { + // likely need to re-build. + // note, this won't care if a file is the same content as before; but its better than nothing. + return true; + } + + return false; + } + + public static DateTimeOffset GetLatestFileWriteTimeFromFolder(string folder) + { + var latestWriteTime = Directory + .EnumerateFiles(folder, "*", SearchOption.AllDirectories) + .Select(File.GetLastWriteTimeUtc) + .DefaultIfEmpty(DateTime.MinValue) + .Max(); + return new DateTimeOffset(latestWriteTime); + } +} \ No newline at end of file diff --git a/cli/cli/Commands/BackendCommands/BackendInspectToolCommand.cs b/cli/cli/Commands/BackendCommands/BackendInspectToolCommand.cs new file mode 100644 index 0000000000..e584ec9ee9 --- /dev/null +++ b/cli/cli/Commands/BackendCommands/BackendInspectToolCommand.cs @@ -0,0 +1,151 @@ +using System.CommandLine; +using Beamable.Common.BeamCli; +using Beamable.Server; + +namespace cli.BackendCommands; + +public class BackendInspectToolCommandArgs : CommandArgs, IBackendCommandArgs +{ + public string searchTerm; + public string backendHome; + + public string BackendHome => backendHome; +} + +public class BackendInspectToolResults +{ + public BackendToolInfo tool; + public List instances = new List(); +} +public class BackendInspectInfraResults +{ + public BackendInfraInfo infra; + public List containers = new List(); +} + +public class BackendInspectResults +{ + public BackendInspectToolResults tool; + public BackendInspectInfraResults infra; +} + +public class BackendInspectToolResultChannel : IResultChannel +{ + public string ChannelName => "toolResult"; +} +public class BackendInspectInfraResultChannel : IResultChannel +{ + public string ChannelName => "infraResult"; +} + +public class BackendInspectToolCommand + : AppCommand + , ISkipManifest + , IStandaloneCommand + , IResultSteam + , IResultSteam +{ + public BackendInspectToolCommand() : base("inspect", "inspect a backend component") + { + } + + public override void Configure() + { + AddArgument( + new Argument("component", "the process id, container id, or name of the component to inspect"), + (args, i) => args.searchTerm = i); + BackendCommandGroup.AddBackendHomeOption(this, (args, i) => args.backendHome = i); + + } + + public override async Task Handle(BackendInspectToolCommandArgs args) + { + BackendCommandGroup.ValidateBackendHomeDirectoryExists(args.backendHome); + var list = BackendListToolsCommand.GatherToolList(args.backendHome); + + var res = await Inspect(list, args, args.searchTerm); + + if (res.tool != null) + { + this.LogResult(res.tool); + this.SendResults(res.tool); + } + if (res.infra != null) + { + this.LogResult(res.infra); + this.SendResults(res.infra); + } + + if (res.tool == null && res.infra == null) + { + Log.Information("No component found. "); + } + } + + public static async Task Inspect(BackendToolList list, CommandArgs args, string term) + { + var results = new BackendInspectResults(); + var matchedInfra = list.infra.FirstOrDefault(i => i.name == term); + BackendPsInfraStatus infraStatus = null; + + // first try to match by infra name. + if (matchedInfra != null) + { + infraStatus = await BackendPsCommand.CheckInfraStatus(list, args); + var containers = infraStatus.coreServices.Where(t => t.service == matchedInfra.name).ToList(); + results.infra = new BackendInspectInfraResults + { + infra = matchedInfra, + containers = containers + }; + return results; + } + + // then, assume its likely a tool, and get the tool status + var toolStatus = await BackendPsCommand.CheckToolStatus(list, args); + + // try matching by the tool name + var matchedTool = list.tools.FirstOrDefault(i => i.name == term); + if (matchedTool != null) + { + var instances = toolStatus.tools.Where(t => t.toolName == term).ToList(); + results.tool = new BackendInspectToolResults + { + tool = matchedTool, + instances = instances + }; + return results; + } + + // and try matching by the tool process id + var matchedStatus = toolStatus.tools.FirstOrDefault(t => t.processId.ToString() == term); + if (matchedStatus != null) + { + matchedTool = list.tools.FirstOrDefault(t => t.name == matchedStatus.toolName); + results.tool = new BackendInspectToolResults + { + tool = matchedTool, + instances = new List { matchedStatus } + }; + return results; + } + + // and if those didn't work, the last case is a container id, so get the container status + infraStatus = await BackendPsCommand.CheckInfraStatus(list, args); + var matchedInfraStatus = infraStatus.coreServices.FirstOrDefault(c => c.containerId.StartsWith(term)); + if (matchedInfraStatus != null) + { + results.infra = new BackendInspectInfraResults + { + infra = list.infra.FirstOrDefault(i => i.name == matchedInfraStatus.service), + containers = new List{matchedInfraStatus} + }; + return results; + } + + // otherwise there is nothing :( + + return results; + } + +} \ No newline at end of file diff --git a/cli/cli/Commands/BackendCommands/BackendListToolsCommand.cs b/cli/cli/Commands/BackendCommands/BackendListToolsCommand.cs new file mode 100644 index 0000000000..2fb19d0571 --- /dev/null +++ b/cli/cli/Commands/BackendCommands/BackendListToolsCommand.cs @@ -0,0 +1,204 @@ +using System.Diagnostics; +using Beamable.Server; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace cli.BackendCommands; + +public class BackendListToolsCommandArgs : CommandArgs +{ + public string backendHome; + +} + +public class BackendListToolsCommandResults +{ + public BackendToolList toolList = new BackendToolList(); +} + +public class BackendToolList +{ + public string coreProjectPath; + public List infra = new List(); + public List tools = new List(); + public List invalidFolders = new List(); +} + +public class BackendInfraInfo +{ + public string name; + public string[] dependsOn = Array.Empty(); +} + +[DebuggerDisplay("tool=[{name}]")] +public class BackendToolInfo +{ + public string name; + public string projectPath; + public string mainClassName; + public string[] profiles = Array.Empty(); + public string[] dependsOn = Array.Empty(); + public string[] basicServiceNames = Array.Empty(); + public string[] objectSerivceNames = Array.Empty(); +} + +public class BackendListToolsCommand + : AtomicCommand +, ISkipManifest, IStandaloneCommand +{ + public BackendListToolsCommand() : base("list-tools", "List all the available tools in the backend source") + { + } + + public override void Configure() + { + BackendCommandGroup.AddBackendHomeOption(this, (args, i) => args.backendHome = i); + } + + public override Task GetResult(BackendListToolsCommandArgs args) + { + return Task.FromResult(new BackendListToolsCommandResults + { + toolList = GatherToolList(args.backendHome) + }); + + } + + public static DockerComposeModel GetLocalDockerComposeInfo(string backendHome) + { + BackendCommandGroup.ValidateBackendHomeDirectoryExists(backendHome); + + var deserializer = new DeserializerBuilder() + .IgnoreUnmatchedProperties() + .WithNamingConvention(UnderscoredNamingConvention.Instance) // see height_in_inches in sample yml + .Build(); + const string localDockerComposeFile = "docker/local/docker-compose.yml"; + var localDockerComposePath = Path.Combine(backendHome, localDockerComposeFile); + var yml = File.ReadAllText(localDockerComposePath); + var p = deserializer.Deserialize(yml); + return p; + } + + + + public static BackendToolList GatherToolList(string backendHome) + { + BackendCommandGroup.ValidateBackendHomeDirectoryExists(backendHome); + + var result = new BackendToolList + { + coreProjectPath = Path.Combine(backendHome, "core") + }; + + var dockerComposeInfo = GetLocalDockerComposeInfo(backendHome); + + foreach (var (name, service) in dockerComposeInfo.services) + { + if (service.labels.ContainsKey(BackendPsCommand.BEAMABLE_LABEL)) // "core" means "infra" + { + result.infra.Add(new BackendInfraInfo + { + name = name, + dependsOn = service.dependsOn + }); + } + } + + var toolsDir = Path.Combine(backendHome, "tools"); + var toolFolders = Directory.GetDirectories(toolsDir); + foreach (var toolFolder in toolFolders) + { + var tool = Path.GetFileName(toolFolder); + Log.Debug($"inspecting tool folder=[{tool}]"); + + var srcFiles = Directory.EnumerateFiles(toolFolder, "*.scala", SearchOption.AllDirectories); + var foundEntryPoint = false; + foreach (var srcFile in srcFiles) + { + using var stream = File.OpenRead(srcFile); + using var reader = new StreamReader(stream); + var package = ""; + while (!reader.EndOfStream) + { + var line = reader.ReadLine(); + + if (line.StartsWith("package ")) + { + package = line.Substring("package ".Length); // maybe there is a comment on the end of the line? + continue; + } + var indexOfObject = line.IndexOf("object"); + var indexOfExtends = line.IndexOf("extends MicroService"); + + var isServiceClass = indexOfObject > -1 && indexOfExtends > -1; + if (!isServiceClass) continue; + + var startIndex = indexOfObject + "object ".Length; + var className = line.Substring(startIndex, (indexOfExtends - startIndex) - 1); + + var toolInfo = new BackendToolInfo + { + name = tool, + projectPath = toolFolder, + mainClassName = package + "." + className + }; + if (!dockerComposeInfo.services.TryGetValue(tool, out var service)) + { + Log.Debug($"Found a tool=[{tool}] that was not listed in the docker-compose file"); + } + else + { + toolInfo.profiles = service.profiles; + toolInfo.dependsOn = service.dependsOn; + + + if (service.services?.TryGetValue("basic", out var basic) ?? false) + { + toolInfo.basicServiceNames = basic ?? new string[] { tool }; + } + if (service.services?.TryGetValue("object", out var objects) ?? false) + { + toolInfo.objectSerivceNames = objects ?? new string[] { tool }; + } + + // var explicitBasicServices = service.ExplicitBasicServices; + // var explicitObjectServices = service.ExplicitObjectServices; + // if (explicitBasicServices != null) + // { + // toolInfo.basicServiceNames = explicitBasicServices; + // if (toolInfo.basicServiceNames.Length == 0) + // { + // toolInfo.basicServiceNames = new string[] { tool }; + // } + // } + // + // if (explicitObjectServices != null) + // { + // toolInfo.objectSerivceNames = explicitObjectServices; + // if (toolInfo.objectSerivceNames.Length == 0) + // { + // toolInfo.objectSerivceNames = new string[] { tool }; + // } + // } + } + + result.tools.Add(toolInfo); + foundEntryPoint = true; + break; + } + + if (foundEntryPoint) + { + break; + } + } + + if (!foundEntryPoint) + { + result.invalidFolders.Add(toolFolder); + } + } + + return result; + } +} \ No newline at end of file diff --git a/cli/cli/Commands/BackendCommands/BackendLogCommand.cs b/cli/cli/Commands/BackendCommands/BackendLogCommand.cs new file mode 100644 index 0000000000..607277a911 --- /dev/null +++ b/cli/cli/Commands/BackendCommands/BackendLogCommand.cs @@ -0,0 +1,154 @@ +using System.CommandLine; +using System.Diagnostics; +using System.Net; +using System.Text; +using Beamable.Server; +using Spectre.Console; + +namespace cli.BackendCommands; + +public class BackendLogCommandArgs : CommandArgs, IBackendCommandArgs +{ + public string toolName; + public bool watch; + public int linesBack; + public string backendHome; + public string BackendHome => backendHome; +} + +public class BackendLogCommand + : AppCommand + , ISkipManifest + , IStandaloneCommand +{ + public BackendLogCommand() : base("logs", "read logs for a tool started with the cli") + { + } + + public override void Configure() + { + AddOption(new Option(new string[] { "--tool", "-t" }, "the name of the tool to read logs for"), + (args, i) => args.toolName = i); + AddOption(new Option(new string[] { "--watch", "-w" }, "should the logs watch the file"), + (args, i) => args.watch = i); + AddOption(new Option(new string[] { "--lines", "-l" }, () => 20, "the number of lines to read backwards in the log file"), + (args, i) => args.linesBack = i); + + BackendCommandGroup.AddBackendHomeOption(this, (args, i) => args.backendHome = i); + } + + public override async Task Handle(BackendLogCommandArgs args) + { + BackendCommandGroup.ValidateBackendHomeDirectoryExists(args.backendHome); + + var list = BackendListToolsCommand.GatherToolList(args.backendHome); + + + var tool = list.tools.FirstOrDefault(t => t.name == args.toolName); + if (tool == null) throw new CliException($"Unknown tool=[{args.toolName}]"); + + var toolStatus = await BackendPsCommand.CheckToolStatus(list, args); + + var matchingTools = toolStatus.tools + .Where(t => t.toolName == tool.name) + .DistinctBy(t => t.processId) + .ToList(); + + if (matchingTools.Count > 1) + { + throw new NotImplementedException("there are too many instances running, not sure which one to pick"); + } + + var matchingTool = matchingTools[0]; + if (string.IsNullOrEmpty(matchingTool.stdOutPath)) + { + Log.Warning("The instance does not have a configured std-out buffer location. Maybe it was run from the IDE?"); + return; + } + + var stdOut = TailLogs(tool, matchingTool.stdOutPath, args.linesBack, args.watch, args, Log.Information); + var stdErr = TailLogs(tool, matchingTool.stdErrPath, args.linesBack, args.watch, args, Log.Error); + + await stdOut; + } + + // chat-gpt wrote this for me. + static void SeekToLastNLines(FileStream fs, int n, int bufferSize = 4096) + { + byte[] newlineBytes = Encoding.UTF8.GetBytes(Environment.NewLine); + int nlLen = newlineBytes.Length; + + byte[] buffer = new byte[bufferSize + nlLen]; // pad for overlap check + long filePos = fs.Length; + int newLinesFound = 0; + + while (filePos > 0 && newLinesFound <= n) + { + int toRead = (int)Math.Min(bufferSize, filePos); + filePos -= toRead; + fs.Seek(filePos, SeekOrigin.Begin); + + // read a block + overlap to handle newline sequences crossing boundary + int read = fs.Read(buffer, 0, toRead + (nlLen - 1)); + + for (int i = read - nlLen; i >= 0; i--) + { + bool isNewline = true; + for (int j = 0; j < nlLen; j++) + { + if (buffer[i + j] != newlineBytes[j]) + { + isNewline = false; + break; + } + } + + if (isNewline) + { + newLinesFound++; + if (newLinesFound > n) + { + fs.Seek(filePos + i + nlLen, SeekOrigin.Begin); + return; + } + } + } + } + + // If file has fewer than n lines, rewind to start + fs.Seek(0, SeekOrigin.Begin); + } + + public static async Task TailLogs(BackendToolInfo tool, string logPath, int lines, bool watch, CommandArgs args, Action onLine) + { + while (watch && !File.Exists(logPath) && !args.Lifecycle.IsCancelled) + { + // wait for the file to exist. + await Task.Delay(TimeSpan.FromMilliseconds(250)); + } + + if (!File.Exists(logPath)) + { + return; + } + + using var stream = File.OpenRead(logPath); + SeekToLastNLines(stream, lines); + using var reader = new StreamReader(stream); + + while (!args.Lifecycle.IsCancelled) + { + if (reader.EndOfStream) + { + if (!watch) + { + return; + } + await Task.Delay(TimeSpan.FromMilliseconds(250)); + continue; + } + var line = await reader.ReadLineAsync(); + onLine?.Invoke(line); + } + } +} \ No newline at end of file diff --git a/cli/cli/Commands/BackendCommands/BackendMongoUtil.cs b/cli/cli/Commands/BackendCommands/BackendMongoUtil.cs new file mode 100644 index 0000000000..7537e0fcd8 --- /dev/null +++ b/cli/cli/Commands/BackendCommands/BackendMongoUtil.cs @@ -0,0 +1,212 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.NetworkInformation; +using System.Text.Json; +using Beamable.Common; +using Beamable.Server; +using Hocon; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Driver; + +namespace cli.BackendCommands; + +public static class BackendMongoUtil +{ + private static BackendMongo _mongoSingleton; + + public static BackendMongo GetMongo(BackendToolList list) + { + if (_mongoSingleton != null) return _mongoSingleton; + var dbFlake = list.tools.FirstOrDefault(d => d.name == "dbflake"); + if (dbFlake == null) + throw new CliException( + "To connect to mongo, the cli is trying to find connection strings from dbflakes .conf files, but no dbflake was found."); + var conf = HoconConfigurationFactory.FromFile(Path.Combine(dbFlake.projectPath, "src", "main", "resources", + "server.conf")); + var userName = conf.GetString("mongodb.default.username"); + var password = conf.GetString("mongodb.default.password"); + + var host = conf.GetString("mongodb.master.host"); + var port = conf.GetString("mongodb.master.port"); + _mongoSingleton = Connect(userName, password, host, port); + return _mongoSingleton; + } + + public static async Task RunWatching( + this IMongoCollection collection, + CancellationToken ct, + Action> changeHandler) + { + try + { + using var changeStream = await collection.WatchAsync(cancellationToken: ct); + while (await changeStream.MoveNextAsync(ct)) + { + foreach (var change in changeStream.Current) + { + changeHandler(change); + } + } + } + catch (OperationCanceledException) + { + // its okay to let a cancellation happen. + } + catch (Exception ex) + { + Log.Fatal("Unknown error happened during mongo watch. " + ex.GetType().Name); + } + } + + public static BackendMongo Connect(string mongoUser, string mongoPassword, string host, string port) + { + var connStr = $"mongodb://{mongoUser}:{WebUtility.UrlEncode(mongoPassword)}@{host}:{port}/?directConnection=true"; + var clientSettings = MongoClientSettings.FromConnectionString(connStr); + clientSettings.ConnectTimeout = TimeSpan.FromMilliseconds(250); + clientSettings.ServerSelectionTimeout = TimeSpan.FromMilliseconds(250); // how long driver waits to find a suitable server + // clientSettings.SocketTimeout = TimeSpan.FromMilliseconds(250); // how long operations wait on socket reads/writes + var client = new MongoClient(clientSettings); + return new BackendMongo + { + Client = client + }; + } + + public static IMongoCollection GetTopologyCollection(this BackendMongo mongo) + { + var db = mongo.Client.GetDatabase(mongo.WizardMasterDatabaseName); + return db.GetCollection(mongo.ServiceTopologyCollectionName); + } + + public static async Task IsAvailable(this BackendMongo mongo) + { + try + { + + var db = mongo.Client.GetDatabase("admin"); + var command = new BsonDocument("ping", 1); + await db.RunCommandAsync(command); // lightweight check + return true; + } + catch (Exception ex) + { + return false; + } + } + + public static async Task> GetTopologies(this BackendMongo mongo) + { + if (!await mongo.IsAvailable()) + { + return new List(); + } + var coll = GetTopologyCollection(mongo); + var cursor = await coll.FindAsync(FilterDefinition.Empty); + var entries = await cursor.ToListAsync(); + + var entryCache = new Dictionary(); + + var httpClient = new HttpClient(); + foreach (var entry in entries) + { + await Ping(entry); + } + + async Task Ping(BackendTopologyEntry entry) + { + + var host = $"http://{entry.binding.host}:{entry.binding.port}"; + + lock (entryCache) + { + if (entryCache.TryGetValue(host, out entry.debugInfo)) + { + entry.respondedToDebugEndpoint = true; + return; + } + } + + var req = new HttpRequestMessage(HttpMethod.Get, host); + + req.Headers.Add("x-local-dbg", "1"); + try + { + + var res = await httpClient.SendAsync(req); + if (res.StatusCode != HttpStatusCode.OK) + { + entry.debugInfo = new BackendTopologyDebugInfo + { + processId = -1, + }; + return; + } + + var dbgJson = await res.Content.ReadAsStringAsync(); + entry.debugInfo = JsonSerializer.Deserialize(dbgJson, + new JsonSerializerOptions + { + IncludeFields = true + }); + entry.respondedToDebugEndpoint = true; + + lock (entryCache) + { + entryCache[host] = entry.debugInfo; + } + } + catch (Exception ex) + { + entry.debugInfo = new BackendTopologyDebugInfo + { + processId = -1, + }; + return; + } + } + + return entries; + } +} + +public class BackendTopologyDebugInfo +{ + public int processId; + public string stdOutRedirection; + public string stdErrRedirection; +} + +public class BackendTopologyEntry +{ + public ObjectId id; + public string service; + public string instance; + public BackendTopologyBindingEntry binding; + + public BackendTopologyDebugInfo debugInfo; + public bool respondedToDebugEndpoint; + + [BsonExtraElements] + public BsonDocument ExtraElements { get; set; } + + public string ServiceName => service.Split('.')[0]; + public string ServiceType => service.Split('.')[1]; + + public int ProcessId => debugInfo?.processId ?? -1; + public string StandardOutRedirection => debugInfo?.stdOutRedirection; + public string StandardErrRedirection => debugInfo?.stdErrRedirection; +} + +public class BackendTopologyBindingEntry +{ + public string host; + public int port; +} + +public class BackendMongo +{ + public MongoClient Client { get; set; } + public string WizardMasterDatabaseName => "wizard_master"; // TODO pull from env? + public string ServiceTopologyCollectionName => "service_topology"; +} \ No newline at end of file diff --git a/cli/cli/Commands/BackendCommands/BackendPsCommand.cs b/cli/cli/Commands/BackendCommands/BackendPsCommand.cs new file mode 100644 index 0000000000..9699eb5d62 --- /dev/null +++ b/cli/cli/Commands/BackendCommands/BackendPsCommand.cs @@ -0,0 +1,564 @@ +using System.CommandLine; +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using Beamable.Api.Autogenerated.Models; +using Beamable.Server; +using cli.DockerCommands; +using cli.Utils; +using Docker.DotNet.Models; +using Hocon; +using MongoDB.Driver; +using Spectre.Console; +using UnityEngine; +using Message = Docker.DotNet.Models.Message; + +namespace cli.BackendCommands; + +public class BackendPsCommandArgs : CommandArgs +{ + public bool watch; + public string backendHome; +} + +public class BackendPsCommandResults +{ + public BackendPsInfraStatus Infra; + public BackendPsToolStatus Tools; +} + +public class BackendPsInfraStatus +{ + public List coreServices = new List(); + + public bool AllRunning => coreServices.All(c => c.isRunning); +} + +public class BackendPsToolStatus +{ + public List tools = new List(); + + public string GetMeaningfulChecksum() + { + var sb = new StringBuilder(); + foreach (var tool in tools) + { + sb.Append(tool.processId); + sb.Append(tool.toolName); + sb.Append(tool.respondedToDebugEndpoint); + } + return BeamoExtensions.GetHash(sb.ToString()); + } +} + +public class BackendDockerContainerInfo +{ + public string service; + public string containerId; + public bool isRunning; +} + +[DebuggerDisplay("{serviceType}/{toolName} running=[{isRunning}]")] +public class BackendToolRuntimeInfo +{ + public bool isEssential; + public bool isRunning; + public string toolName; + public string serviceName; + public string serviceType; + public int processId; + public string stdOutPath; + public string stdErrPath; + public bool respondedToDebugEndpoint; + public string host; + public int port; +} + +public class BackendToolProcessInfo +{ + public string toolName; + public int processId; + public string mainClass; + public string stdOutPath; + public string stdErrPath; +} + +public class BackendPsCommand + : AppCommand + , IResultSteam + , ISkipManifest + , IStandaloneCommand +{ + public BackendPsCommand() : base("ps", "Get the current local state") + { + } + + public override void Configure() + { + AddOption(new Option(new string[] { "--watch", "-w" }, "Listen for changes to the state"), + (args, i) => args.watch = i); + + BackendCommandGroup.AddBackendHomeOption(this, (args, i) => args.backendHome = i); + } + + public override async Task Handle(BackendPsCommandArgs args) + { + await DockerStatusCommand.RequireDocker(args); + BackendCommandGroup.ValidateBackendHomeDirectoryExists(args.backendHome); + var list = BackendListToolsCommand.GatherToolList(args.backendHome); + + var lockKey = new object(); + var latestInfraStatus = await CheckInfraStatus(list, args); + var latestToolStatus = await CheckToolStatus(list, args); + + var action = new Debouncer(TimeSpan.FromMicroseconds(50), () => + { + lock (lockKey) + { + Report(); + } + }); + + if (args.watch) + { + var docker = ListenForLocalDocker(list, args, args.Lifecycle.CancellationToken, infraStatus => + { + lock (lockKey) + { + latestInfraStatus = infraStatus; + } + action.Signal(); + }); + var mongo = ListenForLocalBindings(list, args, args.Lifecycle.CancellationToken, toolStatus => + { + lock (lockKey) + { + latestToolStatus = toolStatus; + } + action.Signal(); + + }); + await docker; + await mongo; + } + else + { + Report(); + } + + void Report() + { + var status = new BackendPsCommandResults + { + Infra = latestInfraStatus, + Tools = latestToolStatus + }; + this.SendResults(status); + + var table = new Table(); + table.Border(TableBorder.Simple); + table.AddColumn("[bold]type[/]"); + table.AddColumn("[bold]name[/]"); + table.AddColumn("[bold]pid|containerId[/]"); + table.AddColumn("[bold]healthy[/]"); + // table.AddColumn("[bold]version[/]"); + // table.AddColumn("[bold]req count[/]"); + + foreach (var coreService in latestInfraStatus.coreServices) + { + if (!coreService.isRunning) continue; + table.AddRow( + new Text("infra"), + new Text(coreService.service), + new Text(coreService.containerId?.Substring(0, 8) ?? ""), + new Text("") + ); + } + + foreach (var tool in latestToolStatus.tools) + { + if (!tool.isRunning) continue; + + table.AddRow( + new Text(tool.serviceType), + new Text(tool.serviceName), + new Text(tool.processId.ToString()), + new Text(tool.respondedToDebugEndpoint ? "yes" : "no") + ); + } + + AnsiConsole.Write(table); + + var missingInfra = latestInfraStatus.coreServices.Where(x => !x.isRunning).ToList(); + + if (missingInfra.Count > 0) + { + AnsiConsole.MarkupLine($"[bold red]Missing core infrastructure:[/]"); + AnsiConsole.MarkupLine($"[red] {string.Join(", ", missingInfra.Select(x => x.service))}[/]"); + } + else + { + AnsiConsole.MarkupLine("[green]All core infrastructure is running[/]"); + } + + var missingEssentials = latestToolStatus.tools.Where(x => x.isEssential && !x.isRunning).ToList(); + if (missingEssentials.Count > 0) + { + AnsiConsole.MarkupLine($"[bold red]Missing essential tools:[/]"); + AnsiConsole.MarkupLine($"[red] {string.Join(", ", missingEssentials.Select(x => x.serviceType + "/" + x.serviceName))}[/]"); + } + else + { + AnsiConsole.MarkupLine("[green]All essential tools are running[/]"); + } + } + } + + public static async Task ListenForLocalBindings(BackendToolList list, CommandArgs args, CancellationToken ct, Action onToolChange) + { + + var client = BackendMongoUtil.GetMongo(list); + var topCollection = BackendMongoUtil.GetTopologyCollection(client); + var lastStatus = default(BackendPsToolStatus); + var action = new Debouncer(TimeSpan.FromMilliseconds(250), async void () => + { + try + { + var status = await CheckToolStatus(list, args); + if (status.GetMeaningfulChecksum() != lastStatus?.GetMeaningfulChecksum()) + { + lastStatus = status; + onToolChange?.Invoke(status); + } + } + catch (Exception e) + { + Log.Fatal(e, "Failed to parse tool status"); + } + }); + + Timer clock = null; + clock = new Timer(x => + { + if (ct.IsCancellationRequested) + { + clock?.Dispose(); + return; + } + action.Signal(); + }, null, TimeSpan.FromMilliseconds(0), TimeSpan.FromSeconds(1)); + await topCollection.RunWatching(ct, _ => + { + // could be smart and listen to the operation, but given this is a local computer + // we can just slam it and ask for everything everytime. + action.Signal(); + }); + } + + public static async Task ListenForLocalDocker(BackendToolList list, CommandArgs args, CancellationToken ct, Action onCoreChange) + { + await DockerStatusCommand.RequireDocker(args); + + var action = new Debouncer(TimeSpan.FromMilliseconds(250), async void () => + { + try + { + var statusCheck = await CheckInfraStatus(list, args); + onCoreChange?.Invoke(statusCheck); + } + catch (Exception e) + { + Log.Fatal(e, "Failed to parse docker status"); + } + }); + + var task = args.BeamoLocalSystem.Client.System.MonitorEventsAsync(new ContainerEventsParameters + { + + }, new Progress(DockerSystemEventHandler), ct); + + void DockerSystemEventHandler(Message message) + { + var type = message.Type; + + // this only cares about containers turning on or off. + if (!string.Equals(type, "container", StringComparison.InvariantCultureIgnoreCase)) + return; + + action.Signal(); + } + + try + { + await task; + } + catch (TaskCanceledException) + { + // let it gooooo + Log.Verbose("docker watch was cancelled."); + } + catch + { + throw; + } + } + + public static async Task> CheckLocalJvmDebuggables(BackendToolList list) + { + var programs = await JavaUtility.FindDebuggables(); + // filter the programs by the ones that match the main class. + + var classNameToTool = list.tools.ToDictionary(t => t.mainClassName); + + var found = new List(); + foreach (var entry in programs.entries) + { + if (!classNameToTool.TryGetValue(entry.mainClass, out var tool)) continue; + var info = new BackendToolProcessInfo + { + processId = entry.processId, + toolName = tool.name, + mainClass = entry.mainClass + }; + entry.jvmArgs.TryGetValue(BackendRunCommand.JVM_BEAM_OUT_PROPERTY, out info.stdOutPath); + entry.jvmArgs.TryGetValue(BackendRunCommand.JVM_BEAM_ERR_PROPERTY, out info.stdErrPath); + + found.Add(info); + } + + return found; + } + + public const string BEAMABLE_LABEL = "com.beamable.local"; + + public static async Task CheckToolStatus(BackendToolList list, CommandArgs args) + { + var client = BackendMongoUtil.GetMongo(list); + var allEntries = await client.GetTopologies(); + + var localDebuggables = await CheckLocalJvmDebuggables(list); + + // TODO: this is jank, but there are WAY too many object routing services to deal with + var entries = allEntries.DistinctBy(e => e.ProcessId + e.ServiceType).ToList(); + + var gateway = await FindGateways(); + + var status = new BackendPsToolStatus(); + status.tools.Add(gateway); + foreach (var tool in list.tools) + { + var foundTools = new List(); + foreach (var basicName in tool.basicServiceNames) + { + var matched = entries.Where(e => e.ServiceType == "basic" && e.ServiceName == basicName && IsRunning(e)).ToList(); + if (matched.Count == 0) + { + var matchingDebuggables = localDebuggables.Where(d => d.toolName == tool.name).ToList(); + if (matchingDebuggables.Count == 0) + { + status.tools.Add(new BackendToolRuntimeInfo + { + toolName = tool.name, + serviceType = "basic", + serviceName = basicName, + isEssential = tool.profiles?.Contains("essential") ?? false, + }); + } + else + { + foreach (var debuggable in matchingDebuggables) + { + status.tools.Add(new BackendToolRuntimeInfo + { + toolName = tool.name, + serviceType = "basic", + serviceName = basicName, + isEssential = tool.profiles?.Contains("essential") ?? false, + stdErrPath = debuggable.stdErrPath, + stdOutPath = debuggable.stdOutPath, + processId = debuggable.processId, + isRunning = true + }); + } + } + } + else + { + foreach (var entry in matched) + { + foundTools.Add(GenerateInfo(tool, entry)); + } + } + + } + + foreach (var objectName in tool.objectSerivceNames) + { + var matched = entries.Where(e => e.ServiceType == "object" && e.ServiceName == objectName && IsRunning(e)).ToList(); + if (matched.Count == 0) + { + var matchingDebuggables = localDebuggables.Where(d => d.toolName == tool.name).ToList(); + if (matchingDebuggables.Count == 0) + { + status.tools.Add(new BackendToolRuntimeInfo + { + toolName = tool.name, + serviceType = "object", + serviceName = objectName, + isEssential = tool.profiles?.Contains("essential") ?? false, + }); + } + else + { + foreach (var debuggable in matchingDebuggables) + { + status.tools.Add(new BackendToolRuntimeInfo + { + toolName = tool.name, + serviceType = "object", + serviceName = objectName, + isEssential = tool.profiles?.Contains("essential") ?? false, + stdErrPath = debuggable.stdErrPath, + stdOutPath = debuggable.stdOutPath, + processId = debuggable.processId, + isRunning = true + }); + } + } + } + else + { + foreach (var entry in matched) + { + foundTools.Add(GenerateInfo(tool, entry)); + } + } + } + + status.tools.AddRange(foundTools); + } + return status; + + bool IsRunning(BackendTopologyEntry entry) + { + try + { + Process.GetProcessById(entry.ProcessId); + return true; + } + catch + { + return false; + } + } + + BackendToolRuntimeInfo GenerateInfo(BackendToolInfo tool, BackendTopologyEntry entry) + { + var info = new BackendToolRuntimeInfo + { + serviceType = entry.ServiceType, + serviceName = entry.ServiceName, + toolName = tool.name, + isEssential = tool.profiles?.Contains("essential") ?? false, + isRunning = true, + port = entry.binding.port, + host = entry.binding.host, + stdOutPath = entry.StandardOutRedirection, + stdErrPath = entry.StandardErrRedirection, + processId = entry.ProcessId, + respondedToDebugEndpoint = entry.respondedToDebugEndpoint + }; + + return info; + } + + async Task FindGateways() + { + var gatewayInfo = list.tools.FirstOrDefault(t => t.name == "gateway"); + if (gatewayInfo == null) throw new CliException("Tool info could not find a gateway entry"); + + var confPath = Path.Combine(gatewayInfo.projectPath, "src", "main", "resources", "server.conf"); + var config = HoconConfigurationFactory.FromFile(confPath); + var port = config.GetInt("service.port"); + + var client = new HttpClient(); + var gateway = new BackendToolRuntimeInfo + { + toolName = "gateway", + host = "127.0.0.1", + port = port, + processId = -1, + isEssential = true, + serviceName = "gateway", + serviceType = "gateway" + }; + try + { + var metadataJson = await client.GetStringAsync($"http://127.0.0.1:{port}/metadata"); + var metadata = JsonSerializer.Deserialize(metadataJson, new JsonSerializerOptions + { + IncludeFields = true + }); + gateway.isRunning = true; + gateway.respondedToDebugEndpoint = true; + gateway.processId = metadata.processId; + gateway.stdErrPath = metadata.stdErrRedirection; + gateway.stdOutPath = metadata.stdOutRedirection; + } + catch + { + // oh well. + } + + return gateway; + } + } + + + public static async Task CheckInfraStatus(BackendToolList list, CommandArgs args) + { + var containers = await args.BeamoLocalSystem.Client.Containers.ListContainersAsync(new ContainersListParameters + { + Filters = new Dictionary> + { + ["label"] = new Dictionary + { + [BEAMABLE_LABEL] = true + } + } + }); + + var coreStatus = new BackendPsInfraStatus(); + foreach (var infra in list.infra) + { + coreStatus.coreServices.Add(new BackendDockerContainerInfo + { + service = infra.name + }); + } + + const string serviceLabel = "com.docker.compose.service"; + foreach (var container in containers) + { + Log.Debug($"found container=[{container.ID}]"); + if (!container.Labels.TryGetValue(serviceLabel, out var service)) + { + Log.Warning($"Found a docker container=[{container.ID}] with the label={BEAMABLE_LABEL}, but no {serviceLabel}."); + continue; + } + + var coreService = coreStatus.coreServices.FirstOrDefault(c => c.service == service); + if (coreService == null) + { + Log.Warning($"Found a docker container=[{container.ID}] service=[{service}], but it does not match a known required service."); + continue; + } + + coreService.isRunning = true; + coreService.containerId = container.ID; + // maybe we need to extract other properties from docker? + } + + return coreStatus; + } +} \ No newline at end of file diff --git a/cli/cli/Commands/BackendCommands/BackendRunCommand.cs b/cli/cli/Commands/BackendCommands/BackendRunCommand.cs new file mode 100644 index 0000000000..7890ca4682 --- /dev/null +++ b/cli/cli/Commands/BackendCommands/BackendRunCommand.cs @@ -0,0 +1,445 @@ +using System.CodeDom.Compiler; +using System.CommandLine; +using System.Diagnostics; +using Beamable.Common; +using Beamable.Common.BeamCli; +using Beamable.Server; +using Beamable.Server.Common; +using cli.DockerCommands; +using cli.Utils; +using CliWrap; + +namespace cli.BackendCommands; + +public class BackendRunToolCommandArgs : CommandArgs, IBackendCommandArgs +{ + public string backendHome; + public string[] tools; + + public bool runInfra; + public bool runEssentialTools; + public string runProfile; + + public bool reset; + public bool noDeps; + public bool noStop; + + // TODO: add --profile support + // TODO: add --use-dev-env + public string BackendHome => backendHome; +} + +public class BackendRunPlanResultChannel : IResultChannel +{ + public string ChannelName => "plan"; +} + +public class BackendRunCommand + : AppCommand + , IResultSteam + , ISkipManifest +, IStandaloneCommand +{ + public BackendRunCommand() : base("run", "Run a named tool") + { + } + + public override void Configure() + { + AddOption(new Option(new string[] { "--infra", "-i" }, "Specifies to run the infrastructure"), + (args, i) => args.runInfra = i); + + AddOption(new Option(new string[] { "--essential", "--essentials", "-e" }, "Run the essential tools"), + (args, i) => args.runEssentialTools = i); + + AddOption(new Option(new string[]{"--tool", "--tools", "-t"}, "Specifies which tools to be run") + { + Arity = ArgumentArity.OneOrMore + }, (args, i) => + { + var allTools = i.SelectMany(tools => tools.Split(new char[] { ',', ';' }, + StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)).ToArray(); + args.tools = allTools; + }); + + AddOption(new Option(new string[] { "--reset", "-r" }, "Reset any specified components"), + (args, i) => args.reset = i); + + AddOption(new Option(new string[] { "--no-deps", "-nd" }, "Do not attempt to start the dependencies of the requested components"), + (args, i) => args.noDeps = i); + + AddOption(new Option(new string[] { "--no-stop", "-ns" }, "Do not stop existing components"), + (args, i) => args.noStop = i); + + AddOption(new Option(new string[] { "--profile", "-p" }, "Run a specific profile from the docker-compose"), + (args, i) => args.runProfile = i); + + BackendCommandGroup.AddBackendHomeOption(this, (args, i) => args.backendHome = i); + } + + public override async Task Handle(BackendRunToolCommandArgs args) + { + await DockerStatusCommand.RequireDocker(args); + // if (!args.Dryrun) throw new NotImplementedException("can only preview changes"); + // var toolPath = Path.Combine(args.backendHome, args.tool); + // var needsRecompile = BackendCompileCommand.CheckSourceAndTargets(toolPath); + var toolInfo = BackendListToolsCommand.GatherToolList(args.backendHome); + var infraStatusTask = BackendPsCommand.CheckInfraStatus(toolInfo, args); + var toolStatus = await BackendPsCommand.CheckToolStatus(toolInfo, args); + var infraStatus = await infraStatusTask; + + // if the user doesn't specify anything, default to essentials. + if (args.tools.Length == 0 && !args.runInfra) + { + args.runEssentialTools = true; + } + + var plan = GetRunPlan(args, infraStatus, toolStatus, toolInfo); + + this.LogResult(plan); + this.SendResults(plan); + + if (args.Dryrun) + { + // don't actually do anything. + return; + } + + await RunPlan(args, plan, toolInfo); + } + + public const string JVM_BEAM_OUT_PROPERTY = "-Dcom.beamable.stdOutRedirection"; + public const string JVM_BEAM_ERR_PROPERTY = "-Dcom.beamable.stdErrRedirection"; + + public static async Task RunPlan(TArgs args, BackendRunPlan plan, BackendToolList list) + where TArgs : CommandArgs, IBackendCommandArgs + { + if (plan.stopInfra) + { + await StopInfra(); + } + + if (plan.startInfra) + { + await StartInfra(); + } + + if (plan.compileCore) + { + Log.Information("Compiling core..."); + Log.Information(" " + list.coreProjectPath); + await BackendCompileCommand.CompileProject(list.coreProjectPath); + Log.Information("Finished compiling core."); + } + + foreach (var phase in plan.toolPhases) + { + var tasks = new List(); + foreach (var action in phase.actions) + { + tasks.Add(HandleTool(action)); + } + + await Task.WhenAll(tasks); // TODO: error catching. + } + + Log.Information("all done"); + + async Task HandleTool(BackendRunPlanTool action) + { + if (action.compile) + { + await Compile(); + } + + if (action.generateClassPath) + { + await GenerateClassPath(); + } + + if (action.stopPids != null) + { + foreach (var pid in action.stopPids) + { + StopTool(pid); + } + } + + if (action.start) + { + await RunTool(); + } + + async Task Compile() + { + Log.Information($"Compiling {action.toolName}..."); + await BackendCompileCommand.CompileProject(action.projectPath); + } + + async Task GenerateClassPath() + { + Directory.CreateDirectory(action.TempPath); + Log.Information($"Getting classpath for {action.toolName}"); + await JavaUtility.RunMaven($"dependency:build-classpath -Dmdep.outputFile={action.ClassPathFilePath}", + action.projectPath); + } + + async Task RunTool() + { + var classPath = await File.ReadAllTextAsync(action.ClassPathFilePath); + var separator = ':'; // TODO: on windows this is a ; + + classPath = classPath + separator + "target/classes"; + + Log.Information(" found classpath"); + + Log.Information($"Running {action.toolName}"); + + // nohup java -cp "target/classes:target/dependency/*" MyClass > myclass.out 2>&1 & + + Directory.CreateDirectory(action.LogPath); + var logPath = Path.Combine(action.LogPath, $"{DateTimeOffset.Now.ToFileTime()}.log"); + var logErrPath = Path.ChangeExtension(logPath, ".err.log"); + var serverConf = "server.conf"; // TODO: Make this configurable + var maxMemoryMb = 512; + var initialMemoryMb = 256; + var procCount = 2; + var debugPort = PortUtil.FreeTcpPort(); + var toolArgs = $"sh -c \"{args.AppContext.JavaPath} " + + + // try to tell the JVM to shut up. + $"-Xmx{maxMemoryMb}m " + + $"-Xms{initialMemoryMb}m " + + $"-XX:ActiveProcessorCount={procCount} " + + + // allow us to pick when env we connect to? + $"-DCom.kickstand.defaultServerConf={serverConf} " + + + // pass the log redirection + $"{JVM_BEAM_OUT_PROPERTY}='{logPath}' " + + $"{JVM_BEAM_ERR_PROPERTY}='{logErrPath}' " + + + // enable the debugger + $"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address={debugPort} " + + + // pass in the class path + $"-cp '{classPath}' " + + + // entry point main class + $"{action.mainClassName} " + + + // pip the output to a known file. + $"> '{logPath}' 2> '{logErrPath}'\" &"; + var command = Cli.Wrap("nohup") + .WithArguments(toolArgs) + .WithWorkingDirectory(action.projectPath) + .ExecuteAsync(); + var processId = command.ProcessId; + Log.Information($"Started {action.toolName} as process-id=[{processId}]"); + } + + async Task StopTool(int pid) + { + Log.Information($"Stopping {action.toolName} at process-id=[{pid}]"); + + try + { + await Cli.Wrap("kill") + .WithArguments(pid.ToString()) + .ExecuteAsync(); + } + catch (Exception ex) + { + Log.Warning($" received error while trying to stop process-id=[{pid}], message=[{ex.Message}]"); + } + } + + } + + async Task StopInfra() + { + const string label = "compose down"; + Log.Information("Stopping infrastructure..."); + await Cli.Wrap("docker") + .WithArguments("compose --profile core down") + .WithWorkingDirectory(Path.Combine(args.BackendHome, "docker", "local")) + .WithStandardOutputPipe(PipeTarget.ToDelegate(line => { Log.Information($" [{label}] " + line); })) + .WithStandardErrorPipe(PipeTarget.ToDelegate(line => { Log.Information($" [{label}] " + line); })) + .ExecuteAsync(); + Log.Information("Stopped infrastructure."); + } + + async Task StartInfra() + { + const string label = "compose up"; + const string mongoClusterSetup = "mongo_cluster_setup"; + const string mongoMasterSetup = "mongo_master_setup"; + Log.Information("Starting infrastructure..."); + await Cli.Wrap("docker") + .WithArguments("compose --profile core up -d --build") + .WithWorkingDirectory(Path.Combine(args.BackendHome, "docker", "local")) + .WithStandardOutputPipe(PipeTarget.ToDelegate(line => { Log.Information($" [{label}] " + line); })) + .WithStandardErrorPipe(PipeTarget.ToDelegate(line => { Log.Information($" [{label}] " + line); })) + .ExecuteAsync(); + Log.Information("Started infrastructure."); + Log.Information("\nWaiting for mongo cluster..."); + + await Cli.Wrap("docker") + .WithArguments($"attach {mongoClusterSetup}") + .WithWorkingDirectory(Path.Combine(args.BackendHome, "docker", "local")) + .WithStandardOutputPipe(PipeTarget.ToDelegate(line => { Log.Information($" [{mongoClusterSetup}] " + line); })) + .WithStandardErrorPipe(PipeTarget.ToDelegate(line => { Log.Information($" [{mongoClusterSetup}] " + line); })) + .ExecuteAsync(); + + Log.Information("\nWaiting for mongo master..."); + + await Cli.Wrap("docker") + .WithArguments($"attach {mongoMasterSetup}") + .WithWorkingDirectory(Path.Combine(args.BackendHome, "docker", "local")) + .WithStandardOutputPipe(PipeTarget.ToDelegate(line => { Log.Information($" [{mongoMasterSetup}] " + line); })) + .WithStandardErrorPipe(PipeTarget.ToDelegate(line => { Log.Information($" [{mongoMasterSetup}] " + line); })) + .ExecuteAsync(); + + Log.Information("Started infrastructure."); + } + } + + public static BackendRunPlan GetRunPlan(BackendRunToolCommandArgs args, BackendPsInfraStatus infraStatus, BackendPsToolStatus toolStatus, BackendToolList list) + { + var plan = new BackendRunPlan(); + + if (args.runInfra) + { + if (infraStatus.AllRunning) + { + plan.startInfra = false; + if (args.reset) + { + plan.stopInfra = true; + plan.startInfra = true; + } + } + else + { + plan.startInfra = true; + } + } + + if (args.runEssentialTools) + { + if (!infraStatus.AllRunning && !args.noDeps) + { + plan.startInfra = true; + } + + var essentials = list.tools + .Where(t => t.profiles.Contains("essential")) + .Select(t => t.name) + .ToArray(); + var phase = CreatePhase(essentials); + plan.toolPhases.Add(phase); + } + + if (args.tools.Length > 0) + { + var phase = CreatePhase(args.tools); + plan.toolPhases.Add(phase); + } + + BackendRunPlanToolCollection CreatePhase(string[] tools) + { + // we only need to consider the core if there are tools being started. + plan.compileCore = BackendCompileCommand.CheckSourceAndTargets(list.coreProjectPath, out _); + + var phase = new BackendRunPlanToolCollection(); + foreach (var toolName in tools) + { + var tool = list.tools.FirstOrDefault(t => toolName == t.name); + if (tool == null) + { + throw new CliException($"Cannot find tool=[{toolName}]"); + } + + var needsCompile = BackendCompileCommand.CheckSourceAndTargets(tool.projectPath, out var latestSourceTime); + + var classPathFile = BackendRunPlanTool.GetClassPathFilePath(tool.projectPath); + var classPathFileMissing = !File.Exists(classPathFile); + var needsClassPath = classPathFileMissing; + if (!classPathFileMissing) + { + var writeTime = new DateTimeOffset(File.GetLastWriteTime(classPathFile)); + needsClassPath = latestSourceTime > writeTime; + } + + + var existingProcesses = toolStatus.tools.Where(t => t.isRunning && t.toolName == tool.name).ToList(); + var stopPids = args.noStop + ? Array.Empty() + : existingProcesses.Select(x => x.processId).ToArray(); + if (existingProcesses.Count == 0 || (existingProcesses.Count > 0 && args.reset)) + { + phase.actions.Add(new BackendRunPlanTool + { + toolName = tool.name, + mainClassName = tool.mainClassName, + projectPath = tool.projectPath, + start = true, + generateClassPath = needsClassPath, + compile = needsCompile, + stopPids = stopPids + }); + } + + } + return phase; + } + + return plan; + } + + public static void RunInfra() + { + + } + + public static void RunScript() + { + + } + +} + +public class BackendRunPlan +{ + public bool stopInfra; + public bool startInfra; + public bool compileCore; + + public List toolPhases = new List(); +} + +public class BackendRunPlanToolCollection +{ + public List actions = new List(); +} + +public class BackendRunPlanTool +{ + public string toolName; + public string mainClassName; + public string projectPath; + public int[] stopPids; + public bool compile; + public bool generateClassPath; + public bool start; + + public static string GetTargetsPath(string projectPath) => Path.Combine(projectPath, "target"); + public static string GetTempPath(string projectPath) => Path.Combine(GetTargetsPath(projectPath), "beamTemp"); + public static string GetClassPathFilePath(string projectPath) => Path.Combine(GetTempPath(projectPath), "classPath.txt"); + public static string GetLogPath(string projectPath) => Path.Combine(GetTempPath(projectPath), "logs"); + + public string TargetsPath => GetTargetsPath(projectPath); + public string TempPath => GetTempPath(projectPath); + public string ClassPathFilePath => GetClassPathFilePath(projectPath); + public string LogPath => GetLogPath(projectPath); +} \ No newline at end of file diff --git a/cli/cli/Commands/BackendCommands/BackendSetLocalVarsCommand.cs b/cli/cli/Commands/BackendCommands/BackendSetLocalVarsCommand.cs new file mode 100644 index 0000000000..86554eba07 --- /dev/null +++ b/cli/cli/Commands/BackendCommands/BackendSetLocalVarsCommand.cs @@ -0,0 +1,148 @@ +using System.CommandLine; +using System.Diagnostics; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Beamable.Server; +using CliWrap; + +namespace cli.BackendCommands; + +public class BackendSetLocalVarsCommandArgs : CommandArgs +{ + public string repoUrl; + public string backendHome; + public string githubToken; +} + +public class BackendSetLocalVarsCommandResults +{ + +} +public class BackendSetLocalVarsCommand + : AtomicCommand +,IStandaloneCommand, ISkipManifest +{ + public BackendSetLocalVarsCommand() : base("set-local-vars", "Set local variables from github") + { + } + + public override void Configure() + { + BackendCommandGroup.AddBackendRepoOption(this, (args, i) => args.repoUrl = i); + BackendCommandGroup.AddBackendHomeOption(this, (args, i) => args.backendHome = i); + + AddOption( + new Option(new string[] { "--gh-token" }, + "Pass a github token for authentication. By default, the `gh auth token` utility will be used."), + (args, i) => args.githubToken = i); + } + + public override async Task GetResult(BackendSetLocalVarsCommandArgs args) + { + BackendCommandGroup.ValidateBackendHomeDirectoryExists(args.backendHome); + + // need to get the gh-token + if (string.IsNullOrEmpty(args.githubToken)) + { + args.githubToken = await GetGithubToken(); + } + + Log.Information("Fetching github variables..."); + var githubVariables = await GetAllGithubVariables(args.repoUrl, args.githubToken); + + Log.Information("Writing configuration files..."); + var allTemplatePaths = Directory.GetFiles(args.backendHome, "*.liquid", SearchOption.AllDirectories); + var targetFolder = Path.DirectorySeparatorChar + "target" + Path.DirectorySeparatorChar; + foreach (var templatePath in allTemplatePaths) + { + if (templatePath.Contains(targetFolder)) + { + Log.Debug($"Skipping this path because it is in a target/ folder. path=[{templatePath}]"); + continue; + } + + Log.Debug($"found template file=[{templatePath}]"); + var confPath = templatePath.Substring(0, templatePath.Length - ".liquid".Length); + + var template = File.ReadAllText(templatePath); + + foreach (var variable in githubVariables.variables) + { + template = template.Replace($"{{{{ {variable.name} }}}}", variable.value); + } + + Log.Information($" {confPath}"); + File.WriteAllText(confPath, template); + } + + Log.Information("All done!"); + return new BackendSetLocalVarsCommandResults(); + } + + public static async Task GetGithubToken() + { + // TODO: make handling the case where the user is logged out of gh cli easier. + var stdOutBuffer = new StringBuilder(); + await Cli.Wrap("gh") + .WithArguments("auth token") + .WithStandardOutputPipe(PipeTarget.ToStringBuilder(stdOutBuffer)) + .WithStandardErrorPipe(PipeTarget.ToDelegate(line => + { + Log.Error($"gh cli error: {line}"); + Log.Error("Make sure to log into github cli with `github auth login`"); + })) + .ExecuteAsync(); + return stdOutBuffer.ToString().Trim(); + } + + private static async Task GetAllGithubVariables(string repo, string githubToken) + { + var pageSize = 30; + var page = 1; // annoying, github pages are 1 based, so 1 is the first page, not 0. + var client = new HttpClient(); + var results = await MakeGitHubRequestForPage(client, repo, githubToken, page, pageSize); + var accrued = results; + + while (results.total_count > accrued.variables.Count) + { + page++; + results = await MakeGitHubRequestForPage(client, repo, githubToken, page, pageSize); + accrued.variables.AddRange(results.variables); + } + return accrued; + } + + private static async Task MakeGitHubRequestForPage(HttpClient client, string repo, string githubToken, int page, int perPage) + { + var uri = $"https://api.github.com/repos/{repo}/environments/local/variables?per_page={perPage}&page={page}"; + + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + request.Headers.UserAgent.ParseAdd("dotnet-client"); + request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", githubToken); + + var response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + IncludeFields = true + }); + } + + private class GitHubVarsResponse + { + public int total_count; + public List variables; + } + + [DebuggerDisplay("{name}: {value}")] + private class GitHubVar + { + public string name; + public string value; + } +} \ No newline at end of file diff --git a/cli/cli/Commands/BackendCommands/BackendStopCommand.cs b/cli/cli/Commands/BackendCommands/BackendStopCommand.cs new file mode 100644 index 0000000000..68ab7cea35 --- /dev/null +++ b/cli/cli/Commands/BackendCommands/BackendStopCommand.cs @@ -0,0 +1,150 @@ +using System.CommandLine; +using cli.DockerCommands; + +namespace cli.BackendCommands; + +public class BackendStopCommandArgs : CommandArgs, IBackendCommandArgs +{ + public string backendHome; + public string[] tools; + + public bool stopInfra; + public bool stopEssentialTools; + + public int processId; + public bool noDeps; + + // TODO: add --profile support + public string BackendHome => backendHome; +} + +public class BackendStopCommand + : AppCommand + , IResultSteam + , ISkipManifest + ,IStandaloneCommand + +{ + public BackendStopCommand() : base("stop", "stop local beamable components") + { + } + + public override void Configure() + { + AddOption(new Option(new string[] { "--infra", "-i" }, "Specifies to run the infrastructure"), + (args, i) => args.stopInfra = i); + + AddOption(new Option(new string[] { "--essential", "--essentials", "-e" }, "Stop the essential tools"), + (args, i) => args.stopEssentialTools = i); + + AddOption(new Option(new string[]{"--tool", "--tools", "-t"}, "Specifies which tools to be stopped") + { + Arity = ArgumentArity.OneOrMore + }, (args, i) => + { + var allTools = i.SelectMany(tools => tools.Split(new char[] { ',', ';' }, + StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)).ToArray(); + args.tools = allTools; + }); + + AddOption(new Option(new string[] { "--process-id", "--proc" }, "Stop a given process id"), + (args, i) => args.processId = i); + + + BackendCommandGroup.AddBackendHomeOption(this, (args, i) => args.backendHome = i); + } + + public override async Task Handle(BackendStopCommandArgs args) + { + await DockerStatusCommand.RequireDocker(args); + + var toolInfo = BackendListToolsCommand.GatherToolList(args.backendHome); + var infraStatusTask = BackendPsCommand.CheckInfraStatus(toolInfo, args); + var toolStatus = await BackendPsCommand.CheckToolStatus(toolInfo, args); + var infraStatus = await infraStatusTask; + + if (!args.stopInfra && !args.stopEssentialTools && args.tools.Length == 0 && args.processId <= 0) + { + args.stopInfra = true; + args.tools = toolStatus.tools.Select(t => t.toolName).ToArray(); + } + + var plan = GetStopPlan(args, infraStatus, toolStatus, toolInfo); + + + this.LogResult(plan); + this.SendResults(plan); + + if (args.Dryrun) + { + // don't actually do anything. + return; + } + + await BackendRunCommand.RunPlan(args, plan, toolInfo); + + } + + public static BackendRunPlan GetStopPlan( + BackendStopCommandArgs args, + BackendPsInfraStatus infraStatus, + BackendPsToolStatus toolStatus, + BackendToolList list) + { + var plan = new BackendRunPlan(); + if (args.stopInfra) + { + plan.stopInfra = true; + } + + if (args.stopEssentialTools) + { + var essentials = list.tools + .Where(t => t.profiles.Contains("essential")) + .Select(t => t.name) + .ToArray(); + plan.toolPhases.Add(CreatePhase(essentials)); + + } + + if (args.processId > 0) + { + var matching = toolStatus.tools + .Where(t => t.processId == args.processId) + .Select(t => t.toolName) + .ToArray(); + plan.toolPhases.Add(CreatePhase(matching)); + } + + if (args.tools.Length > 0) + { + plan.toolPhases.Add(CreatePhase(args.tools)); + } + + BackendRunPlanToolCollection CreatePhase(string[] tools) + { + var phase = new BackendRunPlanToolCollection(); + + foreach (var toolName in tools) + { + var tool = list.tools.FirstOrDefault(t => toolName == t.name); + if (tool == null) + { + throw new CliException($"Cannot find tool=[{toolName}]"); + } + var existingProcesses = toolStatus.tools.Where(t => t.isRunning && t.toolName == tool.name).ToList(); + var stopPids = existingProcesses.Select(x => x.processId).ToArray(); + + phase.actions.Add(new BackendRunPlanTool + { + toolName = toolName, + stopPids = stopPids + }); + } + + return phase; + } + + return plan; + } +} \ No newline at end of file diff --git a/cli/cli/Commands/BackendCommands/BrewUtility.cs b/cli/cli/Commands/BackendCommands/BrewUtility.cs new file mode 100644 index 0000000000..6c6d7ec7ee --- /dev/null +++ b/cli/cli/Commands/BackendCommands/BrewUtility.cs @@ -0,0 +1,149 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Beamable.Server; +using CliWrap; +using Spectre.Console; + +namespace cli.BackendCommands; + +public static class BrewUtility +{ + public static async Task EnsureBrew(CommandArgs args) + { + var os = CollectorManager.GetCurrentPlatform(); + if (os != OSPlatform.OSX) return; + + var hasBrew = await CheckBrew(); + if (hasBrew) return; + + await InstallBrew(args); + } + + public static async Task RunBrew(string args) + { + await RunBrew("brew", args); + } + public static async Task RunBrew(string program, string args) + { + Log.Information($"Running `{program} {args}`"); + await Cli.Wrap(program) + .WithArguments(args) + .WithStandardOutputPipe(PipeTarget.ToDelegate(line => + { + Log.Information("\t" + line); + })) + .WithStandardErrorPipe(PipeTarget.ToDelegate(line => + { + Log.Error("\t" + line); + })) + .ExecuteAsync(); + } + + public static async Task InstallBrew(CommandArgs args) + { + var os = CollectorManager.GetCurrentPlatform(); + if (os != OSPlatform.OSX) throw new CliException("Brew can only install on OSX"); + + const string downloadPath = "https://github.com/Homebrew/brew/releases/download/4.6.4/Homebrew-4.6.4.pkg"; + + var fileName = Path.GetFileName(downloadPath); + var targetFilePath = Path.Combine(Path.GetTempPath(), fileName); + + if (!File.Exists(targetFilePath)) + { + var shouldDownload = args.Quiet || AnsiConsole.Confirm("Would you like to download the Brew installer?"); + if (!shouldDownload) + { + Log.Information("Please install brew manually"); + Log.Information($" download link: {downloadPath}"); + return; + } + await AnsiConsole.Status().Spinner(Spinner.Known.Default).StartAsync("Downloading Brew...", async _ => + { + await CollectorManager.DownloadAndDecompressGzip(new HttpClient(), downloadPath, targetFilePath, true, false); + }); + } + + var shouldInstall = args.Quiet || AnsiConsole.Confirm("Would you like to run the Brew installer?"); + if (!shouldInstall) + { + Log.Information("Please install brew manually"); + Log.Information($" download link: {downloadPath}"); + Log.Information($" file: {targetFilePath}"); + return; + } + + if (!await CheckBrewDeps()) + { + Log.Information("Brew requires the XCode Command Line Tools... "); + return; + } + + Log.Information("Running Brew installer. Please re-run the command when it is complete."); + var proc = Process.Start(new ProcessStartInfo + { + FileName = targetFilePath, + UseShellExecute = true + }); + await proc.WaitForExitAsync(); + } + + public static async Task CheckBrewDeps() + { + var psi = new ProcessStartInfo + { + FileName = "bash", + Arguments = "-c \"pkgutil --pkg-info=com.apple.pkg.CLTools_Executables >/dev/null 2>&1\"", + RedirectStandardOutput = false, + RedirectStandardError = false, + UseShellExecute = false + }; + + var proc = Process.Start(psi); + await proc.WaitForExitAsync(); + var hasCLT = proc.ExitCode == 0; + + if (!hasCLT) + { + Log.Information("Please install the XCode Command Line Tools and try again."); + Process.Start(new ProcessStartInfo + { + FileName = "xcode-select", + Arguments = "--install", + UseShellExecute = true + }); + } + return hasCLT; + } + + public static async Task CheckBrew() + { + var command = Cli.Wrap("brew") + .WithStandardOutputPipe(PipeTarget.ToDelegate(x => + { + })) + .WithStandardErrorPipe(PipeTarget.ToDelegate(x => + { + })) + .WithArguments("--version") + .WithValidation(CommandResultValidation.None); + + + try + { + var res = command.ExecuteAsync(); + await res; + var success = res.Task.Result.ExitCode == 0; + if (!success) + { + return false; + } + + return true; + } + catch (Exception) + { + return false; + } + } +} \ No newline at end of file diff --git a/cli/cli/Commands/BackendCommands/CheckDepsCommand.cs b/cli/cli/Commands/BackendCommands/CheckDepsCommand.cs new file mode 100644 index 0000000000..4f5be31831 --- /dev/null +++ b/cli/cli/Commands/BackendCommands/CheckDepsCommand.cs @@ -0,0 +1,296 @@ +using System.CommandLine; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using Beamable.Common.BeamCli; +using Beamable.Server; +using cli.DockerCommands; +using CliWrap; +using Spectre.Console; + +namespace cli.BackendCommands; + +public class CheckDepsCommandArgs : CommandArgs +{ + public bool forceInstall; + public bool noInstall; +} + +public class CheckDepsCommandProgramsResult +{ + public List programDependencies = new List(); +} + +public class CheckDepsProgramResult +{ + public string name; + public bool status; +} + +public class CheckDepsCommandResultChannel : IResultChannel +{ + public string ChannelName => "programs"; +} + +public class CheckDepsCommand + : AppCommand + , IResultSteam + ,IStandaloneCommand, ISkipManifest + +{ + public CheckDepsCommand() : base("validate", "Check that the current machine has the dependencies to run the Beamable backend.") + { + } + + public override void Configure() + { + AddOption( + new Option("--force-install", + "Force the installation of programmable dependencies like git, scala, java, and maven"), + (args, i) => args.forceInstall = i); + AddOption( + new Option("--no-install", + "Don't offer any automatic installation"), + (args, i) => args.noInstall = i); + } + + public override async Task Handle(CheckDepsCommandArgs args) + { + var os = CollectorManager.GetCurrentPlatform(); + if (os != OSPlatform.OSX) + { + throw new NotImplementedException("the CLI can only validate dependencies for OSX. Windows coming next!"); + } + + if (args.noInstall && args.forceInstall) + { + throw new CliException("Cannot pass both --no-install and --force-install"); + } + + // The beamable backend requires the following dependencies... + // 1. openJDK 8 + // 2. scala 2.11 + // 3. maven + // 4. docker + // 5. git (implied) + // 6. github cli + // + // to validate these, try invoking each from the terminal + + Log.Information("Checking system for dependencies... Please refer to the ReadMe page for details."); + const string readmeLink = "https://github.com/beamable/BeamableBackend/blob/main/README.md"; + Log.Information(readmeLink); + + var checkJava = CheckForProgram("java", args.AppContext.JavaPath, "-version", "1.8.0"); + var checkMaven = CheckForProgram("maven", "mvn", "-version", ""); + var checkScala = CheckForProgram("scala", "scala", "-version", "version 2.11.12"); + var checkGit = CheckForProgram("git", "git", "--version", ""); + var checkGitHubCli = CheckForProgram("github cli", "gh", "--version", ""); + + // TODO: install the aws cli and sign in + // need to add the STS roles to verify that the user is able to run stuff. + // beamable-assume-service-role-policy + // beamable-microservice-assume-role-policy + var checkAwsCli = CheckForProgram("aws cli", "aws", "--version", ""); + var checkDocker = CheckForProgram("docker", args.AppContext.DockerPath, "--version", ""); + + var initialResults = new CheckDepsCommandProgramsResult + { + programDependencies = new List + { + await checkJava, + await checkMaven, + await checkScala, + await checkGit, + await checkGitHubCli, + await checkDocker + } + }; + + // this.LogResult(initialResults); + this.SendResults(initialResults); + + var missingDeps = initialResults.programDependencies + .Where(d => d.status == false) + .ToList(); + + if (missingDeps.Count == 0) + { + Log.Information("All dependencies are installed!"); + if (!args.forceInstall) + { + // only return if we aren't forcing the installation + return; + } + } + + + Log.Information("The following dependencies must be installed."); + foreach (var missingDep in missingDeps) + { + Log.Information($" {missingDep.name}"); + } + if (args.noInstall) + { + return; + } + + // docker is the only special dependency that cannot be installed with 'brew' + if (missingDeps.Any(d => d.name == "docker")) + { + await InstallDocker(args); + return; + } + + if (args.forceInstall) + { + missingDeps = initialResults.programDependencies.Where(x => x.name != "docker").ToList(); + } + + var hasBrew = await BrewUtility.CheckBrew(); + if (!hasBrew) + { + var shouldInstallBrew = args.Quiet || AnsiConsole.Confirm( + prompt: "To install these dependencies, we recommend you use Brew. Would you like to download and install Brew? Please re-run this command after Brew has been installed."); + if (!shouldInstallBrew) + { + Log.Information("Please install the dependencies and try this command again."); + return; + } + + await BrewUtility.InstallBrew(args); + return; + } + + + var shouldInstall = args.Quiet || AnsiConsole.Confirm("Would you like to use Brew to install these dependencies? You may be asked for your password."); + if (!shouldInstall) + { + Log.Information("Please install the dependencies and try this command again."); + return; + } + + foreach (var dep in missingDeps) + { + switch (dep.name) + { + case "java": + await BrewUtility.RunBrew("install --cask temurin@8"); + break; + case "maven": + await BrewUtility.RunBrew("install maven"); + break; + case "scala": + await BrewUtility.RunBrew("install coursier"); + await BrewUtility.RunBrew("coursier", "setup"); + await BrewUtility.RunBrew("cs", "install scala:2.11.12"); + await BrewUtility.RunBrew("cs", "install scalac:2.11.12"); + break; + case "git": + await BrewUtility.RunBrew("install git"); + break; + case "github cli": + await BrewUtility.RunBrew("install gh"); + break; + default: + Log.Error($"The {dep.name} could not be automatically installed with brew. Please reach out to the Beamable team if you see this error."); + break; + } + } + + Log.Information("The dependencies have been installed. Please re-run this command to verify."); + + } + + public static async Task InstallDocker(CommandArgs args) + { + Log.Information("Docker must be installed manually with the installer."); + var link = StartDockerCommand.GetDockerDownloadLink(); + + Log.Information($"Download link: {link}"); + + string installerFileName = Path.GetFileName(link); + var downloadPath = Path.Combine(Path.GetTempPath(), installerFileName); + var installerExists = File.Exists(downloadPath); + + var confirm = args.Quiet; + if (!installerExists) + { + var shouldDownload = confirm || AnsiConsole.Confirm("Would you like to download the Docker installer and run it?"); + + if (!shouldDownload) + { + Log.Information("Please install Docker manually and run this command again."); + return; + } + await AnsiConsole.Status().Spinner(Spinner.Known.Aesthetic).StartAsync("Downloading...", async ctx => + { + await CollectorManager.DownloadAndDecompressGzip(new HttpClient(), link, downloadPath, true, decompress: false); + }); + } + + var shouldInstall = confirm || AnsiConsole.Confirm("Would you like to run the Docker installer?"); + if (!shouldInstall) + { + Log.Information("Please install Docker manually and run this command again."); + Log.Information($" installer is at {downloadPath}"); + return; + } + + Log.Information("Running Docker installer... Please re-run this command when it completes."); + var p = Process.Start(new ProcessStartInfo(downloadPath) + { + UseShellExecute = true + }); + await p.WaitForExitAsync(); + + } + + public static async Task CheckForProgram(string name, string program, string args, string expectedOutputSnippet) + { + Log.Information($"Checking system for {name}"); + Log.Debug($"Checking for {name} as program=[{program}] with args=[{args}]"); + var result = new CheckDepsProgramResult + { + name = name, + status = false + }; + var output = new StringBuilder(); + var command = CliWrap.Cli + .Wrap(program) + .WithStandardOutputPipe(PipeTarget.ToStringBuilder(output)) + .WithStandardErrorPipe(PipeTarget.ToStringBuilder(output)) + .WithArguments(args) + .WithValidation(CommandResultValidation.None); + + try + { + var res = command.ExecuteAsync(); + await res; + var success = res.Task.Result.ExitCode == 0; + if (!success) + { + result.status = false; + return result; + } + + var outputText = output.ToString(); + Log.Debug(outputText); + Log.Debug(""); + var hasSnippet = outputText.Contains(expectedOutputSnippet, StringComparison.InvariantCultureIgnoreCase); + if (!hasSnippet) + { + Log.Debug($"{name} was installed, but did not contain the expected output=[{expectedOutputSnippet}]"); + result.status = false; + return result; + } + + result.status = true; + } + catch (Exception) + { + result.status = false; + } + return result; + } +} \ No newline at end of file diff --git a/cli/cli/Commands/BackendCommands/DockerComposeModel.cs b/cli/cli/Commands/BackendCommands/DockerComposeModel.cs new file mode 100644 index 0000000000..c5a09bdd76 --- /dev/null +++ b/cli/cli/Commands/BackendCommands/DockerComposeModel.cs @@ -0,0 +1,25 @@ + +using JetBrains.Annotations; +using YamlDotNet.Serialization; + +namespace cli.BackendCommands; + +public class DockerComposeModel +{ + public Dictionary services; +} + +public class DockerComposeService +{ + public string[] profiles = Array.Empty(); + public string[] dependsOn = Array.Empty(); + + // [YamlMember(Alias = "x-beam-services", ApplyNamingConventions = false)] + + [YamlMember(Alias = "x-beam-services", ApplyNamingConventions = false)] + public Dictionary services; + public Dictionary labels = new Dictionary(); + + // public string[] ExplicitBasicServices => (services?.ContainsKey("basic") ?? false) ? services["basic"] : null; + // public string[] ExplicitObjectServices => (services?.ContainsKey("object") ?? false) ? services["object"] : null; +} diff --git a/cli/cli/Commands/BackendCommands/JavaUtility.cs b/cli/cli/Commands/BackendCommands/JavaUtility.cs new file mode 100644 index 0000000000..b6b86057d4 --- /dev/null +++ b/cli/cli/Commands/BackendCommands/JavaUtility.cs @@ -0,0 +1,170 @@ +using System.CommandLine.Parsing; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using Beamable.Server; +using CliWrap; + +namespace cli.BackendCommands; + +public static class JavaUtility +{ + const string JAVA_HOME_ENV_VAR = "JAVA_HOME"; + + public static async Task GetJavaHome() + { + var existingConfig = Environment.GetEnvironmentVariable(JAVA_HOME_ENV_VAR); + if (!string.IsNullOrEmpty(existingConfig)) + { + return existingConfig; + } + + if (CollectorManager.GetCurrentPlatform() != OSPlatform.OSX) + { + // TODO: make this work on windows too! + throw new CliException("Resolving JAVA_HOME only works on OSX right now"); + } + // the user does not have a JAVA_HOME set, so we need to find the one. + + await Cli.Wrap("/usr/libexec/java_home") + .WithStandardOutputPipe(PipeTarget.ToDelegate(x => existingConfig = x)) + .ExecuteAsync(); + return existingConfig; + } + + public static async Task FindDebuggables() + { + var javaHome = await GetJavaHome(); + + var args = + "-v " + // show the JVM arguments + "-l " // show the full main class + ; + + var buffer = new StringBuilder(); + + // https://docs.oracle.com/en/java/javase/11/tools/jps.html + Log.Debug($"Running `jps {args}`"); + var task = await Cli.Wrap("jps") + .WithArguments(args) + .WithEnvironmentVariables(new Dictionary + { + [JAVA_HOME_ENV_VAR] = javaHome + }) + .WithStandardOutputPipe(PipeTarget.ToStringBuilder(buffer)) + .ExecuteAsync(); + + Log.Debug(" " + buffer); + return ExtractJpsData(buffer.ToString()); + } + + public static JpsResult ExtractJpsData(string data) + { + var lines = data.Split(Environment.NewLine, + StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + var results = new JpsResult(); + foreach (var line in lines) + { + var entry = new JpsEntry(); + var i = 0; + var pidBreakIndex = 0; + var mainClassBreakIndex = 0; + + // search for the pid + for (;i < line.Length; i++) + { + if (!char.IsDigit(line, i)) + { + // found the pid! + pidBreakIndex = i; + entry.processId = int.Parse(line.AsSpan(0, pidBreakIndex)); + break; + } + } + + i ++; + + // search for the main class + for (; i < line.Length; i++) + { + if (i == line.Length - 1 || char.IsWhiteSpace(line, i)) + { + // found the main class + mainClassBreakIndex = i; + entry.mainClass = line.AsSpan(pidBreakIndex + 1, i - (pidBreakIndex + 1)).ToString(); + break; + } + } + + i++; + // the rest of the args + var split = CommandLineStringSplitter.Instance.Split(line.Substring(i)).ToList(); + foreach (var option in split) + { + var parts = option.Split('=', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 1) + { + entry.jvmArgs[parts[0]] = ""; + } else if (parts.Length == 2) + { + entry.jvmArgs[parts[0]] = parts[1]; + } + } + + results.entries.Add(entry); + } + + return results; + } + + public static async Task RunMaven( + string args, + string workingDir, + string logPrefix = " ", + bool waitForExit = true, + Action onStdOut=null) + + { + if (onStdOut == null) + { + onStdOut = line => + { + Log.Information(logPrefix + line); + }; + } + var javaHome = await GetJavaHome(); + + Log.Information($"Running `mvn {args}`"); + var task = Cli.Wrap("mvn") + .WithArguments(args) + .WithWorkingDirectory(workingDir) + .WithEnvironmentVariables(new Dictionary + { + [JAVA_HOME_ENV_VAR] = javaHome + }) + .WithStandardOutputPipe(PipeTarget.ToDelegate(onStdOut)) + .WithStandardErrorPipe(PipeTarget.ToDelegate(line => + { + Log.Error(logPrefix + line); + })) + .ExecuteAsync(); + if (waitForExit) + { + await task; + } + } +} + +public class JpsResult +{ + public List entries = new List(); +} + +[DebuggerDisplay("[{processId}] {mainClass}")] +public class JpsEntry +{ + public int processId; + public string mainClass; + public Dictionary jvmArgs = new Dictionary(); +} \ No newline at end of file diff --git a/cli/cli/Commands/BackendCommands/SetupOpenJDKCommand.cs b/cli/cli/Commands/BackendCommands/SetupOpenJDKCommand.cs new file mode 100644 index 0000000000..d7e393a646 --- /dev/null +++ b/cli/cli/Commands/BackendCommands/SetupOpenJDKCommand.cs @@ -0,0 +1,153 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Beamable.Server; +using CliWrap; +using Spectre.Console; + +namespace cli.BackendCommands; + +public class SetupOpenJDKCommandArgs : CommandArgs +{ + +} + +public class SetupOpenJDKCommandResults +{ + +} + +public class SetupOpenJDKCommand : AppCommand +{ + public SetupOpenJDKCommand() : base("setup-jdk", "Set up the openJDK for this machine.") + { + } + + public override void Configure() + { + } + + public override async Task Handle(SetupOpenJDKCommandArgs args) + { + const string downloadSite = "https://adoptium.net/temurin/releases/?version=8"; + + // var client + // CollectorManager.DownloadAndDecompressGzip() + + Log.Information("Checking for java on your system..."); + var hasJava = await CheckJavaOnTerminal(args); + + if (hasJava) + { + Log.Information(" Java was found on your system. Run 'java -version' to verify."); + return; + } + + Log.Information(" Java was not found."); + var link = GetJavaDownloadLink(); + string installerFileName = Path.GetFileName(link); + var downloadPath = Path.Combine(Path.GetTempPath(), installerFileName); + var installerExists = File.Exists(downloadPath); + + if (!installerExists) + { + var download = args.Quiet || AnsiConsole.Confirm("Would you like to download and open the java installer?"); + if (!download) + { + Log.Information("Please install java manually. "); + Log.Information($" temurin: {downloadSite}"); + Log.Information($" direct link: {link}"); + return; + } + + var client = new HttpClient(); + await AnsiConsole.Status().Spinner(Spinner.Known.Aesthetic).StartAsync("Downloading...", async ctx => + { + await CollectorManager.DownloadAndDecompressGzip(client, link, downloadPath, true, decompress: false); + }); + Log.Information("Finished downloading..."); + } + + var runInstall = args.Quiet || AnsiConsole.Confirm("Would you like to run the installer?"); + if (!runInstall) + { + Log.Information("Please install java manually. "); + Log.Information($" temurin: {downloadSite}"); + Log.Information($" direct link: {link}"); + Log.Information($" installer path: {downloadPath}"); + return; + } + + Log.Information("Running java installer..."); + Log.Information(" please complete the installation and re-run this command to verify"); + await RunInstaller(downloadPath); + + Log.Information("all done!"); + } + + public static string GetJavaDownloadLink() + { + // mac + const string macDownload = + "https://github.com/adoptium/temurin8-binaries/releases/download/jdk8u462-b08/OpenJDK8U-jdk_x64_mac_hotspot_8u462b08.pkg"; + + // win + const string winDownload = + "https://github.com/adoptium/temurin8-binaries/releases/download/jdk8u462-b08/OpenJDK8U-jdk_x64_windows_hotspot_8u462b08.msi"; + + var os = CollectorManager.GetCurrentPlatform(); + if (os == OSPlatform.Windows) + { + return winDownload; + } else if (os == OSPlatform.OSX) + { + return macDownload; + } + else + { + throw new CliException( + $"The CLI only supports getting java download links on windows and osx, but the current os=[{os}]"); + } + } + + public static async Task RunInstaller(string installerPath) + { + var startInfo = new ProcessStartInfo(installerPath); + startInfo.UseShellExecute = true; // open it as if + + var process = Process.Start(startInfo); + await process.WaitForExitAsync(); + } + + public static async Task CheckJavaOnTerminal(CommandArgs args) + { + + var command = CliWrap.Cli + .Wrap(args.AppContext.JavaPath) + .WithStandardOutputPipe(PipeTarget.ToDelegate(x => + { + })) + .WithStandardErrorPipe(PipeTarget.ToDelegate(x => + { + })) + .WithArguments("-version") + .WithValidation(CommandResultValidation.None); + + try + { + var res = command.ExecuteAsync(); + await res; + var success = res.Task.Result.ExitCode == 0; + if (!success) + { + return false; + } + + return true; + } + catch (Exception) + { + return false; + } + + } +} \ No newline at end of file diff --git a/cli/cli/Commands/Services/DockerCommands/DockerStatusCommand.cs b/cli/cli/Commands/Services/DockerCommands/DockerStatusCommand.cs index bbfa61d797..bc184599a1 100644 --- a/cli/cli/Commands/Services/DockerCommands/DockerStatusCommand.cs +++ b/cli/cli/Commands/Services/DockerCommands/DockerStatusCommand.cs @@ -2,6 +2,7 @@ using System.CommandLine; using Beamable.Common.Dependencies; using cli.Services; +using cli.Utils; namespace cli.DockerCommands; @@ -77,6 +78,15 @@ public override async Task Handle(DockerStatusCommandArgs args) } + public static async Task RequireDocker(CommandArgs args) + { + var dockerStatus = await DockerStatusCommand.CheckDocker(args); + if (!dockerStatus.isDaemonRunning) + { + throw CliExceptions.DOCKER_NOT_RUNNING; + } + } + public static Task CheckDocker(CommandArgs args) { return CheckDocker(args.AppContext, args.BeamoLocalSystem); diff --git a/cli/cli/Options/DotnetPathOption.cs b/cli/cli/Options/DotnetPathOption.cs index 8121abc6be..6a25ee11b8 100644 --- a/cli/cli/Options/DotnetPathOption.cs +++ b/cli/cli/Options/DotnetPathOption.cs @@ -119,3 +119,107 @@ public static bool TryValidateDockerExec(string candidatePath, out string messag } } + + +public class JavaPathOption : Option +{ + public static readonly JavaPathOption Instance = new JavaPathOption(); + private JavaPathOption() : base( + name: "--java-path", + description: "a custom location for java 8. By default, the CLI will attempt to resolve" + + $" java through its usual install locations. You can also use the {ConfigService.ENV_VAR_JAVA_EXE} " + + "environment variable to specify. ") + { + if (TryGetJavaPath(out var dockerPath, out _)) + { + Description += "\nCurrently, a java path has been automatically identified."; + SetDefaultValue(dockerPath); + } + else + { + if (!string.IsNullOrEmpty(ConfigService.CustomJavaExe)) + { + SetDefaultValue(ConfigService.CustomJavaExe); + } + Description += "\nCurrently, no java path is available, and you must set this option to use docker CLI."; + } + } + + + public static bool TryGetJavaPath(out string javaPath, out string errorMessage) + { + javaPath = ConfigService.CustomJavaExe; + errorMessage = null; + if (!string.IsNullOrEmpty(javaPath)) + { + // the path is specified, so we must use it. + if (!TryValidateJavaExec(javaPath, out var message)) + { + errorMessage = $"specified java path via {ConfigService.ENV_VAR_JAVA_EXE} env var, but {message}"; + return false; + } + + return true; + } + + var paths = new string[] + { + "java", // hopefully its just on the PATH + // TODO: come up with common folders where java lives when installed + }; + foreach (var path in paths) + { + if (!TryValidateJavaExec(path, out _)) + continue; + + javaPath = path; + return true; // yahoo! + } + + errorMessage = + $"java executable not found using common paths. Please specify with {ConfigService.ENV_VAR_JAVA_EXE} env var"; + return false; + + } + + + public static bool TryValidateJavaExec(string candidatePath, out string message) + { + message = null; + Log.Verbose($"testing java candidate=[{candidatePath}]"); + + var command = CliWrap.Cli + .Wrap(candidatePath) + .WithStandardOutputPipe(PipeTarget.ToDelegate(x => + { + Log.Verbose($"java-version-check-stdout=[{x}]"); + })) + .WithStandardErrorPipe(PipeTarget.ToDelegate(x => + { + Log.Verbose($"java-version-check-stderr=[{x}]"); + })) + .WithArguments("-version") + .WithValidation(CommandResultValidation.None); + + try + { + var res = command.ExecuteAsync(); + res.Task.Wait(); + var success = res.Task.Result.ExitCode == 0; + if (!success) + { + message = $"given path=[{candidatePath}] is not a valid java executable"; + return false; + } + + Log.Verbose($"found java path=[{candidatePath}]"); + return true; + } + catch (Exception ex) + { + Log.Verbose($"given path=[{candidatePath}] was not able to invoke java, and threw an error=[{ex.Message}]"); + return false; + } + } + +} diff --git a/cli/cli/Services/ConfigService.cs b/cli/cli/Services/ConfigService.cs index 73eaf78bee..fe4c05e4ee 100644 --- a/cli/cli/Services/ConfigService.cs +++ b/cli/cli/Services/ConfigService.cs @@ -398,6 +398,7 @@ public T LoadDataFile(string fileName, Func defaultValueGenerator) public const string ENV_VAR_DOCKER_URI = "BEAM_DOCKER_URI"; public const string ENV_VAR_BEAM_CLI_IS_REDIRECTED_COMMAND = "BEAM_CLI_IS_REDIRECTED_COMMAND"; public const string ENV_VAR_DOCKER_EXE = "BEAM_DOCKER_EXE"; + public const string ENV_VAR_JAVA_EXE = "BEAM_JAVA_EXE"; public const string CONFIG_FILE_PROJECT_PATH_ROOT = "project-root-path.json"; public const string CONFIG_FILE_EXTRA_PATHS = "additional-project-paths.json"; @@ -416,6 +417,11 @@ public T LoadDataFile(string fileName, Func defaultValueGenerator) /// will make a guess where Docker's exe is, but it can be specified and overwritten with this env var /// public static string CustomDockerExe => Environment.GetEnvironmentVariable(ENV_VAR_DOCKER_EXE); + + /// + /// When managing the BeamableBackend, java needs to be used to run programs. + /// + public static string CustomJavaExe => Environment.GetEnvironmentVariable(ENV_VAR_JAVA_EXE); /// /// Github Action Runners for windows don't seem to work with volumes for mongo. diff --git a/cli/cli/Services/IAppContext.cs b/cli/cli/Services/IAppContext.cs index a4ab29241b..097fc381ee 100644 --- a/cli/cli/Services/IAppContext.cs +++ b/cli/cli/Services/IAppContext.cs @@ -54,6 +54,8 @@ public interface IAppContext : IRealmInfo, IRequesterInfo /// string LocalProjectVersion { get; } string DockerPath { get; } + + string JavaPath { get; } /// /// Control how basic options are found from the console context. @@ -98,6 +100,7 @@ public class DefaultAppContext : IAppContext public string DotnetPath { get; private set; } public string DockerPath { get; private set; } + public string JavaPath { get; private set; } public HashSet IgnoreBeamoIds { get; private set; } @@ -190,6 +193,7 @@ public DefaultAppContext(InvocationContext consoleContext, DryRunOption dryRunOp _skipValidationOption = skipValidationOption; _dotnetPathOption = dotnetPathOption; DockerPath = consoleContext.ParseResult.GetValueForOption(dockerPathOption); + JavaPath = consoleContext.ParseResult.GetValueForOption(JavaPathOption.Instance); IgnoreBeamoIds = new HashSet(consoleContext.ParseResult.GetValueForOption(IgnoreBeamoIdsOption.Instance)); } diff --git a/cli/cli/Utils/Debouncer.cs b/cli/cli/Utils/Debouncer.cs new file mode 100644 index 0000000000..80c9dd530c --- /dev/null +++ b/cli/cli/Utils/Debouncer.cs @@ -0,0 +1,31 @@ +namespace cli.Utils; + +public sealed class Debouncer +{ + private readonly TimeSpan _delay; + private readonly Action _action; + + private Timer? _timer; + private readonly object _lock = new(); + + public Debouncer(TimeSpan delay, Action action) + { + _delay = delay; + _action = action ?? throw new ArgumentNullException(nameof(action)); + } + + /// + /// Schedule the action to run after the debounce delay. + /// If called again before the delay expires, the timer resets. + /// + public void Signal() + { + lock (_lock) + { + _timer?.Change(Timeout.Infinite, Timeout.Infinite); // stop old timer + _timer?.Dispose(); + _timer = new Timer(_ => _action(), null, _delay, Timeout.InfiniteTimeSpan); + } + } + +} \ No newline at end of file diff --git a/cli/cli/cli.csproj b/cli/cli/cli.csproj index 80a57424b1..ab1dbb9dd6 100644 --- a/cli/cli/cli.csproj +++ b/cli/cli/cli.csproj @@ -56,6 +56,8 @@ develop a dependency on it. --> + + @@ -87,6 +89,7 @@ + diff --git a/microservice/beamable.tooling.common/Microservice/CollectorManager.cs b/microservice/beamable.tooling.common/Microservice/CollectorManager.cs index 3525a17474..216a2b7fbe 100644 --- a/microservice/beamable.tooling.common/Microservice/CollectorManager.cs +++ b/microservice/beamable.tooling.common/Microservice/CollectorManager.cs @@ -262,7 +262,7 @@ public static async Task ResolveCollector( }; } - private static async Task DownloadAndDecompressGzip(HttpClient httpClient, string url, string outputPath, bool makeExecutable) + public static async Task DownloadAndDecompressGzip(HttpClient httpClient, string url, string outputPath, bool makeExecutable, bool decompress=true) { Log.Information($"Downloading {url} to {outputPath}"); var folder = Path.GetDirectoryName(outputPath); @@ -271,10 +271,17 @@ private static async Task DownloadAndDecompressGzip(HttpClient httpClient, strin response.EnsureSuccessStatusCode(); using var responseStream = await response.Content.ReadAsStreamAsync(); - using var gzipStream = new GZipStream(responseStream, CompressionMode.Decompress); using var outputFileStream = File.Create(outputPath); - await gzipStream.CopyToAsync(outputFileStream); + if (decompress) + { + using var gzipStream = new GZipStream(responseStream, CompressionMode.Decompress); + await gzipStream.CopyToAsync(outputFileStream); + } + else + { + await responseStream.CopyToAsync(outputFileStream); + } if (makeExecutable) { diff --git a/microservice/microservice/dbmicroservice/MicroserviceBootstrapper.cs b/microservice/microservice/dbmicroservice/MicroserviceBootstrapper.cs index d9169faf47..5a163c2ffd 100644 --- a/microservice/microservice/dbmicroservice/MicroserviceBootstrapper.cs +++ b/microservice/microservice/dbmicroservice/MicroserviceBootstrapper.cs @@ -61,6 +61,7 @@ using OpenTelemetry.Resources; using OpenTelemetry.Trace; using System.Threading; +using Beamable.Api.Autogenerated.Beamootel; using beamable.tooling.common; using ZLogger; using Beamable.Tooling.Common; diff --git a/microservice/microservice/dbmicroservice/ScratchNotes.cs b/microservice/microservice/dbmicroservice/ScratchNotes.cs new file mode 100644 index 0000000000..022fd58f57 --- /dev/null +++ b/microservice/microservice/dbmicroservice/ScratchNotes.cs @@ -0,0 +1,6 @@ +namespace microservice.dbmicroservice; + +public class ScratchNotes +{ + +} \ No newline at end of file