Skip to content

Commit

Permalink
Add cron schedules for auto updates
Browse files Browse the repository at this point in the history
Closes #1822
  • Loading branch information
Cyberboss committed Jul 2, 2024
1 parent 40d667f commit 1e1b65b
Show file tree
Hide file tree
Showing 22 changed files with 4,685 additions and 25 deletions.
6 changes: 3 additions & 3 deletions build/Version.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
<PropertyGroup>
<TgsCoreVersion>6.5.0</TgsCoreVersion>
<TgsConfigVersion>5.1.0</TgsConfigVersion>
<TgsApiVersion>10.3.0</TgsApiVersion>
<TgsApiVersion>10.4.0</TgsApiVersion>
<TgsCommonLibraryVersion>7.0.0</TgsCommonLibraryVersion>
<TgsApiLibraryVersion>13.3.0</TgsApiLibraryVersion>
<TgsClientVersion>15.3.0</TgsClientVersion>
<TgsApiLibraryVersion>13.4.0</TgsApiLibraryVersion>
<TgsClientVersion>15.4.0</TgsClientVersion>
<TgsDmapiVersion>7.1.2</TgsDmapiVersion>
<TgsInteropVersion>5.9.0</TgsInteropVersion>
<TgsHostWatchdogVersion>1.4.1</TgsHostWatchdogVersion>
Expand Down
9 changes: 9 additions & 0 deletions src/Tgstation.Server.Api/Models/Instance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,18 @@ public abstract class Instance : NamedEntity
/// <summary>
/// The time interval in minutes the repository is automatically pulled and compiles. 0 disables.
/// </summary>
/// <remarks>Auto-updates intervals start counting when set, TGS is started, or from the completion of the previous update. Incompatible with <see cref="AutoUpdateCron"/>.</remarks>
[Required]
public uint? AutoUpdateInterval { get; set; }

/// <summary>
/// A cron expression indicating when auto-updates should trigger. Must be a valid 5-6 part cron schedule (seconds optional). See https://github.com/atifaziz/NCrontab/wiki/Crontab-Expression for details. Empty <see cref="string"/> disables.
/// </summary>
/// <remarks>Updates will not be triggered if the previous update is still running. Incompatible with <see cref="AutoUpdateInterval"/>.</remarks>
[Required]
[StringLength(Limits.MaximumStringLength)]
public string? AutoUpdateCron { get; set; }

/// <summary>
/// The maximum number of chat bots the <see cref="Instance"/> may contain.
/// </summary>
Expand Down
7 changes: 4 additions & 3 deletions src/Tgstation.Server.Host/Components/IInstanceCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,11 @@ public interface IInstanceCore : ILatestCompileJobProvider, IRenameNotifyee
IConfiguration Configuration { get; }

/// <summary>
/// Change the <see cref="Api.Models.Instance.AutoUpdateInterval"/> for the <see cref="IInstanceCore"/>.
/// Change the auto-update timing for the <see cref="IInstanceCore"/>.
/// </summary>
/// <param name="newInterval">The new auto update inteval.</param>
/// <param name="newInterval">The new auto-update inteval.</param>
/// <param name="newCron">The new auto-update cron schedule.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
ValueTask SetAutoUpdateInterval(uint newInterval);
ValueTask ScheduleAutoUpdate(uint newInterval, string? newCron);
}
}
55 changes: 46 additions & 9 deletions src/Tgstation.Server.Host/Components/Instance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

using NCrontab;

using Serilog.Context;

