2022-03-23 14:50:23 -04:00
// Copyright Epic Games, Inc. All Rights Reserved.
2021-05-17 15:02:10 -04:00
2022-03-23 14:50:23 -04:00
using System ;
using System.Collections.Generic ;
using System.Globalization ;
using System.IO ;
using System.Linq ;
using System.Reflection ;
using System.Text.Json ;
using System.Text.Json.Serialization ;
using System.Threading ;
using System.Threading.Tasks ;
2021-05-17 15:02:10 -04:00
using EpicGames.Core ;
2022-03-17 10:06:38 -04:00
using Horde.Build.Acls ;
2022-03-16 11:18:39 -04:00
using Horde.Build.Api ;
using Horde.Build.Collections ;
using Horde.Build.Models ;
using Horde.Build.Notifications ;
2022-03-31 17:22:28 -04:00
using Horde.Build.Services ;
2022-04-05 20:17:50 -04:00
using Horde.Build.Tools ;
2022-03-16 11:18:39 -04:00
using Horde.Build.Utilities ;
2022-03-23 14:50:23 -04:00
using HordeCommon ;
2021-05-17 15:02:10 -04:00
using Microsoft.AspNetCore.StaticFiles ;
2021-08-19 13:39:38 -04:00
using Microsoft.Extensions.Configuration ;
2022-03-23 14:50:23 -04:00
using Microsoft.Extensions.Hosting ;
2021-05-17 15:02:10 -04:00
using Microsoft.Extensions.Logging ;
using Microsoft.Extensions.Options ;
2022-03-31 17:22:28 -04:00
namespace Horde.Build.Server
2021-05-17 15:02:10 -04:00
{
2021-12-14 16:54:30 -05:00
using PoolId = StringId < IPool > ;
2021-08-07 19:19:30 -04:00
using ProjectId = StringId < IProject > ;
using StreamId = StringId < IStream > ;
2021-05-17 15:02:10 -04:00
/// <summary>
/// Polls Perforce for stream config changes
/// </summary>
2021-12-14 16:54:30 -05:00
public sealed class ConfigService : IHostedService , IDisposable
2021-05-17 15:02:10 -04:00
{
2021-07-21 11:23:33 -04:00
const string FileScheme = "file" ;
const string PerforceScheme = "p4-cluster" ;
2021-05-17 15:02:10 -04:00
/// <summary>
/// Config file version number
/// </summary>
2022-03-15 09:52:51 -04:00
const int Version = 10 ;
2022-03-31 17:22:28 -04:00
readonly MongoService _mongoService ;
2022-04-05 20:17:50 -04:00
readonly ToolCollection _toolCollection ;
2022-03-23 14:50:23 -04:00
readonly ProjectService _projectService ;
readonly StreamService _streamService ;
readonly IPerforceService _perforceService ;
readonly INotificationService _notificationService ;
readonly AgentService _agentService ;
readonly PoolService _poolService ;
readonly IOptionsMonitor < ServerSettings > _settings ;
readonly ITicker _ticker ;
readonly ILogger _logger ;
2021-05-17 15:02:10 -04:00
/// <summary>
/// Constructor
/// </summary>
2022-04-05 20:17:50 -04:00
public ConfigService ( MongoService mongoService , IPerforceService perforceService , ToolCollection toolCollection , ProjectService projectService , StreamService streamService , INotificationService notificationService , PoolService poolService , AgentService agentService , IClock clock , IOptionsMonitor < ServerSettings > settings , ILogger < ConfigService > logger )
2021-05-17 15:02:10 -04:00
{
2022-03-31 17:22:28 -04:00
_mongoService = mongoService ;
2022-03-23 14:50:23 -04:00
_perforceService = perforceService ;
2022-04-05 20:17:50 -04:00
_toolCollection = toolCollection ;
2022-03-23 14:50:23 -04:00
_projectService = projectService ;
_streamService = streamService ;
_notificationService = notificationService ;
_poolService = poolService ;
_agentService = agentService ;
_settings = settings ;
2022-03-31 17:22:28 -04:00
if ( mongoService . ReadOnlyMode )
2022-01-13 12:49:40 -05:00
{
2022-03-23 14:50:23 -04:00
_ticker = new NullTicker ( ) ;
2022-01-13 12:49:40 -05:00
}
else
{
2022-03-23 14:50:23 -04:00
_ticker = clock . AddSharedTicker < ConfigService > ( TimeSpan . FromMinutes ( 1.0 ) , TickLeaderAsync , logger ) ;
2022-01-13 12:49:40 -05:00
}
2022-03-23 14:50:23 -04:00
_logger = logger ;
2021-08-19 13:39:38 -04:00
// This will trigger if the local Horde.json user configuration is changed
2022-03-23 14:50:23 -04:00
_settings . OnChange ( OnUserConfigUpdated ) ;
2021-05-17 15:02:10 -04:00
}
2021-12-14 16:54:30 -05:00
/// <inheritdoc/>
2022-03-23 14:50:23 -04:00
public Task StartAsync ( CancellationToken cancellationToken ) = > _ticker . StartAsync ( ) ;
2021-12-14 16:54:30 -05:00
/// <inheritdoc/>
2022-03-23 14:50:23 -04:00
public Task StopAsync ( CancellationToken cancellationToken ) = > _ticker . StopAsync ( ) ;
2021-12-14 16:54:30 -05:00
/// <inheritdoc/>
2022-03-23 14:50:23 -04:00
public void Dispose ( ) = > _ticker . Dispose ( ) ;
2021-12-14 16:54:30 -05:00
2022-03-23 14:50:23 -04:00
GlobalConfig ? _cachedGlobalConfig ;
string? _cachedGlobalConfigRevision ;
Dictionary < ProjectId , ( ProjectConfig Config , string Revision ) > _cachedProjectConfigs = new Dictionary < ProjectId , ( ProjectConfig , string ) > ( ) ;
readonly Dictionary < ProjectId , string? > _cachedLogoRevisions = new Dictionary < ProjectId , string? > ( ) ;
2021-12-14 16:54:30 -05:00
2022-03-23 14:50:23 -04:00
async Task UpdateConfigAsync ( Uri configPath )
2021-05-17 15:02:10 -04:00
{
// Update the globals singleton
2022-03-23 14:50:23 -04:00
GlobalConfig globalConfig ;
2021-05-17 15:02:10 -04:00
for ( ; ; )
{
2022-03-23 14:50:23 -04:00
Dictionary < Uri , string > globalRevisions = await FindRevisionsAsync ( new [ ] { configPath } ) ;
if ( globalRevisions . Count = = 0 )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
throw new Exception ( $"Invalid config path: {configPath}" ) ;
2021-05-17 15:02:10 -04:00
}
2022-03-23 14:50:23 -04:00
string revision = globalRevisions . First ( ) . Value ;
if ( _cachedGlobalConfig = = null | | revision ! = _cachedGlobalConfigRevision )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
_logger . LogInformation ( "Caching global config from {Revision}" , revision ) ;
2021-07-23 19:06:43 -04:00
try
2021-05-26 15:29:08 -04:00
{
2022-03-23 14:50:23 -04:00
_cachedGlobalConfig = await ReadDataAsync < GlobalConfig > ( configPath ) ;
_cachedGlobalConfigRevision = revision ;
2021-07-23 19:06:43 -04:00
}
2022-03-23 14:50:23 -04:00
catch ( Exception ex )
2021-07-23 19:06:43 -04:00
{
2022-03-23 14:50:23 -04:00
await SendFailureNotificationAsync ( ex , configPath ) ;
2021-05-26 15:29:08 -04:00
return ;
}
2021-05-17 15:02:10 -04:00
}
2022-03-23 14:50:23 -04:00
globalConfig = _cachedGlobalConfig ;
2021-05-17 15:02:10 -04:00
2022-03-31 17:22:28 -04:00
Globals globals = await _mongoService . GetGlobalsAsync ( ) ;
2022-03-23 14:50:23 -04:00
if ( globals . ConfigRevision = = revision )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
_logger . LogInformation ( "Updating configuration from {ConfigPath}" , globals . ConfigRevision ) ;
2021-05-17 15:02:10 -04:00
break ;
}
2022-03-23 14:50:23 -04:00
globals . ConfigRevision = revision ;
globals . PerforceClusters = _cachedGlobalConfig . PerforceClusters ;
globals . ScheduledDowntime = _cachedGlobalConfig . Downtime ;
globals . MaxConformCount = _cachedGlobalConfig . MaxConformCount ;
globals . ComputeClusters = _cachedGlobalConfig . Compute ;
globals . RootAcl = Acl . Merge ( null , _cachedGlobalConfig . Acl ) ;
2021-05-17 15:02:10 -04:00
2022-03-31 17:22:28 -04:00
if ( await _mongoService . TryUpdateSingletonAsync ( globals ) )
2021-05-17 15:02:10 -04:00
{
break ;
}
}
2021-12-15 14:47:12 -05:00
// Update the agent rate table
2022-03-23 14:50:23 -04:00
await _agentService . UpdateRateTableAsync ( globalConfig . Rates ) ;
2021-12-15 14:47:12 -05:00
2022-04-05 20:17:50 -04:00
// Update the tools
await _toolCollection . ConfigureAsync ( globalConfig . Tools ) ;
2021-05-17 15:02:10 -04:00
// Projects to remove
2022-03-23 14:50:23 -04:00
List < IProject > projects = await _projectService . GetProjectsAsync ( ) ;
2021-05-17 15:02:10 -04:00
// Get the path to all the project configs
2022-03-23 14:50:23 -04:00
List < ( ProjectConfigRef ProjectRef , Uri Path ) > projectConfigs = globalConfig . Projects . Select ( x = > ( x , CombinePaths ( configPath , x . Path ) ) ) . ToList ( ) ;
2021-05-17 15:02:10 -04:00
2022-03-23 14:50:23 -04:00
Dictionary < ProjectId , ( ProjectConfig Config , string Revision ) > prevCachedProjectConfigs = _cachedProjectConfigs ;
_cachedProjectConfigs = new Dictionary < ProjectId , ( ProjectConfig , string ) > ( ) ;
2021-05-17 15:02:10 -04:00
2022-03-23 14:50:23 -04:00
List < ( ProjectId ProjectId , Uri Path ) > projectLogos = new List < ( ProjectId ProjectId , Uri Path ) > ( ) ;
2021-05-17 15:02:10 -04:00
2022-03-23 14:50:23 -04:00
List < ( ProjectId ProjectId , StreamConfigRef StreamRef , Uri Path ) > streamConfigs = new List < ( ProjectId , StreamConfigRef , Uri ) > ( ) ;
2021-05-17 15:02:10 -04:00
2021-05-26 15:29:08 -04:00
// List of project ids that were not able to be updated. We will avoid removing any existing project or stream definitions for these.
2022-03-23 14:50:23 -04:00
HashSet < ProjectId > skipProjectIds = new HashSet < ProjectId > ( ) ;
2021-05-26 15:29:08 -04:00
2021-05-17 15:02:10 -04:00
// Update any existing projects
2022-03-23 14:50:23 -04:00
Dictionary < Uri , string > projectRevisions = await FindRevisionsAsync ( projectConfigs . Select ( x = > x . Path ) ) ;
for ( int idx = 0 ; idx < projectConfigs . Count ; idx + + )
2021-05-17 15:02:10 -04:00
{
2021-05-26 15:29:08 -04:00
// Make sure we were able to fetch metadata for
2022-03-23 14:50:23 -04:00
( ProjectConfigRef projectRef , Uri projectPath ) = projectConfigs [ idx ] ;
if ( ! projectRevisions . TryGetValue ( projectPath , out string? revision ) )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
_logger . LogWarning ( "Unable to update project {ProjectId} due to missing revision information" , projectRef . Id ) ;
skipProjectIds . Add ( projectRef . Id ) ;
2021-05-26 15:29:08 -04:00
continue ;
}
2021-05-17 15:02:10 -04:00
2022-03-23 14:50:23 -04:00
IProject ? project = projects . FirstOrDefault ( x = > x . Id = = projectRef . Id ) ;
2022-03-31 17:22:28 -04:00
bool update = project = = null | | project . ConfigPath ! = projectPath . ToString ( ) | | project . ConfigRevision ! = revision ;
2021-05-26 15:29:08 -04:00
2022-03-23 14:50:23 -04:00
ProjectConfig ? projectConfig ;
if ( ! update & & prevCachedProjectConfigs . TryGetValue ( projectRef . Id , out ( ProjectConfig Config , string Revision ) result ) & & result . Revision = = revision )
2021-07-26 21:17:27 -04:00
{
2022-03-23 14:50:23 -04:00
projectConfig = result . Config ;
2021-07-26 21:17:27 -04:00
}
else
2021-05-26 15:29:08 -04:00
{
2022-03-23 14:50:23 -04:00
_logger . LogInformation ( "Caching configuration for project {ProjectId} ({Revision})" , projectRef . Id , revision ) ;
2021-07-23 19:06:43 -04:00
try
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
projectConfig = await ReadDataAsync < ProjectConfig > ( projectPath ) ;
if ( update )
2021-07-23 19:06:43 -04:00
{
2022-03-23 14:50:23 -04:00
_logger . LogInformation ( "Updating configuration for project {ProjectId} ({Revision})" , projectRef . Id , revision ) ;
await _projectService . Collection . AddOrUpdateAsync ( projectRef . Id , projectPath . ToString ( ) , revision , idx , projectConfig ) ;
2021-07-23 19:06:43 -04:00
}
}
2022-03-23 14:50:23 -04:00
catch ( Exception ex )
2021-07-23 19:06:43 -04:00
{
2022-03-23 14:50:23 -04:00
await SendFailureNotificationAsync ( ex , projectPath ) ;
skipProjectIds . Add ( projectRef . Id ) ;
2021-05-26 15:29:08 -04:00
continue ;
2021-05-17 15:02:10 -04:00
}
}
2021-05-26 15:29:08 -04:00
2022-03-23 14:50:23 -04:00
if ( projectConfig . Logo ! = null )
2021-05-26 15:29:08 -04:00
{
2022-03-23 14:50:23 -04:00
projectLogos . Add ( ( projectRef . Id , CombinePaths ( projectPath , projectConfig . Logo ) ) ) ;
2021-05-26 15:29:08 -04:00
}
2022-03-23 14:50:23 -04:00
_cachedProjectConfigs [ projectRef . Id ] = ( projectConfig , revision ) ;
streamConfigs . AddRange ( projectConfig . Streams . Select ( x = > ( projectRef . Id , x , CombinePaths ( projectPath , x . Path ) ) ) ) ;
2021-05-17 15:02:10 -04:00
}
// Get the logo revisions
2022-03-23 14:50:23 -04:00
Dictionary < Uri , string > logoRevisions = await FindRevisionsAsync ( projectLogos . Select ( x = > x . Path ) ) ;
for ( int idx = 0 ; idx < projectLogos . Count ; idx + + )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
( ProjectId projectId , Uri path ) = projectLogos [ idx ] ;
if ( logoRevisions . TryGetValue ( path , out string? revision ) )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
string? currentRevision ;
if ( ! _cachedLogoRevisions . TryGetValue ( projectId , out currentRevision ) )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
currentRevision = ( await _projectService . Collection . GetLogoAsync ( projectId ) ) ? . Revision ;
_cachedLogoRevisions [ projectId ] = currentRevision ;
2021-05-17 15:02:10 -04:00
}
2022-03-23 14:50:23 -04:00
if ( revision ! = currentRevision )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
_logger . LogInformation ( "Updating logo for project {ProjectId} ({Revision})" , projectId , revision ) ;
2021-07-23 19:06:43 -04:00
try
{
2022-03-23 14:50:23 -04:00
await _projectService . Collection . SetLogoAsync ( projectId , path . ToString ( ) , revision , GetMimeTypeFromPath ( path ) , await ReadDataAsync ( path ) ) ;
_cachedLogoRevisions [ projectId ] = revision ;
2021-07-23 19:06:43 -04:00
}
2022-03-23 14:50:23 -04:00
catch ( Exception ex )
2021-07-23 19:06:43 -04:00
{
2022-03-23 14:50:23 -04:00
await SendFailureNotificationAsync ( ex , path ) ;
2021-07-23 19:06:43 -04:00
continue ;
}
2021-05-17 15:02:10 -04:00
}
}
}
// Get the current streams
2022-03-23 14:50:23 -04:00
List < IStream > streams = await _streamService . GetStreamsAsync ( ) ;
2021-05-17 15:02:10 -04:00
// Get the revisions for all the stream documents
2022-03-23 14:50:23 -04:00
Dictionary < Uri , string > streamRevisions = await FindRevisionsAsync ( streamConfigs . Select ( x = > x . Path ) ) ;
for ( int idx = 0 ; idx < streamConfigs . Count ; idx + + )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
( ProjectId projectId , StreamConfigRef streamRef , Uri streamPath ) = streamConfigs [ idx ] ;
if ( streamRevisions . TryGetValue ( streamPath , out string? revision ) )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
IStream ? stream = streams . FirstOrDefault ( x = > x . Id = = streamRef . Id ) ;
if ( stream = = null | | stream . ConfigPath ! = streamPath . ToString ( ) | | stream . ConfigRevision ! = revision )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
_logger . LogInformation ( "Updating configuration for stream {StreamRef} ({Revision})" , streamRef . Id , revision ) ;
2021-07-23 19:06:43 -04:00
try
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
StreamConfig streamConfig = await ReadDataAsync < StreamConfig > ( streamPath ) ;
stream = await _streamService . StreamCollection . CreateOrReplaceAsync ( streamRef . Id , stream , streamPath . ToString ( ) , revision , projectId , streamConfig ) ;
2021-07-23 19:06:43 -04:00
}
2022-03-23 14:50:23 -04:00
catch ( Exception ex )
2021-07-23 19:06:43 -04:00
{
2022-03-23 14:50:23 -04:00
await SendFailureNotificationAsync ( ex , streamPath ) ;
2021-07-23 19:06:43 -04:00
continue ;
2021-05-17 15:02:10 -04:00
}
}
}
}
// Remove any projects which are no longer used
2022-03-23 14:50:23 -04:00
HashSet < ProjectId > removeProjectIds = new HashSet < ProjectId > ( projects . Select ( x = > x . Id ) ) ;
removeProjectIds . ExceptWith ( projectConfigs . Select ( y = > y . ProjectRef . Id ) ) ;
2021-05-17 15:02:10 -04:00
2022-03-23 14:50:23 -04:00
foreach ( ProjectId removeProjectId in removeProjectIds )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
_logger . LogInformation ( "Removing project {ProjectId}" , removeProjectId ) ;
await _projectService . DeleteProjectAsync ( removeProjectId ) ;
2021-05-17 15:02:10 -04:00
}
// Remove any streams that are no longer used
2022-03-23 14:50:23 -04:00
HashSet < StreamId > removeStreamIds = new HashSet < StreamId > ( streams . Where ( x = > ! skipProjectIds . Contains ( x . ProjectId ) ) . Select ( x = > x . Id ) ) ;
removeStreamIds . ExceptWith ( streamConfigs . Select ( x = > x . StreamRef . Id ) ) ;
2021-05-17 15:02:10 -04:00
2022-03-23 14:50:23 -04:00
foreach ( StreamId removeStreamId in removeStreamIds )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
_logger . LogInformation ( "Removing stream {StreamId}" , removeStreamId ) ;
await _streamService . DeleteStreamAsync ( removeStreamId ) ;
2021-05-17 15:02:10 -04:00
}
}
2022-03-23 14:50:23 -04:00
static readonly FileExtensionContentTypeProvider s_contentTypeProvider = new FileExtensionContentTypeProvider ( ) ;
2021-05-17 15:02:10 -04:00
2022-03-23 14:50:23 -04:00
static string GetMimeTypeFromPath ( Uri path )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
string? contentType ;
if ( ! s_contentTypeProvider . TryGetContentType ( path . AbsolutePath , out contentType ) )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
contentType = "application/octet-stream" ;
2021-05-17 15:02:10 -04:00
}
2022-03-23 14:50:23 -04:00
return contentType ;
2021-05-17 15:02:10 -04:00
}
2022-03-23 14:50:23 -04:00
static Uri CombinePaths ( Uri baseUri , string path )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
if ( path . StartsWith ( "//" , StringComparison . Ordinal ) )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
if ( baseUri . Scheme = = PerforceScheme )
2021-07-21 11:23:33 -04:00
{
2022-03-23 14:50:23 -04:00
return new Uri ( $"{PerforceScheme}://{baseUri.Host}{path}" ) ;
2021-07-21 11:23:33 -04:00
}
else
{
2022-03-23 14:50:23 -04:00
return new Uri ( $"{PerforceScheme}://{PerforceCluster.DefaultName}{path}" ) ;
2021-07-21 11:23:33 -04:00
}
2021-05-17 15:02:10 -04:00
}
2022-03-23 14:50:23 -04:00
return new Uri ( baseUri , path ) ;
2021-05-17 15:02:10 -04:00
}
2022-03-23 14:50:23 -04:00
async Task < Dictionary < Uri , string > > FindRevisionsAsync ( IEnumerable < Uri > paths )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
Dictionary < Uri , string > revisions = new Dictionary < Uri , string > ( ) ;
2021-05-17 15:02:10 -04:00
// Find all the Perforce uris
2022-03-23 14:50:23 -04:00
List < Uri > perforcePaths = new List < Uri > ( ) ;
foreach ( Uri path in paths )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
if ( path . Scheme = = FileScheme )
2021-07-21 11:23:33 -04:00
{
2022-03-23 14:50:23 -04:00
revisions [ path ] = $"ver={Version},md5={ContentHash.MD5(new FileReference(path.LocalPath))}" ;
2021-07-21 11:23:33 -04:00
}
2022-03-23 14:50:23 -04:00
else if ( path . Scheme = = PerforceScheme )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
perforcePaths . Add ( path ) ;
2021-05-17 15:02:10 -04:00
}
else
{
2022-03-23 14:50:23 -04:00
throw new Exception ( $"Invalid path format: {path}" ) ;
2021-05-17 15:02:10 -04:00
}
}
// Query all the Perforce revisions
2022-03-23 14:50:23 -04:00
foreach ( IGrouping < string , Uri > perforcePath in perforcePaths . GroupBy ( x = > x . Host , StringComparer . OrdinalIgnoreCase ) )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
List < FileSummary > files = await _perforceService . FindFilesAsync ( perforcePath . Key , perforcePath . Select ( x = > x . AbsolutePath ) ) ;
foreach ( FileSummary file in files )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
Uri fileUri = new Uri ( $"{PerforceScheme}://{perforcePath.Key}{file.DepotPath}" ) ;
if ( file . Error = = null )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
revisions [ fileUri ] = $"ver={Version},chg={file.Change},path={fileUri}" ;
2021-05-17 15:02:10 -04:00
}
else
{
2022-03-23 14:50:23 -04:00
_notificationService . NotifyConfigUpdateFailure ( file . Error , file . DepotPath ) ;
2021-05-17 15:02:10 -04:00
}
}
}
2022-03-23 14:50:23 -04:00
return revisions ;
2021-05-17 15:02:10 -04:00
}
2022-03-23 14:50:23 -04:00
async Task < T > ReadDataAsync < T > ( Uri configPath ) where T : class
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
byte [ ] data = await ReadDataAsync ( configPath ) ;
2021-05-17 15:02:10 -04:00
2022-03-23 14:50:23 -04:00
JsonSerializerOptions options = new JsonSerializerOptions ( ) ;
Startup . ConfigureJsonSerializer ( options ) ;
2021-05-17 15:02:10 -04:00
2022-03-23 14:50:23 -04:00
return JsonSerializer . Deserialize < T > ( data , options ) ! ;
2021-05-17 15:02:10 -04:00
}
2022-03-23 14:50:23 -04:00
Task < byte [ ] > ReadDataAsync ( Uri configPath )
2021-05-17 15:02:10 -04:00
{
2022-03-31 17:22:28 -04:00
switch ( configPath . Scheme )
2021-05-17 15:02:10 -04:00
{
2021-07-21 11:23:33 -04:00
case FileScheme :
2022-03-23 14:50:23 -04:00
return File . ReadAllBytesAsync ( configPath . LocalPath ) ;
2021-07-21 11:23:33 -04:00
case PerforceScheme :
2022-03-23 14:50:23 -04:00
return _perforceService . PrintAsync ( configPath . Host , configPath . AbsolutePath ) ;
2021-07-21 11:23:33 -04:00
default :
2022-03-23 14:50:23 -04:00
throw new Exception ( $"Invalid config path: {configPath}" ) ;
2021-05-17 15:02:10 -04:00
}
}
2022-03-23 14:50:23 -04:00
async Task SendFailureNotificationAsync ( Exception ex , Uri configPath )
2021-07-23 19:06:43 -04:00
{
2022-03-23 14:50:23 -04:00
_logger . LogError ( ex , "Unable to read data from {ConfigPath}: {Message}" , configPath , ex . Message ) ;
2021-07-23 19:06:43 -04:00
2022-03-23 14:50:23 -04:00
string fileName = configPath . AbsolutePath ;
int change = - 1 ;
IUser ? author = null ;
string? description = null ;
2021-07-23 19:06:43 -04:00
2022-03-23 14:50:23 -04:00
if ( configPath . Scheme = = PerforceScheme )
2021-07-23 19:06:43 -04:00
{
try
{
2022-03-23 14:50:23 -04:00
List < FileSummary > files = await _perforceService . FindFilesAsync ( configPath . Host , new [ ] { fileName } ) ;
change = files [ 0 ] . Change ;
2021-07-23 19:06:43 -04:00
2022-03-23 14:50:23 -04:00
List < ChangeSummary > changes = await _perforceService . GetChangesAsync ( configPath . Host , change , change , 1 ) ;
if ( changes . Count > 0 & & changes [ 0 ] . Number = = change )
2021-08-19 15:00:46 -04:00
{
2022-03-23 14:50:23 -04:00
( author , description ) = ( changes [ 0 ] . Author , changes [ 0 ] . Description ) ;
2021-08-19 15:00:46 -04:00
}
2021-07-23 19:06:43 -04:00
}
2022-03-23 14:50:23 -04:00
catch ( Exception ex2 )
2021-07-23 19:06:43 -04:00
{
2022-03-23 14:50:23 -04:00
_logger . LogError ( ex2 , "Unable to identify change that last modified {ConfigPath} from Perforce" , configPath ) ;
2021-07-23 19:06:43 -04:00
}
}
2022-03-23 14:50:23 -04:00
_notificationService . NotifyConfigUpdateFailure ( ex . Message , fileName , change , author , description ) ;
2021-07-23 19:06:43 -04:00
}
2021-05-17 15:02:10 -04:00
/// <inheritdoc/>
2022-03-23 14:50:23 -04:00
async ValueTask TickLeaderAsync ( CancellationToken stoppingToken )
2021-05-17 15:02:10 -04:00
{
2021-08-19 13:39:38 -04:00
2022-03-23 14:50:23 -04:00
Uri ? configUri = null ;
2022-03-31 17:22:28 -04:00
2022-03-23 14:50:23 -04:00
if ( Path . IsPathRooted ( _settings . CurrentValue . ConfigPath ) & & ! _settings . CurrentValue . ConfigPath . StartsWith ( "//" , StringComparison . Ordinal ) )
2021-08-19 13:39:38 -04:00
{
// absolute path to config
2022-03-31 17:22:28 -04:00
configUri = new Uri ( _settings . CurrentValue . ConfigPath ) ;
2021-08-19 13:39:38 -04:00
}
2022-03-23 14:50:23 -04:00
else if ( _settings . CurrentValue . ConfigPath ! = null )
2021-08-19 13:39:38 -04:00
{
// relative (development) or perforce path
2022-03-31 17:22:28 -04:00
configUri = CombinePaths ( new Uri ( FileReference . Combine ( Program . AppDir , "_" ) . FullName ) , _settings . CurrentValue . ConfigPath ) ;
2021-08-19 13:39:38 -04:00
}
2022-03-23 14:50:23 -04:00
if ( configUri ! = null )
2021-05-17 15:02:10 -04:00
{
2022-03-23 14:50:23 -04:00
await UpdateConfigAsync ( configUri ) ;
2021-05-17 15:02:10 -04:00
}
}
2021-08-19 13:39:38 -04:00
//
// On premises configuration handling (aka Horde.json settings, though needs to have perforce global config handled too, checkout/modify/submit)
//
/// <summary>
/// Update the global configuration
/// </summary>
2022-03-23 14:50:23 -04:00
/// <param name="request"></param>
2021-08-19 13:39:38 -04:00
/// <returns></returns>
2022-03-23 14:50:23 -04:00
public async Task < bool > UpdateGlobalConfig ( UpdateGlobalConfigRequest request )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
if ( _settings . CurrentValue . ConfigPath = = null | | ! Path . IsPathRooted ( _settings . CurrentValue . ConfigPath ) | | _settings . CurrentValue . ConfigPath . StartsWith ( "//" , StringComparison . Ordinal ) )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
throw new Exception ( $"Global config path must be rooted for service updates. (Perforce paths are not currently supported for live updates), {_settings.CurrentValue.ConfigPath}" ) ;
2021-08-19 13:39:38 -04:00
}
2022-03-31 17:22:28 -04:00
2022-03-23 14:50:23 -04:00
FileReference globalConfigFile = new FileReference ( _settings . CurrentValue . ConfigPath ) ;
DirectoryReference globalConfigDirectory = globalConfigFile . Directory ;
2021-08-19 13:39:38 -04:00
// make sure the directory exists
2022-03-23 14:50:23 -04:00
if ( ! DirectoryReference . Exists ( globalConfigDirectory ) )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
DirectoryReference . CreateDirectory ( globalConfigDirectory ) ;
2021-08-19 13:39:38 -04:00
}
2022-03-23 14:50:23 -04:00
bool configDirty = false ;
2021-08-19 13:39:38 -04:00
// projects
2022-03-23 14:50:23 -04:00
if ( request . ProjectsJson ! = null )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
configDirty = true ;
2021-08-19 13:39:38 -04:00
// write out the projects
2022-03-23 14:50:23 -04:00
foreach ( KeyValuePair < string , string > project in request . ProjectsJson )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
await FileReference . WriteAllTextAsync ( FileReference . Combine ( globalConfigDirectory , project . Key ) , project . Value ) ;
2021-08-19 13:39:38 -04:00
2022-03-23 14:50:23 -04:00
if ( request . ProjectLogo ! = null )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
byte [ ] bytes = Convert . FromBase64String ( request . ProjectLogo ) ;
await FileReference . WriteAllBytesAsync ( FileReference . Combine ( globalConfigDirectory , project . Key . Replace ( ".json" , ".png" , StringComparison . OrdinalIgnoreCase ) ) , bytes ) ;
2021-08-19 13:39:38 -04:00
}
}
}
// streams
2022-03-23 14:50:23 -04:00
if ( request . StreamsJson ! = null )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
configDirty = true ;
2021-08-19 13:39:38 -04:00
// write out the streams
2022-03-23 14:50:23 -04:00
foreach ( KeyValuePair < string , string > stream in request . StreamsJson )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
await FileReference . WriteAllTextAsync ( FileReference . Combine ( globalConfigDirectory , stream . Key ) , stream . Value ) ;
2021-08-19 13:39:38 -04:00
}
}
// update global config path
2022-03-23 14:50:23 -04:00
if ( ! String . IsNullOrEmpty ( request . GlobalsJson ) )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
configDirty = true ;
2021-08-19 13:39:38 -04:00
2022-03-23 14:50:23 -04:00
await FileReference . WriteAllTextAsync ( globalConfigFile , request . GlobalsJson ) ;
2021-08-19 13:39:38 -04:00
}
2022-03-23 14:50:23 -04:00
if ( configDirty = = true )
2022-03-31 17:22:28 -04:00
{
await UpdateConfigAsync ( new Uri ( "file://" + globalConfigFile . ToString ( ) ) ) ;
2021-08-19 13:39:38 -04:00
}
// create the default pool
2022-03-23 14:50:23 -04:00
if ( request . DefaultPoolName ! = null )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
PoolId poolIdValue = PoolId . Sanitize ( request . DefaultPoolName ) ;
2021-08-19 13:39:38 -04:00
2022-03-23 14:50:23 -04:00
IPool ? pool = await _poolService . GetPoolAsync ( poolIdValue ) ;
if ( pool = = null )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
await _poolService . CreatePoolAsync ( request . DefaultPoolName , condition : "OSFamily == 'Windows'" , properties : new Dictionary < string , string > ( ) { [ "Color" ] = "0" } ) ;
2021-08-19 13:39:38 -04:00
}
}
return true ;
}
/// <summary>
/// Update the server settings
/// </summary>
2022-03-23 14:50:23 -04:00
/// <param name="request"></param>
2021-08-19 13:39:38 -04:00
/// <returns></returns>
2022-03-23 14:50:23 -04:00
public async Task < ServerUpdateResponse > UpdateServerSettings ( UpdateServerSettingsRequest request )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
ServerUpdateResponse response = new ServerUpdateResponse ( ) ;
2021-08-19 13:39:38 -04:00
2022-03-23 14:50:23 -04:00
Dictionary < string , object > newUserSettings = new Dictionary < string , object > ( ) ;
2021-08-19 13:39:38 -04:00
try
{
2022-03-23 14:50:23 -04:00
if ( request . Settings = = null | | request . Settings . Count = = 0 )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
return response ;
2021-08-19 13:39:38 -04:00
}
2022-03-23 14:50:23 -04:00
if ( _userConfigUpdated ! = null )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
response . Errors . Add ( "User configuation already being updated" ) ;
return response ;
2021-08-19 13:39:38 -04:00
}
// Load the current user configuration
2022-03-23 14:50:23 -04:00
IConfiguration config = new ConfigurationBuilder ( )
2021-08-19 13:39:38 -04:00
. AddJsonFile ( Program . UserConfigFile . FullName , optional : true )
. Build ( ) ;
2022-03-23 14:50:23 -04:00
ServerSettings userSettings = new ServerSettings ( ) ;
config . GetSection ( "Horde" ) . Bind ( userSettings ) ;
2021-08-19 13:39:38 -04:00
// Figure out current and new settings
2022-03-23 14:50:23 -04:00
HashSet < string > userProps = new HashSet < string > ( StringComparer . OrdinalIgnoreCase ) ;
2021-08-19 13:39:38 -04:00
if ( FileReference . Exists ( Program . UserConfigFile ) )
{
2022-03-23 14:50:23 -04:00
byte [ ] data = await FileReference . ReadAllBytesAsync ( Program . UserConfigFile ) ;
2021-08-19 13:39:38 -04:00
2022-03-23 14:50:23 -04:00
JsonDocument document = JsonDocument . Parse ( data ) ;
JsonElement hordeElement ;
2021-08-19 13:39:38 -04:00
2022-03-23 14:50:23 -04:00
if ( document . RootElement . TryGetProperty ( "Horde" , out hordeElement ) )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
foreach ( JsonProperty property in hordeElement . EnumerateObject ( ) )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
userProps . Add ( property . Name ) ;
2021-08-19 13:39:38 -04:00
}
}
}
2022-03-23 14:50:23 -04:00
foreach ( string name in request . Settings . Keys )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
userProps . Add ( name ) ;
2021-08-19 13:39:38 -04:00
}
// Apply the changes from the request
2022-03-23 14:50:23 -04:00
foreach ( KeyValuePair < string , object > pair in request . Settings )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
PropertyInfo ? property = userSettings . GetType ( ) . GetProperty ( pair . Key , BindingFlags . IgnoreCase | BindingFlags . Public | BindingFlags . Instance ) ;
if ( property = = null )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
response . Errors . Add ( $"Horde configuration property {pair.Key} does not exist when reading server settings" ) ;
_logger . LogError ( "Horde configuration property {Key} does not exist when reading server settings" , pair . Key ) ;
2021-08-19 13:39:38 -04:00
continue ;
}
2022-03-23 14:50:23 -04:00
if ( property . SetMethod = = null )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
response . Errors . Add ( $"Horde configuration property {pair.Key} does not have a set method" ) ;
_logger . LogError ( "Horde configuration property {Key} does not have a set method" , pair . Key ) ;
2021-08-19 13:39:38 -04:00
continue ;
}
// explicit setting removal
2022-03-23 14:50:23 -04:00
if ( pair . Value = = null )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
userProps . Remove ( pair . Key ) ;
2021-08-19 13:39:38 -04:00
continue ;
2022-03-31 17:22:28 -04:00
}
2021-08-19 13:39:38 -04:00
2022-03-23 14:50:23 -04:00
JsonElement element = ( JsonElement ) pair . Value ;
2021-08-19 13:39:38 -04:00
2022-03-23 14:50:23 -04:00
object? value = null ;
2021-08-19 13:39:38 -04:00
2022-03-23 14:50:23 -04:00
switch ( element . ValueKind )
2021-08-19 13:39:38 -04:00
{
case JsonValueKind . True :
2022-03-23 14:50:23 -04:00
value = true ;
2021-08-19 13:39:38 -04:00
break ;
case JsonValueKind . False :
2022-03-23 14:50:23 -04:00
value = false ;
2021-08-19 13:39:38 -04:00
break ;
case JsonValueKind . Number :
2022-03-23 14:50:23 -04:00
value = element . GetDouble ( ) ;
2021-08-19 13:39:38 -04:00
break ;
case JsonValueKind . String :
2022-03-23 14:50:23 -04:00
value = element . GetString ( ) ;
2021-08-19 13:39:38 -04:00
break ;
}
// unable to map type
2022-03-23 14:50:23 -04:00
if ( value = = null )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
response . Errors . Add ( $"Unable to map type for Property {property.Name}" ) ;
2021-08-19 13:39:38 -04:00
continue ;
2022-03-31 17:22:28 -04:00
}
2021-08-19 13:39:38 -04:00
// handle common conversions
2022-03-23 14:50:23 -04:00
if ( property . GetType ( ) ! = value . GetType ( ) )
2021-08-19 13:39:38 -04:00
{
try
{
2022-03-31 17:22:28 -04:00
value = Convert . ChangeType ( value , property . PropertyType , CultureInfo . CurrentCulture ) ;
2021-08-19 13:39:38 -04:00
}
2022-03-23 14:50:23 -04:00
catch ( Exception ex )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
response . Errors . Add ( $"Property {property.Name} raised exception during conversion, {ex.Message}" ) ;
2022-03-31 17:22:28 -04:00
continue ;
2021-08-19 13:39:38 -04:00
}
2022-03-31 17:22:28 -04:00
2022-03-23 14:50:23 -04:00
if ( value = = null )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
response . Errors . Add ( $"Property {property.Name} had null value on conversion" ) ;
2022-03-31 17:22:28 -04:00
continue ;
2021-08-19 13:39:38 -04:00
}
}
2022-03-31 17:22:28 -04:00
else
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
response . Errors . Add ( $"Property {property.Name} is not assignable to {value}" ) ;
2021-08-19 13:39:38 -04:00
continue ;
}
2022-03-31 17:22:28 -04:00
2021-08-19 13:39:38 -04:00
// Set the value, providing some validation
try
{
2022-03-23 14:50:23 -04:00
property . SetMethod . Invoke ( userSettings , new object [ ] { value } ) ;
2021-08-19 13:39:38 -04:00
}
2022-03-23 14:50:23 -04:00
catch ( Exception ex )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
response . Errors . Add ( $"Exception updating property {pair.Key}, {ex.Message}" ) ;
2021-08-19 13:39:38 -04:00
}
}
// Construct the new user settings
2022-03-23 14:50:23 -04:00
foreach ( string name in userProps )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
PropertyInfo ? property = userSettings . GetType ( ) . GetProperty ( name , BindingFlags . IgnoreCase | BindingFlags . Public | BindingFlags . Instance ) ;
if ( property = = null )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
response . Errors . Add ( $"Horde configuration property {name} does not exist when writing server settings" ) ;
_logger . LogError ( "Horde configuration property {Name} does not exist when writing server settings" , name ) ;
2021-08-19 13:39:38 -04:00
continue ;
}
2022-03-23 14:50:23 -04:00
if ( property . GetMethod = = null )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
response . Errors . Add ( $"Horde configuration property {name} does not have a get method" ) ;
_logger . LogError ( "Horde configuration property {Name} does not have a get method" , name ) ;
2021-08-19 13:39:38 -04:00
continue ;
}
2022-03-23 14:50:23 -04:00
object? result = property . GetMethod . Invoke ( userSettings , null ) ;
2021-08-19 13:39:38 -04:00
2022-03-23 14:50:23 -04:00
if ( result = = null )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
response . Errors . Add ( $"Horde configuration property {name} was null while writing and should have already been filtered out" ) ;
_logger . LogError ( "Horde configuration property {Name} was null while writing and should have already been filtered out" , name ) ;
2021-08-19 13:39:38 -04:00
continue ;
}
2022-03-23 14:50:23 -04:00
newUserSettings . Add ( property . Name , result ) ;
2021-08-19 13:39:38 -04:00
}
}
2022-03-23 14:50:23 -04:00
catch ( Exception ex )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
response . Errors . Add ( $"Exception while updating settings: {ex.Message}" ) ;
_logger . LogError ( ex , "{Error}" , response . Errors . Last ( ) ) ;
2021-08-19 13:39:38 -04:00
}
2022-03-23 14:50:23 -04:00
if ( response . Errors . Count ! = 0 )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
return response ;
2021-08-19 13:39:38 -04:00
}
2022-03-23 14:50:23 -04:00
Dictionary < string , object > newLocalSettings = new Dictionary < string , object > ( ) ;
newLocalSettings [ "Horde" ] = newUserSettings ;
2021-08-19 13:39:38 -04:00
try
{
2022-03-23 14:50:23 -04:00
_userConfigUpdated = new TaskCompletionSource < bool > ( ) ;
2021-08-19 13:39:38 -04:00
// This will trigger a setting update as the user config json is set to reload on change
try
{
2022-03-23 14:50:23 -04:00
await FileReference . WriteAllBytesAsync ( Program . UserConfigFile , JsonSerializer . SerializeToUtf8Bytes ( newLocalSettings , new JsonSerializerOptions { WriteIndented = true , DefaultIgnoreCondition = JsonIgnoreCondition . WhenWritingNull } ) ) ;
2021-08-19 13:39:38 -04:00
}
2022-03-23 14:50:23 -04:00
catch ( Exception ex )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
response . Errors . Add ( $"Unable to serialize json settings to {Program.UserConfigFile}, {ex.Message}" ) ;
_logger . LogError ( ex , "Unable to serialize json settings to {ConfigFile}, {Message}" , Program . UserConfigFile . ToString ( ) , ex . Message ) ;
2021-08-19 13:39:38 -04:00
}
2022-03-23 14:50:23 -04:00
if ( response . Errors . Count = = 0 )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
if ( await Task . WhenAny ( _userConfigUpdated . Task , Task . Delay ( 5000 ) ) ! = _userConfigUpdated . Task )
2021-08-19 13:39:38 -04:00
{
2022-03-23 14:50:23 -04:00
response . Errors . Add ( "Server update timed out while awaiting write" ) ;
_logger . LogError ( "Server update timed out after writing config" ) ;
2021-08-19 13:39:38 -04:00
}
}
}
finally
{
2022-03-23 14:50:23 -04:00
_userConfigUpdated = null ;
2021-08-19 13:39:38 -04:00
}
2022-03-23 14:50:23 -04:00
return response ;
2021-08-19 13:39:38 -04:00
}
2022-03-23 14:50:23 -04:00
void OnUserConfigUpdated ( ServerSettings settings , string name )
2021-08-19 13:39:38 -04:00
{
NumUserConfigUpdates + + ;
2022-03-23 14:50:23 -04:00
_userConfigUpdated ? . SetResult ( true ) ;
2021-08-19 13:39:38 -04:00
}
2022-03-23 14:50:23 -04:00
private TaskCompletionSource < bool > ? _userConfigUpdated ;
2021-08-19 13:39:38 -04:00
/// <summary>
/// The number of server configuration updates that have been made while the server is running
/// This is used to detect whether server may need to be restarted
/// </summary>
public int NumUserConfigUpdates { get ; private set ; } = 0 ;
2021-05-17 15:02:10 -04:00
}
}