Adds log downloading endpoint

This commit is contained in:
Jordan Brown
2020-06-25 16:54:23 -04:00
parent a52a2a3b34
commit 34adf330de
16 changed files with 254 additions and 38 deletions
@@ -1,11 +1,12 @@
using System.ComponentModel.DataAnnotations;
using Tgstation.Server.Api.Models.Internal;
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// Represents a game configuration file. Create and delete actions uncerimonuously overwrite/delete files
/// </summary>
public sealed class ConfigurationFile
public sealed class ConfigurationFile : RawData
{
/// <summary>
/// The path to the <see cref="ConfigurationFile"/> file
@@ -27,12 +28,5 @@ namespace Tgstation.Server.Api.Models
/// The MD5 hash of the file when last read by the user. If this doesn't match during update actions, the write will be denied with <see cref="System.Net.HttpStatusCode.Conflict"/>
/// </summary>
public string? LastReadHash { get; set; }
/// <summary>
/// The content of the <see cref="ConfigurationFile"/>. Will be <see langword="null"/> if <see cref="AccessDenied"/> is <see langword="true"/> or during listing and write operations
/// </summary>
#pragma warning disable CA1819, SA1011 // Properties should not return arrays, Closing square bracket should be followed by a space
public byte[]? Content { get; set; }
#pragma warning restore CA1819, SA1011 // Properties should not return arrays, Closing square bracket should be followed by a space
}
}
@@ -0,0 +1,15 @@
namespace Tgstation.Server.Api.Models.Internal
{
/// <summary>
/// Represents raw bytes.
/// </summary>
public abstract class RawData
{
/// <summary>
/// The bytes of the <see cref="RawData"/>.
/// </summary>
#pragma warning disable CA1819, SA1011 // Properties should not return arrays, Closing square bracket should be followed by a space
public byte[]? Content { get; set; }
#pragma warning restore CA1819, SA1011 // Properties should not return arrays, Closing square bracket should be followed by a space
}
}
@@ -0,0 +1,21 @@
using System;
using Tgstation.Server.Api.Models.Internal;
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// Represents a server log file.
/// </summary>
public sealed class LogFile : RawData
{
/// <summary>
/// The name of the log file.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// The <see cref="DateTimeOffset"/> of when the log file was modified.
/// </summary>
public DateTimeOffset LastModified { get; set; }
}
}
@@ -36,6 +36,11 @@ namespace Tgstation.Server.Api.Rights
/// <summary>
/// User can read info and rights of other users
/// </summary>
ReadUsers = 16
ReadUsers = 16,
/// <summary>
/// User can list and download <see cref="Models.LogFile"/>s.
/// </summary>
DownloadLogs = 32,
}
}
+19
View File
@@ -18,6 +18,11 @@ namespace Tgstation.Server.Api
/// </summary>
public const string Administration = Root + nameof(Models.Administration);
/// <summary>
/// The <see cref="Models.Administration"/> controller
/// </summary>
public const string Logs = Administration + "/Logs";
/// <summary>
/// The <see cref="Models.User"/> controller
/// </summary>
@@ -102,5 +107,19 @@ namespace Tgstation.Server.Api
/// <param name="route">The route</param>
/// <returns>The <paramref name="route"/> with /List appended</returns>
public static string ListRoute(string route) => String.Format(CultureInfo.InvariantCulture, "{0}/{1}", route, List);
/// <summary>
/// Sanitize a <see cref="Models.Internal.RawData"/> path for use in a GET <see cref="Uri"/>.
/// </summary>
/// <param name="path">The path to sanitize.</param>
/// <returns>The sanitized path.</returns>
public static string SanitizeGetPath(string path)
{
if (path == null)
path = String.Empty;
if (path.Length == 0 || path[0] != '/')
path = '/' + path;
return path;
}
}
}
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api;
@@ -31,5 +32,14 @@ namespace Tgstation.Server.Client
/// <inheritdoc />
public Task Restart(CancellationToken cancellationToken) => apiClient.Delete(Routes.Administration, cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<LogFile>> ListLogs(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<LogFile>>(Routes.Logs, cancellationToken);
/// <inheritdoc />
public Task<LogFile> GetLog(LogFile logFile, CancellationToken cancellationToken) => apiClient.Read<LogFile>(
Routes.Logs + Routes.SanitizeGetPath(
logFile?.Name ?? throw new ArgumentNullException(nameof(logFile))),
cancellationToken);
}
}
@@ -20,20 +20,6 @@ namespace Tgstation.Server.Client.Components
/// </summary>
readonly Instance instance;
/// <summary>
/// Sanitize a <see cref="ConfigurationFile"/> path for use in a GET <see cref="Uri"/>
/// </summary>
/// <param name="path">The path to sanitize</param>
/// <returns>The sanitized path</returns>
static string SanitizeGetPath(string path)
{
if (path == null)
path = String.Empty;
if (path.Length == 0 || path[0] != '/')
path = '/' + path;
return path;
}
/// <summary>
/// Construct a <see cref="ConfigurationClient"/>
/// </summary>
@@ -52,7 +38,7 @@ namespace Tgstation.Server.Client.Components
public Task<ConfigurationFile> CreateDirectory(ConfigurationFile directory, CancellationToken cancellationToken) => apiClient.Create<ConfigurationFile, ConfigurationFile>(Routes.Configuration, directory, instance.Id, cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<ConfigurationFile>> List(string directory, CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<ConfigurationFile>>(Routes.ListRoute(Routes.Configuration) + SanitizeGetPath(directory), instance.Id, cancellationToken);
public Task<IReadOnlyList<ConfigurationFile>> List(string directory, CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<ConfigurationFile>>(Routes.ListRoute(Routes.Configuration) + Routes.SanitizeGetPath(directory), instance.Id, cancellationToken);
/// <inheritdoc />
public Task<ConfigurationFile> Read(ConfigurationFile file, CancellationToken cancellationToken)
@@ -60,7 +46,7 @@ namespace Tgstation.Server.Client.Components
if (file == null)
throw new ArgumentNullException(nameof(file));
return apiClient.Read<ConfigurationFile>(
Routes.ConfigurationFile + SanitizeGetPath(file.Path ?? throw new ArgumentException("file.Path should not be null!", nameof(file))),
Routes.ConfigurationFile + Routes.SanitizeGetPath(file.Path ?? throw new ArgumentException("file.Path should not be null!", nameof(file))),
instance.Id,
cancellationToken);
}
@@ -1,4 +1,5 @@
using System.Threading;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
@@ -30,5 +31,20 @@ namespace Tgstation.Server.Client
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task Restart(CancellationToken cancellationToken);
/// <summary>
/// Lists the log files available for download.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in an <see cref="IReadOnlyList{T}"/> of <see cref="LogFile"/> metadata.</returns>
Task<IReadOnlyList<LogFile>> ListLogs(CancellationToken cancellationToken);
/// <summary>
/// Download a given <paramref name="logFile"/>.
/// </summary>
/// <param name="logFile">The <see cref="LogFile"/> to download.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the downloaded <see cref="LogFile"/>.</returns>
Task<LogFile> GetLog(LogFile logFile, CancellationToken cancellationToken);
}
}
@@ -37,7 +37,7 @@ namespace Tgstation.Server.Host.Components.StaticFiles
/// <param name="configurationRelativePath">The relative path in the Configuration directory</param>
/// <param name="systemIdentity">The <see cref="ISystemIdentity"/> for the operation. If <see langword="null"/>, the operation will be performed as the user of the <see cref="Core.Application"/></param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="ConfigurationFile"/>s for the items in the directory. <see cref="ConfigurationFile.Content"/> and <see cref="ConfigurationFile.LastReadHash"/> will both be <see langword="null"/></returns>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="ConfigurationFile"/>s for the items in the directory. <see cref="Api.Models.Internal.RawData.Content"/> and <see cref="ConfigurationFile.LastReadHash"/> will both be <see langword="null"/></returns>
Task<IReadOnlyList<ConfigurationFile>> ListDirectory(string configurationRelativePath, ISystemIdentity systemIdentity, CancellationToken cancellationToken);
/// <summary>
@@ -1,13 +1,16 @@
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using System;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.System;
namespace Tgstation.Server.Host.Configuration
{
/// <summary>
/// File logging configuration options
/// </summary>
sealed class FileLoggingConfiguration
public sealed class FileLoggingConfiguration
{
/// <summary>
/// The key for the <see cref="Microsoft.Extensions.Configuration.IConfigurationSection"/> the <see cref="FileLoggingConfiguration"/> resides in
@@ -45,5 +48,26 @@ namespace Tgstation.Server.Host.Configuration
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public LogLevel MicrosoftLogLevel { get; set; } = DefaultMicrosoftLogLevel;
/// <summary>
/// Gets the evaluated log <see cref="Directory"/>.
/// </summary>
/// <param name="ioManager">The <see cref="IIOManager"/> to use.</param>
/// <param name="assemblyInformationProvider">The <see cref="IAssemblyInformationProvider"/> to use.</param>
/// <returns>The evaluated log <see cref="Directory"/>.</returns>
public string GetFullLogDirectory(IIOManager ioManager, IAssemblyInformationProvider assemblyInformationProvider)
{
if (ioManager == null)
throw new ArgumentNullException(nameof(ioManager));
if (assemblyInformationProvider == null)
throw new ArgumentNullException(nameof(assemblyInformationProvider));
return !String.IsNullOrEmpty(Directory)
? Directory
: ioManager.ConcatPath(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), // common app data is C:/ProgramData on windows, else /usr/share
assemblyInformationProvider.VersionPrefix,
"Logs");
}
}
}
@@ -6,6 +6,7 @@ using Octokit;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading;
@@ -66,6 +67,11 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
readonly GeneralConfiguration generalConfiguration;
/// <summary>
/// The <see cref="FileLoggingConfiguration"/> for the <see cref="AdministrationController"/>
/// </summary>
readonly FileLoggingConfiguration fileLoggingConfiguration;
/// <summary>
/// Construct an <see cref="AdministrationController"/>
/// </summary>
@@ -79,6 +85,7 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/></param>
/// <param name="updatesConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing value of <see cref="updatesConfiguration"/></param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing value of <see cref="generalConfiguration"/></param>
/// <param name="fileLoggingConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing value of <see cref="fileLoggingConfiguration"/></param>
public AdministrationController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
@@ -89,7 +96,8 @@ namespace Tgstation.Server.Host.Controllers
IPlatformIdentifier platformIdentifier,
ILogger<AdministrationController> logger,
IOptions<UpdatesConfiguration> updatesConfigurationOptions,
IOptions<GeneralConfiguration> generalConfigurationOptions)
IOptions<GeneralConfiguration> generalConfigurationOptions,
IOptions<FileLoggingConfiguration> fileLoggingConfigurationOptions)
: base(
databaseContext,
authenticationContextFactory,
@@ -104,6 +112,7 @@ namespace Tgstation.Server.Host.Controllers
this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier));
updatesConfiguration = updatesConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(updatesConfigurationOptions));
generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
fileLoggingConfiguration = fileLoggingConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(fileLoggingConfigurationOptions));
}
ObjectResult RateLimit(RateLimitExceededException exception)
@@ -258,7 +267,7 @@ namespace Tgstation.Server.Host.Controllers
if (model.NewVersion.Major != assemblyInformationProvider.Version.Major)
return BadRequest(new ErrorMessage(ErrorCode.CannotChangeServerSuite));
if(!serverUpdater.WatchdogPresent)
if (!serverUpdater.WatchdogPresent)
return UnprocessableEntity(new ErrorMessage(ErrorCode.MissingHostWatchdog));
return await CheckReleasesAndApplyUpdate(model.NewVersion, cancellationToken).ConfigureAwait(false);
@@ -292,5 +301,88 @@ namespace Tgstation.Server.Host.Controllers
return StatusCode((int)HttpStatusCode.ServiceUnavailable);
}
}
/// <summary>
/// List <see cref="LogFile"/>s present.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200">Listed logs successfully.</response>
/// <response code="409">An IO error occurred while listing.</response>
[HttpGet(Routes.Logs)]
[TgsAuthorize(AdministrationRights.DownloadLogs)]
[ProducesResponseType(typeof(List<LogFile>), 200)]
[ProducesResponseType(typeof(ErrorMessage), 409)]
public async Task<IActionResult> ListLogs(CancellationToken cancellationToken)
{
var path = fileLoggingConfiguration.GetFullLogDirectory(ioManager, assemblyInformationProvider);
try
{
var files = await ioManager.GetFiles(path, cancellationToken).ConfigureAwait(false);
var tasks = files.Select(
async file => new LogFile
{
Name = ioManager.GetFileName(file),
LastModified = await ioManager.GetLastModified(
ioManager.ConcatPath(path, file),
cancellationToken)
.ConfigureAwait(false)
})
.ToList();
await Task.WhenAll(tasks).ConfigureAwait(false);
var result = tasks.Select(x => x.Result).ToList();
return Ok(result);
}
catch (IOException ex)
{
return Conflict(new ErrorMessage(ErrorCode.IOError)
{
AdditionalData = ex.ToString()
});
}
}
/// <summary>
/// Download a <see cref="LogFile"/>.
/// </summary>
/// <param name="path">The path to download.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200">Downloaded <see cref="LogFile"/> successfully.</response>
/// <response code="409">An IO error occurred while downloading.</response>
[HttpGet(Routes.Logs + "/{path}")]
[TgsAuthorize(AdministrationRights.DownloadLogs)]
[ProducesResponseType(typeof(List<LogFile>), 200)]
[ProducesResponseType(typeof(ErrorMessage), 409)]
public async Task<IActionResult> GetLog(string path, CancellationToken cancellationToken)
{
if (path == null)
throw new ArgumentNullException(nameof(path));
var fullPath = ioManager.ConcatPath(
fileLoggingConfiguration.GetFullLogDirectory(ioManager, assemblyInformationProvider),
path);
try
{
var readTask = ioManager.ReadAllBytes(fullPath, cancellationToken);
return Ok(new LogFile
{
Name = path,
LastModified = await ioManager.GetLastModified(fullPath, cancellationToken).ConfigureAwait(false),
Content = await readTask.ConfigureAwait(false)
});
}
catch (IOException ex)
{
return Conflict(new ErrorMessage(ErrorCode.IOError)
{
AdditionalData = ex.ToString()
});
}
}
}
}
@@ -122,13 +122,7 @@ namespace Tgstation.Server.Host.Core
if (postSetupServices.FileLoggingConfiguration.Disable)
return;
// common app data is C:/ProgramData on windows, else /usr/share
var logPath = !String.IsNullOrEmpty(postSetupServices.FileLoggingConfiguration.Directory)
? postSetupServices.FileLoggingConfiguration.Directory
: IOManager.ConcatPath(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
AssemblyInformationProvider.VersionPrefix,
"Logs");
var logPath = postSetupServices.FileLoggingConfiguration.GetFullLogDirectory(IOManager, AssemblyInformationProvider);
var logEventLevel = ConvertSeriLogLevel(postSetupServices.FileLoggingConfiguration.LogLevel);
@@ -222,7 +222,7 @@ namespace Tgstation.Server.Host.IO
public async Task<byte[]> ReadAllBytes(string path, CancellationToken cancellationToken)
{
path = ResolvePath(path);
using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete, DefaultBufferSize, true);
using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, DefaultBufferSize, true);
byte[] buf;
buf = new byte[file.Length];
await file.ReadAsync(buf, 0, (int)file.Length, cancellationToken).ConfigureAwait(false);
@@ -311,5 +311,13 @@ namespace Tgstation.Server.Host.IO
/// <inheritdoc />
public bool PathContainsParentAccess(string path) => path?.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }).Any(x => x == "..") ?? throw new ArgumentNullException(nameof(path));
/// <inheritdoc />
public Task<DateTimeOffset> GetLastModified(string path, CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
{
path = ResolvePath(path ?? throw new ArgumentNullException(nameof(path)));
var fileInfo = new FileInfo(path);
return new DateTimeOffset(fileInfo.LastWriteTimeUtc);
}, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
}
}
@@ -194,5 +194,13 @@ namespace Tgstation.Server.Host.IO
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task ZipToDirectory(string path, byte[] zipFileBytes, CancellationToken cancellationToken);
/// <summary>
/// Get the <see cref="DateTimeOffset"/> of when a given <paramref name="path"/> was last modified.
/// </summary>
/// <param name="path">The path to get metadata for.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="DateTimeOffset"/> of when the file was last modified.</returns>
Task<DateTimeOffset> GetLastModified(string path, CancellationToken cancellationToken);
}
}
@@ -1,5 +1,6 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
@@ -19,7 +20,29 @@ namespace Tgstation.Server.Tests
public async Task Run(CancellationToken cancellationToken)
{
var logsTest = TestLogs(cancellationToken);
await TestRead(cancellationToken).ConfigureAwait(false);
await logsTest;
}
async Task TestLogs(CancellationToken cancellationToken)
{
var logs = await client.ListLogs(cancellationToken);
Assert.AreEqual(1, logs.Count);
var logFile = logs.Single();
Assert.IsNotNull(logFile);
Assert.IsFalse(String.IsNullOrWhiteSpace(logFile.Name));
Assert.IsNull(logFile.Content);
var downloaded = await client.GetLog(logFile, cancellationToken);
Assert.AreEqual(logFile.Name, downloaded.Name);
Assert.IsTrue(logFile.LastModified <= downloaded.LastModified);
Assert.IsNull(logFile.Content);
await ApiAssert.ThrowsException<ConflictException>(() => client.GetLog(new LogFile
{
Name = "very_fake_path.log"
}, cancellationToken), ErrorCode.IOError);
}
async Task TestRead(CancellationToken cancellationToken)
@@ -79,6 +79,7 @@ namespace Tgstation.Server.Tests
String.Format(CultureInfo.InvariantCulture, "General:MinimumPasswordLength={0}", 10),
String.Format(CultureInfo.InvariantCulture, "General:InstanceLimit={0}", 11),
String.Format(CultureInfo.InvariantCulture, "General:UserLimit={0}", 150),
String.Format(CultureInfo.InvariantCulture, "FileLogging:Directory={0}", Path.Combine(Directory, "Logs")),
String.Format(CultureInfo.InvariantCulture, "General:ValidInstancePaths:0={0}", Directory),
"General:ByondTopicTimeout=3000"
};