diff --git a/src/Tgstation.Server.Api/Models/Byond.cs b/src/Tgstation.Server.Api/Models/Byond.cs index 17f5b1456c..9eebf4ddd1 100644 --- a/src/Tgstation.Server.Api/Models/Byond.cs +++ b/src/Tgstation.Server.Api/Models/Byond.cs @@ -14,5 +14,11 @@ namespace Tgstation.Server.Api.Models /// [Permissions(ReadRight = ByondRights.ReadActive, WriteRight = ByondRights.ChangeVersion)] public Version Version { get; set; } + + /// + /// The being used to install a new + /// + [Permissions(DenyWrite = true)] + public Job InstallJob { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/Repository.cs b/src/Tgstation.Server.Api/Models/Repository.cs index 3cec95577c..981c77aa94 100644 --- a/src/Tgstation.Server.Api/Models/Repository.cs +++ b/src/Tgstation.Server.Api/Models/Repository.cs @@ -32,6 +32,12 @@ namespace Tgstation.Server.Api.Models [Permissions(DenyWrite = true)] public bool? IsGitHub { get; set; } + /// + /// The started by the if any + /// + [Permissions(DenyWrite = true)] + public Job ActiveJob { get; set; } + /// /// Do the equivalent of a git pull. Will attempt to merge unless is also specified /// diff --git a/src/Tgstation.Server.Api/Rights/ByondRights.cs b/src/Tgstation.Server.Api/Rights/ByondRights.cs index ae650b69af..ed08ed282a 100644 --- a/src/Tgstation.Server.Api/Rights/ByondRights.cs +++ b/src/Tgstation.Server.Api/Rights/ByondRights.cs @@ -17,8 +17,16 @@ namespace Tgstation.Server.Api.Rights /// ReadActive = 1, /// - /// User may change to any BYOND version + /// User may list all installed BYOND versions /// - ChangeVersion = 4 + ListInstalled = 2, + /// + /// User may change the active BYOND version + /// + ChangeVersion = 4, + /// + /// User may cancel version installations + /// + CancelInstall = 8 } } diff --git a/src/Tgstation.Server.Api/Rights/RepositoryRights.cs b/src/Tgstation.Server.Api/Rights/RepositoryRights.cs index fa86528d28..5eb33c11b7 100644 --- a/src/Tgstation.Server.Api/Rights/RepositoryRights.cs +++ b/src/Tgstation.Server.Api/Rights/RepositoryRights.cs @@ -55,6 +55,14 @@ namespace Tgstation.Server.Api.Rights /// /// User may delete the /// - Delete = 2048 + Delete = 2048, + /// + /// User may cancel clone operations + /// + CancelClone = 4096, + /// + /// User may cancel synchronize operations + /// + CancelSynchronize = 8192 } } diff --git a/src/Tgstation.Server.Host/Components/Byond/ByondManager.cs b/src/Tgstation.Server.Host/Components/Byond/ByondManager.cs index f6f0c44790..6e5079e76c 100644 --- a/src/Tgstation.Server.Host/Components/Byond/ByondManager.cs +++ b/src/Tgstation.Server.Host/Components/Byond/ByondManager.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Host.Core; using Tgstation.Server.Host.IO; namespace Tgstation.Server.Host.Components.Byond @@ -20,6 +21,16 @@ namespace Tgstation.Server.Host.Components.Byond /// public Version ActiveVersion { get; private set; } + /// + public IReadOnlyList InstalledVersions + { + get + { + lock (installedVersions) + return installedVersions.Select(x => Version.Parse(x.Key)).ToList(); + } + } + /// /// The for the /// @@ -40,6 +51,13 @@ namespace Tgstation.Server.Host.Components.Byond /// readonly Dictionary installedVersions; + /// + /// The for the + /// + readonly SemaphoreSlim semaphore; + + static string VersionKey(Version version) => new Version(version.Major, version.Minor).ToString(); + /// /// Construct a /// @@ -53,9 +71,11 @@ namespace Tgstation.Server.Host.Components.Byond this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); installedVersions = new Dictionary(); + semaphore = new SemaphoreSlim(1); } - static string VersionKey(Version version) => new Version(version.Major, version.Minor).ToString(); + /// + public void Dispose() => semaphore.Dispose(); async Task InstallVersion(Version version, CancellationToken cancellationToken) { @@ -95,8 +115,11 @@ namespace Tgstation.Server.Host.Components.Byond public async Task ChangeVersion(Version version, CancellationToken cancellationToken) { await InstallVersion(version, cancellationToken).ConfigureAwait(false); - await ioManager.WriteAllBytes(ActiveVersionFileName, Encoding.UTF8.GetBytes(version.ToString()), cancellationToken).ConfigureAwait(false); - ActiveVersion = version; + using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false)) + { + await ioManager.WriteAllBytes(ActiveVersionFileName, Encoding.UTF8.GetBytes(version.ToString()), cancellationToken).ConfigureAwait(false); + ActiveVersion = version; + } } /// diff --git a/src/Tgstation.Server.Host/Components/Byond/IByondManager.cs b/src/Tgstation.Server.Host/Components/Byond/IByondManager.cs index 70fd934081..767d15537e 100644 --- a/src/Tgstation.Server.Host/Components/Byond/IByondManager.cs +++ b/src/Tgstation.Server.Host/Components/Byond/IByondManager.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Hosting; using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -8,13 +9,18 @@ namespace Tgstation.Server.Host.Components.Byond /// /// For managing the BYOND installation /// - public interface IByondManager : IHostedService + public interface IByondManager : IHostedService, IDisposable { /// /// The currently active BYOND version /// Version ActiveVersion { get; } + /// + /// The installed BYOND versions + /// + IReadOnlyList InstalledVersions { get; } + /// /// Change the active BYOND version /// diff --git a/src/Tgstation.Server.Host/Components/Repository/Repository.cs b/src/Tgstation.Server.Host/Components/Repository/Repository.cs index 13fdf97475..11e516ee2a 100644 --- a/src/Tgstation.Server.Host/Components/Repository/Repository.cs +++ b/src/Tgstation.Server.Host/Components/Repository/Repository.cs @@ -12,6 +12,11 @@ namespace Tgstation.Server.Host.Components.Repository /// sealed class Repository : IRepository { + /// + /// Indication of a GitHub repository + /// + public const string GitHubUrl = "://github.com/"; + const string UnknownReference = ""; /// @@ -86,7 +91,7 @@ namespace Tgstation.Server.Host.Components.Repository this.ioMananger = ioMananger ?? throw new ArgumentNullException(nameof(ioMananger)); this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer)); this.onDispose = onDispose ?? throw new ArgumentNullException(nameof(onDispose)); - IsGitHubRepository = Origin.ToUpperInvariant().Contains("://GITHUB.COM/"); + IsGitHubRepository = Origin.ToUpperInvariant().Contains(GitHubUrl.ToUpperInvariant()); if (IsGitHubRepository) { GetRepositoryOwnerName(Origin, out var owner, out var name); diff --git a/src/Tgstation.Server.Host/Components/Repository/RepositoryManager.cs b/src/Tgstation.Server.Host/Components/Repository/RepositoryManager.cs index ecaf3c5d98..25cd954457 100644 --- a/src/Tgstation.Server.Host/Components/Repository/RepositoryManager.cs +++ b/src/Tgstation.Server.Host/Components/Repository/RepositoryManager.cs @@ -53,28 +53,38 @@ namespace Tgstation.Server.Host.Components.Repository { using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false)) if (!await ioManager.DirectoryExists(".", cancellationToken).ConfigureAwait(false)) - { - await DeleteRepository(cancellationToken).ConfigureAwait(false); - - await Task.Factory.StartNew(() => + try + { + await DeleteRepository(cancellationToken).ConfigureAwait(false); + + await Task.Factory.StartNew(() => + { + string path = null; + try + { + path = LibGit2Sharp.Repository.Clone(Repository.GenerateAuthUrl(url.ToString(), accessString), ioManager.ResolvePath("."), new CloneOptions + { + OnProgress = (a) => !cancellationToken.IsCancellationRequested, + OnTransferProgress = (a) => !cancellationToken.IsCancellationRequested, + RecurseSubmodules = true, + OnUpdateTips = (a, b, c) => !cancellationToken.IsCancellationRequested, + RepositoryOperationStarting = (a) => !cancellationToken.IsCancellationRequested, + BranchName = initialBranch + }); + } + catch (UserCancelledException) { } + cancellationToken.ThrowIfCancellationRequested(); + }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false); + } + catch { - string path = null; try { - path = LibGit2Sharp.Repository.Clone(Repository.GenerateAuthUrl(url.ToString(), accessString), ioManager.ResolvePath("."), new CloneOptions - { - OnProgress = (a) => !cancellationToken.IsCancellationRequested, - OnTransferProgress = (a) => !cancellationToken.IsCancellationRequested, - RecurseSubmodules = true, - OnUpdateTips = (a, b, c) => !cancellationToken.IsCancellationRequested, - RepositoryOperationStarting = (a) => !cancellationToken.IsCancellationRequested, - BranchName = initialBranch - }); + await ioManager.DeleteDirectory(".", default).ConfigureAwait(false); } - catch (UserCancelledException) { } - cancellationToken.ThrowIfCancellationRequested(); - }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false); - } + catch { } + throw; + } return await LoadRepository(cancellationToken).ConfigureAwait(false); } diff --git a/src/Tgstation.Server.Host/Controllers/ByondController.cs b/src/Tgstation.Server.Host/Controllers/ByondController.cs new file mode 100644 index 0000000000..5d731e6060 --- /dev/null +++ b/src/Tgstation.Server.Host/Controllers/ByondController.cs @@ -0,0 +1,108 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Components; +using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Controllers +{ + /// + /// Controller for managing s + /// + [Route("/" + nameof(Byond))] + public sealed class ByondController : ModelController + { + /// + /// The for the + /// + readonly IInstanceManager instanceManager; + + /// + /// The for the + /// + readonly IJobManager jobManager; + + /// + /// The for the + /// + readonly ILogger logger; + + /// + /// Construct a + /// + /// The for the + /// The for the + /// The value of + /// The value of + /// The value of + public ByondController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, IJobManager jobManager, ILogger logger) : base(databaseContext, authenticationContextFactory, true) + { + this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); + this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + [TgsAuthorize(ByondRights.ReadActive)] + public override Task Read(CancellationToken cancellationToken) => Task.FromResult((IActionResult) + Json(new Api.Models.Byond + { + Version = instanceManager.GetInstance(Instance).ByondManager.ActiveVersion + })); + + /// + [TgsAuthorize(ByondRights.ListInstalled)] + public override Task List(CancellationToken cancellationToken) => Task.FromResult((IActionResult) + Json(instanceManager.GetInstance(Instance).ByondManager.InstalledVersions.Select(x => new Api.Models.Byond + { + Version = x + }))); + + /// + [TgsAuthorize(ByondRights.ChangeVersion)] + public override async Task Update([FromBody] Api.Models.Byond model, CancellationToken cancellationToken) + { + if (model == null) + return BadRequest(new { message = "Missing request model!" }); + + if(model.Version == null) + return BadRequest(new { message = "Missing version!" }); + + var byondManager = instanceManager.GetInstance(Instance).ByondManager; + + //remove cruff fields + var installingVersion = new Version(model.Version.Major, model.Version.Major); + + var result = new Api.Models.Byond(); + + if (byondManager.InstalledVersions.Any(x => x == model.Version)) + { + logger.LogInformation("User ID {0} changing instance ID {1} BYOND version to {2}", AuthenticationContext.User.Id, Instance.Id, installingVersion); + await byondManager.ChangeVersion(model.Version, cancellationToken).ConfigureAwait(false); + } + else + { + logger.LogInformation("User ID {0} installing BYOND version to {2} on instance ID {1}", AuthenticationContext.User.Id, Instance.Id, installingVersion); + //run the install through the job manager + var job = new Models.Job + { + StartedBy = AuthenticationContext.User, + CancelRightsType = RightsType.Byond, + CancelRight = (int)ByondRights.CancelInstall, + Instance = Instance + }; + await jobManager.RegisterOperation(job, (paramJob, serviceProvicer, ct) => byondManager.ChangeVersion(installingVersion, ct), cancellationToken).ConfigureAwait(false); + result.InstallJob = job.ToApi(); + } + result.Version = byondManager.ActiveVersion; + return Json(result); + } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs index 0167d49112..323e073e00 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs @@ -55,15 +55,16 @@ namespace Tgstation.Server.Host.Controllers if (instance.Watchdog.Running) return StatusCode((int)HttpStatusCode.Gone); - await jobManager.RegisterOperation(new Models.Job + var job = new Models.Job { Description = "Launch DreamDaemon", CancelRight = (int)DreamDaemonRights.Shutdown, CancelRightsType = RightsType.DreamDaemon, Instance = Instance, StartedBy = AuthenticationContext.User - }, - async (job, serviceProvider, innerCt) => + }; + await jobManager.RegisterOperation(job, + async (paramJob, serviceProvider, innerCt) => { var result = await instance.Watchdog.Launch(innerCt).ConfigureAwait(false); if (result == null) @@ -72,7 +73,7 @@ namespace Tgstation.Server.Host.Controllers throw new Exception("Failed to launch watchdog!"); }, cancellationToken).ConfigureAwait(false); - return Ok(); + return Json(job); } /// diff --git a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs index 11a20fa482..aeac86e77c 100644 --- a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs +++ b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Linq; @@ -38,6 +39,11 @@ namespace Tgstation.Server.Host.Controllers /// readonly IJobManager jobManager; + /// + /// The for the + /// + readonly ILogger logger; + /// /// Construct a /// @@ -46,11 +52,13 @@ namespace Tgstation.Server.Host.Controllers /// The value of /// The value of /// The value of - public RepositoryController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, Octokit.IGitHubClient gitHubClient, IJobManager jobManager) : base(databaseContext, authenticationContextFactory, true) + /// The value of + public RepositoryController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, Octokit.IGitHubClient gitHubClient, IJobManager jobManager, ILogger logger) : base(databaseContext, authenticationContextFactory, true) { this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); this.gitHubClient = gitHubClient ?? throw new ArgumentNullException(nameof(gitHubClient)); this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } static string GetAccessString(Api.Models.Internal.RepositorySettings repositorySettings) => repositorySettings.AccessUser != null ? String.Concat(repositorySettings.AccessUser, '@', repositorySettings.AccessToken) : null; @@ -115,6 +123,13 @@ namespace Tgstation.Server.Host.Controllers if (currentModel == default) return StatusCode((int)HttpStatusCode.Gone); + //normalize github urls + const string BadGitHubUrl = "://www.github.com/"; + var uiOrigin = model.Origin.ToUpperInvariant(); + var uiBad = BadGitHubUrl.ToUpperInvariant(); + var uiGitHub = Components.Repository.Repository.GitHubUrl.ToUpperInvariant(); + if (uiOrigin.Contains(uiBad)) + model.Origin = uiOrigin.Replace(uiBad, uiGitHub); currentModel.AccessToken = model.AccessToken; currentModel.AccessUser = model.AccessUser; //intentionally only these fields, user not allowed to change anything else atm @@ -122,15 +137,37 @@ namespace Tgstation.Server.Host.Controllers var origin = model.Origin; var repoManager = instanceManager.GetInstance(Instance).RepositoryManager; - using (var repo = await repoManager.CloneRepository(new Uri(origin), cloneBranch, GetAccessString(currentModel), cancellationToken).ConfigureAwait(false)) + + using (var repo = await repoManager.LoadRepository(cancellationToken).ConfigureAwait(false)) { if (repo == null) //clone conflict return Conflict(); + + var job = new Models.Job + { + Description = "Clone repository", + StartedBy = AuthenticationContext.User, + CancelRightsType = RightsType.Repository, + CancelRight = (int)RepositoryRights.CancelClone, + Instance = Instance + }; var api = currentModel.ToApi(); - await PopulateApi(api, repo, null, null, cancellationToken).ConfigureAwait(false); - currentModel.LastOriginCommitSha = repo.Head; - await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); + await jobManager.RegisterOperation(job, async (paramJob, serviceProvider, ct) => + { + using (var repos = await repoManager.CloneRepository(new Uri(origin), cloneBranch, GetAccessString(currentModel), cancellationToken).ConfigureAwait(false)) + { + if (repos == null) + throw new Exception("Filesystem conflict while cloning repository!"); + await PopulateApi(api, repo, null, null, cancellationToken).ConfigureAwait(false); + } + }, cancellationToken).ConfigureAwait(false); + + api.Origin = model.Origin; + api.Reference = model.Reference; + api.IsGitHub = model.Origin.ToUpperInvariant().Contains(uiGitHub); + api.ActiveJob = job.ToApi(); + return Json(api); } } @@ -154,7 +191,17 @@ namespace Tgstation.Server.Host.Controllers await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); - await instanceManager.GetInstance(Instance).RepositoryManager.DeleteRepository(cancellationToken).ConfigureAwait(false); + logger.LogInformation("Instance {0} repository delete initiated by user {1}", Instance.Id, AuthenticationContext.User.Id); + + var job = new Models.Job + { + Description = "Delete repository", + StartedBy = AuthenticationContext.User, + Instance = Instance + }; + var api = currentModel.ToApi(); + await jobManager.RegisterOperation(job, (paramJob, serviceProvider, ct) => instanceManager.GetInstance(Instance).RepositoryManager.DeleteRepository(cancellationToken), cancellationToken).ConfigureAwait(false); + api.ActiveJob = job.ToApi(); return Ok(); } @@ -341,7 +388,9 @@ namespace Tgstation.Server.Host.Controllers { Description = "Synchronize repository changes", StartedBy = AuthenticationContext.User, - Instance = Instance + Instance = Instance, + CancelRightsType = RightsType.Repository, + CancelRight = (int)RepositoryRights.CancelSynchronize, }; await jobManager.RegisterOperation(job, async (paramJob, serviceProvider, ct) => { @@ -349,6 +398,8 @@ namespace Tgstation.Server.Host.Controllers if (repos != null) await repos.Sychronize(accessString, ct).ConfigureAwait(false); }, cancellationToken).ConfigureAwait(false); + + api.ActiveJob = job.ToApi(); } return Json(api); diff --git a/src/Tgstation.Server.Host/Core/JobManager.cs b/src/Tgstation.Server.Host/Core/JobManager.cs index 2f0dcc2a94..c99672c654 100644 --- a/src/Tgstation.Server.Host/Core/JobManager.cs +++ b/src/Tgstation.Server.Host/Core/JobManager.cs @@ -104,6 +104,19 @@ namespace Tgstation.Server.Host.Core { var databaseContext = scope.ServiceProvider.GetRequiredService(); job.StartedAt = DateTimeOffset.Now; + job.Instance = new Instance + { + Id = job.Instance.Id + }; + databaseContext.Instances.Attach(job.Instance); + if (job.StartedBy != null) + { + job.StartedBy = new User + { + Id = job.StartedBy.Id + }; + databaseContext.Users.Attach(job.StartedBy); + } databaseContext.Jobs.Add(job); await databaseContext.Save(cancellationToken).ConfigureAwait(false); var jobHandler = JobHandler.Create(x => RunJob(job, operation, x));