using Tgstation.Server.Api.Rights;
Expand Down Expand Up @@ -183,7 +186,7 @@ public async Task StartAsync(CancellationToken cancellationToken)
using (LogContext.PushProperty(SerilogContextHelper.InstanceIdContextProperty, metadata.Id))
{
await Task.WhenAll(
SetAutoUpdateInterval(metadata.Require(x => x.AutoUpdateInterval)).AsTask(),
ScheduleAutoUpdate(metadata.Require(x => x.AutoUpdateInterval), metadata.AutoUpdateCron).AsTask(),
Configuration.StartAsync(cancellationToken),
EngineManager.StartAsync(cancellationToken),
Chat.StartAsync(cancellationToken),
Expand All @@ -202,7 +205,7 @@ public async Task StopAsync(CancellationToken cancellationToken)
using (LogContext.PushProperty(SerilogContextHelper.InstanceIdContextProperty, metadata.Id))
{
logger.LogDebug("Stopping instance...");
await SetAutoUpdateInterval(0);
await ScheduleAutoUpdate(0, null);
await Watchdog.StopAsync(cancellationToken);
await Task.WhenAll(
Configuration.StopAsync(cancellationToken),
Expand All @@ -213,8 +216,11 @@ public async Task StopAsync(CancellationToken cancellationToken)
}

/// <inheritdoc />
public async ValueTask SetAutoUpdateInterval(uint newInterval)
public async ValueTask ScheduleAutoUpdate(uint newInterval, string? newCron)
{
if (newInterval > 0 && !String.IsNullOrWhiteSpace(newCron))
throw new ArgumentException("Only one of newInterval and newCron may be set!");

Task toWait;
lock (timerLock)
{
Expand All @@ -232,9 +238,9 @@ public async ValueTask SetAutoUpdateInterval(uint newInterval)
}

await toWait;
if (newInterval == 0)
if (newInterval == 0 && String.IsNullOrWhiteSpace(newCron))
{
logger.LogTrace("New auto-update interval is 0. Not starting task.");
logger.LogTrace("Auto-update disabled 0. Not starting task.");
return;
}

Expand All @@ -243,12 +249,12 @@ public async ValueTask SetAutoUpdateInterval(uint newInterval)
// race condition, just quit
if (timerTask != null)
{
logger.LogWarning("Aborting auto update interval change due to race condition!");
logger.LogWarning("Aborting auto-update scheduling change due to race condition!");
return;
}

timerCts = new CancellationTokenSource();
timerTask = TimerLoop(newInterval, timerCts.Token);
timerTask = TimerLoop(newInterval, newCron, timerCts.Token);
}
}

Expand Down Expand Up @@ -484,16 +490,47 @@ async ValueTask UpdateRevInfo(string currentHead, bool onOrigin, IEnumerable<Tes
/// Pull the repository and compile for every set of given <paramref name="minutes"/>.
/// </summary>
/// <param name="minutes">How many minutes the operation should repeat. Does not include running time.</param>
/// <param name="cron">Alternative cron schedule.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
#pragma warning disable CA1502 // TODO: Decomplexify
async Task TimerLoop(uint minutes, CancellationToken cancellationToken)
async Task TimerLoop(uint minutes, string? cron, CancellationToken cancellationToken)
{
logger.LogDebug("Entering auto-update loop");
while (true)
try
{
await asyncDelayer.Delay(TimeSpan.FromMinutes(minutes > Int32.MaxValue ? Int32.MaxValue : minutes), cancellationToken);
TimeSpan delay;
if (cron != null)
{
logger.LogTrace("Using cron schedule: {cron}", cron);
var schedule = CrontabSchedule.Parse(
cron,
new CrontabSchedule.ParseOptions
{
IncludingSeconds = true,
});
var now = DateTime.UtcNow;
var nextOccurrence = schedule.GetNextOccurrence(now);
delay = nextOccurrence - now;
}
else
{
logger.LogTrace("Using interval: {interval}m", minutes);
if (minutes > Int32.MaxValue)
{
logger.LogWarning(
"Auto-update interval is above the maximum limit of {maxMinutes}m. This is likely a user/client error. Truncating to maximum...",
Int32.MaxValue);
minutes = Int32.MaxValue;
}

delay = TimeSpan.FromMinutes(minutes);
}

logger.LogInformation("Next auto-update will occur at {time}", DateTimeOffset.UtcNow + delay);

await asyncDelayer.Delay(delay, cancellationToken);
logger.LogInformation("Beginning auto update...");
await eventConsumer.HandleEvent(EventType.InstanceAutoUpdateStart, Enumerable.Empty<string>(), true, cancellationToken);
try
Expand Down
2 changes: 1 addition & 1 deletion src/Tgstation.Server.Host/Components/InstanceWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public InstanceWrapper()
public ValueTask InstanceRenamed(string newInstanceName, CancellationToken cancellationToken) => Instance.InstanceRenamed(newInstanceName, cancellationToken);

/// <inheritdoc />
public ValueTask SetAutoUpdateInterval(uint newInterval) => Instance.SetAutoUpdateInterval(newInterval);
public ValueTask ScheduleAutoUpdate(uint newInterval, string? newCron) => Instance.ScheduleAutoUpdate(newInterval, newCron);

/// <inheritdoc />
public CompileJob? LatestCompileJob() => Instance.LatestCompileJob();
Expand Down
51 changes: 48 additions & 3 deletions src/Tgstation.Server.Host/Controllers/InstanceController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

using NCrontab;

using Tgstation.Server.Api;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Request;
Expand Down Expand Up @@ -144,6 +146,10 @@ public async ValueTask<IActionResult> Create([FromBody] InstanceCreateRequest mo
if (String.IsNullOrWhiteSpace(model.Name) || String.IsNullOrWhiteSpace(model.Path))
return BadRequest(new ErrorMessageResponse(ErrorCode.InstanceWhitespaceNameOrPath));

IActionResult? earlyOut = ValidateCronSetting(model);
if (earlyOut != null)
return earlyOut;

var unNormalizedPath = model.Path;
var targetInstancePath = NormalizePath(unNormalizedPath);
model.Path = targetInstancePath;
Expand All @@ -166,7 +172,6 @@ bool InstanceIsChildOf(string otherPath)
return Conflict(new ErrorMessageResponse(ErrorCode.InstanceAtConflictingPath));

// Validate it's not a child of any other instance
IActionResult? earlyOut = null;
ulong countOfOtherInstances = 0;
using (var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
{
Expand Down Expand Up @@ -415,9 +420,28 @@ bool CheckModified<T>(Expression<Func<Api.Models.Instance, T>> expression, Insta
}

var oldAutoUpdateInterval = originalModel.AutoUpdateInterval!.Value;
var oldAutoUpdateCron = originalModel.AutoUpdateCron;

var earlyOut = ValidateCronSetting(model);
if (earlyOut != null)
return earlyOut;

var changedAutoInterval = model.AutoUpdateInterval.HasValue && oldAutoUpdateInterval != model.AutoUpdateInterval;
var changedAutoCron = model.AutoUpdateCron != null && oldAutoUpdateCron != model.AutoUpdateCron;

if (changedAutoCron)
{
if (changedAutoInterval)
return BadRequest(new ErrorMessageResponse(ErrorCode.ModelValidationFailure));

if (String.IsNullOrWhiteSpace(model.AutoUpdateCron))
model.AutoUpdateCron = String.Empty;
}

var renamed = model.Name != null && originalModel.Name != model.Name;

if (CheckModified(x => x.AutoUpdateInterval, InstanceManagerRights.SetAutoUpdate)
|| CheckModified(x => x.AutoUpdateCron, InstanceManagerRights.SetAutoUpdate)
|| CheckModified(x => x.ConfigurationType, InstanceManagerRights.SetConfiguration)
|| CheckModified(x => x.Name, InstanceManagerRights.Rename)
|| CheckModified(x => x.Online, InstanceManagerRights.SetOnline)
Expand Down Expand Up @@ -497,13 +521,13 @@ bool CheckModified<T>(Expression<Func<Api.Models.Instance, T>> expression, Insta
api.MoveJob = job.ToApi();
}

if (model.AutoUpdateInterval.HasValue && oldAutoUpdateInterval != model.AutoUpdateInterval)
if (changedAutoInterval || changedAutoCron)
{
// ignoring retval because we don't care if it's offline
await WithComponentInstanceNullable(
async componentInstance =>
{
await componentInstance.SetAutoUpdateInterval(model.AutoUpdateInterval.Value);
await componentInstance.ScheduleAutoUpdate(model.AutoUpdateInterval!.Value, model.AutoUpdateCron);
return null;
},
originalModel);
Expand Down Expand Up @@ -746,6 +770,7 @@ public async ValueTask<IActionResult> GrantPermissions(long id, CancellationToke
Online = false,
Path = initialSettings.Path,
AutoUpdateInterval = initialSettings.AutoUpdateInterval ?? 0,
AutoUpdateCron = initialSettings.AutoUpdateCron ?? String.Empty,
ChatBotLimit = initialSettings.ChatBotLimit ?? Models.Instance.DefaultChatBotLimit,
RepositorySettings = new RepositorySettings
{
Expand Down Expand Up @@ -821,5 +846,25 @@ async ValueTask CheckAccessible(InstanceResponse instanceResponse, CancellationT
.Where(x => x.InstanceId == instanceResponse.Id && x.PermissionSetId == AuthenticationContext.PermissionSet.Id)
.AnyAsync(cancellationToken);
}

/// <summary>
/// Validates a given <paramref name="instance"/>'s <see cref="Api.Models.Instance.AutoUpdateCron"/> setting.
/// </summary>
/// <param name="instance">The <see cref="Api.Models.Instance"/> to validate.</param>
/// <returns><see langword="null"/> if <paramref name="instance"/> has a valid <see cref="Api.Models.Instance.AutoUpdateCron"/> setting, a <see cref="BadRequestObjectResult"/> otherwise.</returns>
BadRequestObjectResult? ValidateCronSetting(Api.Models.Instance instance)
{
if (!String.IsNullOrWhiteSpace(instance.AutoUpdateCron) &&
((instance.AutoUpdateInterval.HasValue && instance.AutoUpdateInterval.Value != 0)
|| (CrontabSchedule.TryParse(
instance.AutoUpdateCron,
new CrontabSchedule.ParseOptions
{
IncludingSeconds = true,
}) == null)))
return BadRequest(new ErrorMessageResponse(ErrorCode.ModelValidationFailure));

return null;
}
}
}
8 changes: 4 additions & 4 deletions src/Tgstation.Server.Host/Database/DatabaseContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -375,22 +375,22 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
/// <summary>
/// Used by unit tests to remind us to setup the correct MSSQL migration downgrades.
/// </summary>
internal static readonly Type MSLatestMigration = typeof(MSSwitchTo64BitDeploymentIds);
internal static readonly Type MSLatestMigration = typeof(MSAddCronAutoUpdates);

/// <summary>
/// Used by unit tests to remind us to setup the correct MYSQL migration downgrades.
/// </summary>
internal static readonly Type MYLatestMigration = typeof(MYSwitchTo64BitDeploymentIds);
internal static readonly Type MYLatestMigration = typeof(MYAddCronAutoUpdates);

/// <summary>
/// Used by unit tests to remind us to setup the correct PostgresSQL migration downgrades.
/// </summary>
internal static readonly Type PGLatestMigration = typeof(PGSwitchTo64BitDeploymentIds);
internal static readonly Type PGLatestMigration = typeof(PGAddCronAutoUpdates);

/// <summary>
/// Used by unit tests to remind us to setup the correct SQLite migration downgrades.
/// </summary>
internal static readonly Type SLLatestMigration = typeof(SLAddCompilerAdditionalArguments);
internal static readonly Type SLLatestMigration = typeof(SLAddCronAutoUpdates);

/// <inheritdoc />
#pragma warning disable CA1502 // Cyclomatic complexity
Expand Down
Loading

0 comments on commit 1e1b65b

Please sign in to comment.