diff --git a/src/Vantage.Core/Logs/ILogProvider.cs b/src/Vantage.Core/Logs/ILogProvider.cs new file mode 100644 index 0000000..64ca46b --- /dev/null +++ b/src/Vantage.Core/Logs/ILogProvider.cs @@ -0,0 +1,8 @@ +using Vantage.Core.Models; + +namespace Vantage.Core.Logs; + +public interface ILogProvider +{ + Task> ReadAsync(HostConfig host, LogSource source, LogQuery query, CancellationToken cancellationToken); +} diff --git a/src/Vantage.Core/Logs/LogCommand.cs b/src/Vantage.Core/Logs/LogCommand.cs new file mode 100644 index 0000000..5e6cd41 --- /dev/null +++ b/src/Vantage.Core/Logs/LogCommand.cs @@ -0,0 +1,33 @@ +using Vantage.Core.Ssh; + +namespace Vantage.Core.Logs; + +public static class LogCommand +{ + public static string Build(LogSource source, LogQuery query) + { + var lines = Math.Clamp(query.Lines, 1, 100_000); + var baseCommand = source.Kind switch + { + LogSourceKind.JournalUnit => BuildJournal(source.Target, query, lines), + LogSourceKind.File => BuildFile(source.Target, lines), + _ => throw new ArgumentOutOfRangeException(nameof(source)) + }; + + if (string.IsNullOrWhiteSpace(query.Filter)) + return baseCommand; + + return baseCommand + " | grep -F -- " + ShellArgs.Quote(query.Filter); + } + + private static string BuildJournal(string unit, LogQuery query, int lines) + { + var command = $"journalctl -u {ShellArgs.Quote(unit)} -n {lines} --no-pager -o short-iso"; + if (!string.IsNullOrWhiteSpace(query.Since)) + command += " --since " + ShellArgs.Quote(query.Since); + return command; + } + + private static string BuildFile(string path, int lines) => + $"tail -n {lines} -- {ShellArgs.Quote(path)}"; +} diff --git a/src/Vantage.Core/Logs/LogLine.cs b/src/Vantage.Core/Logs/LogLine.cs new file mode 100644 index 0000000..d831bf5 --- /dev/null +++ b/src/Vantage.Core/Logs/LogLine.cs @@ -0,0 +1,3 @@ +namespace Vantage.Core.Logs; + +public readonly record struct LogLine(DateTimeOffset? Timestamp, string? Unit, string Message, string Raw); diff --git a/src/Vantage.Core/Logs/LogLineParser.cs b/src/Vantage.Core/Logs/LogLineParser.cs new file mode 100644 index 0000000..aa922d6 --- /dev/null +++ b/src/Vantage.Core/Logs/LogLineParser.cs @@ -0,0 +1,77 @@ +using System.Globalization; + +namespace Vantage.Core.Logs; + +public static class LogLineParser +{ + public static IReadOnlyList Parse(string logOutput) + { + var result = new List(); + + foreach (var rawLine in logOutput.Replace("\r\n", "\n").Split('\n')) + { + if (rawLine.Length == 0) + continue; + result.Add(ParseLine(rawLine)); + } + + return result; + } + + public static LogLine ParseLine(string line) + { + var firstSpace = line.IndexOf(' '); + if (firstSpace <= 0 || !TryParseTimestamp(line[..firstSpace], out var timestamp)) + return new LogLine(null, null, line, line); + + var rest = line[(firstSpace + 1)..]; + var secondSpace = rest.IndexOf(' '); + if (secondSpace <= 0) + return new LogLine(timestamp, null, rest, line); + + var afterHost = rest[(secondSpace + 1)..]; + var colon = afterHost.IndexOf(':'); + if (colon <= 0) + return new LogLine(timestamp, null, afterHost, line); + + var unit = StripPid(afterHost[..colon].Trim()); + var message = afterHost[(colon + 1)..].TrimStart(); + return new LogLine(timestamp, unit, message, line); + } + + private static string StripPid(string unit) + { + var bracket = unit.IndexOf('['); + return bracket > 0 ? unit[..bracket] : unit; + } + + private static bool TryParseTimestamp(string token, out DateTimeOffset timestamp) + { + if (DateTimeOffset.TryParse(token, CultureInfo.InvariantCulture, DateTimeStyles.None, out timestamp)) + return true; + + var withColon = InsertOffsetColon(token); + if (withColon is not null && + DateTimeOffset.TryParse(withColon, CultureInfo.InvariantCulture, DateTimeStyles.None, out timestamp)) + return true; + + timestamp = default; + return false; + } + + private static string? InsertOffsetColon(string token) + { + if (token.Length < 5) + return null; + + var sign = token[^5]; + if (sign != '+' && sign != '-') + return null; + + var offset = token[^4..]; + if (!offset.All(char.IsDigit)) + return null; + + return token[..^2] + ":" + token[^2..]; + } +} diff --git a/src/Vantage.Core/Logs/LogSource.cs b/src/Vantage.Core/Logs/LogSource.cs new file mode 100644 index 0000000..4d3e840 --- /dev/null +++ b/src/Vantage.Core/Logs/LogSource.cs @@ -0,0 +1,27 @@ +namespace Vantage.Core.Logs; + +public enum LogSourceKind +{ + JournalUnit, + File +} + +public sealed class LogSource +{ + public LogSourceKind Kind { get; set; } = LogSourceKind.JournalUnit; + public string Target { get; set; } = ""; + public string Label { get; set; } = ""; + + public static LogSource Journal(string unit, string? label = null) => + new() { Kind = LogSourceKind.JournalUnit, Target = unit, Label = label ?? unit }; + + public static LogSource File(string path, string? label = null) => + new() { Kind = LogSourceKind.File, Target = path, Label = label ?? path }; +} + +public sealed class LogQuery +{ + public int Lines { get; set; } = 200; + public string? Filter { get; set; } + public string? Since { get; set; } +}