diff --git a/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs b/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs index 09d60121a..fff309eae 100644 --- a/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs +++ b/dotnet/src/dotnetcore/GxNetCoreStartup/Startup.cs @@ -17,6 +17,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; @@ -32,6 +33,7 @@ using Microsoft.Extensions.Caching.SqlServer; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; using StackExchange.Redis; @@ -41,6 +43,7 @@ namespace GeneXus.Application public class Program { const string DEFAULT_PORT = "80"; + const int GRACEFUL_SHUTDOWN_DELAY_SECONDS = 30; static string DEFAULT_SCHEMA = Uri.UriSchemeHttp; public static void Main(string[] args) @@ -78,13 +81,14 @@ public static void Main(string[] args) { Console.Error.WriteLine("ERROR:"); Console.Error.WriteLine("Web Host terminated unexpectedly: {0}", e.Message); - } + } } public static IWebHost BuildWebHost(string[] args) => WebHost.CreateDefaultBuilder(args) .UseStartup() .UseContentRoot(Startup.LocalPath) + .UseShutdownTimeout(TimeSpan.FromSeconds(GRACEFUL_SHUTDOWN_DELAY_SECONDS)) .Build(); public static IWebHost BuildWebHostPort(string[] args, string port) @@ -94,11 +98,12 @@ public static IWebHost BuildWebHostPort(string[] args, string port) static IWebHost BuildWebHostPort(string[] args, string port, string schema) { return WebHost.CreateDefaultBuilder(args) - .UseUrls($"{schema}://*:{port}") - .UseStartup() - .UseWebRoot(Startup.LocalPath) - .UseContentRoot(Startup.LocalPath) - .Build(); + .UseUrls($"{schema}://*:{port}") + .UseStartup() + .UseWebRoot(Startup.LocalPath) + .UseContentRoot(Startup.LocalPath) + .UseShutdownTimeout(TimeSpan.FromSeconds(GRACEFUL_SHUTDOWN_DELAY_SECONDS)) + .Build(); } private static void LocatePhysicalLocalPath() @@ -123,15 +128,15 @@ public static IApplicationBuilder UseGXHandlerFactory(this IApplicationBuilder b public static IApplicationBuilder MapWebSocketManager(this IApplicationBuilder app, string basePath) { return app - .Map($"{basePath}/gxwebsocket" , (_app) => _app.UseMiddleware()) - .Map($"{basePath}/gxwebsocket.svc", (_app) => _app.UseMiddleware()); //Compatibility reasons. Remove in the future. + .Map($"{basePath}/gxwebsocket", (_app) => _app.UseMiddleware()) + .Map($"{basePath}/gxwebsocket.svc", (_app) => _app.UseMiddleware()); //Compatibility reasons. Remove in the future. } } public class CustomBadRequestObjectResult : ObjectResult { static readonly IGXLogger log = GXLoggerFactory.GetLogger(typeof(CustomBadRequestObjectResult).FullName); public CustomBadRequestObjectResult(ActionContext context) - : base(HttpHelper.GetJsonError(StatusCodes.Status400BadRequest.ToString(), HttpHelper.StatusCodeToTitle(HttpStatusCode.BadRequest))) + : base(HttpHelper.GetJsonError(StatusCodes.Status400BadRequest.ToString(), HttpHelper.StatusCodeToTitle(HttpStatusCode.BadRequest))) { LogErrorResponse(context); StatusCode = StatusCodes.Status400BadRequest; @@ -180,7 +185,7 @@ public class Startup internal const string GX_CONTROLLERS = "gxcontrollers"; internal static string DefaultFileName { get; set; } - public List servicesBase = new List(); + public List servicesBase = new List(); private GXRouting gxRouting; public Startup(IConfiguration configuration, IHostingEnvironment env) @@ -197,6 +202,10 @@ public void ConfigureServices(IServiceCollection services) { OpenTelemetryService.Setup(services); + services.AddHealthChecks() + .AddCheck("liveness", () => HealthCheckResult.Healthy(), tags: new[] { "live" }) + .AddCheck("readiness", () => HealthCheckResult.Healthy(), tags: new[] { "ready" }); + IMvcBuilder builder = services.AddMvc(option => { option.EnableEndpointRouting = false; @@ -249,7 +258,7 @@ public void ConfigureServices(IServiceCollection services) string sessionCookieName = GxWebSession.GetSessionCookieName(VirtualPath); if (!string.IsNullOrEmpty(sessionCookieName)) { - options.Cookie.Name=sessionCookieName; + options.Cookie.Name = sessionCookieName; GxWebSession.SessionCookieName = sessionCookieName; } string sameSite; @@ -274,20 +283,20 @@ public void ConfigureServices(IServiceCollection services) services.AddResponseCompression(options => { options.MimeTypes = new[] - { - // Default - "text/plain", - "text/css", - "application/javascript", - "text/html", - "application/xml", - "text/xml", - "application/json", - "text/json", - // Custom - "application/json", - "application/pdf" - }; + { + // Default + "text/plain", + "text/css", + "application/javascript", + "text/html", + "application/xml", + "text/xml", + "application/json", + "text/json", + // Custom + "application/json", + "application/pdf" + }; options.EnableForHttps = true; }); } @@ -296,7 +305,7 @@ public void ConfigureServices(IServiceCollection services) private void RegisterControllerAssemblies(IMvcBuilder mvcBuilder) { - + if (RestAPIHelpers.ServiceAsController()) { mvcBuilder.AddMvcOptions(options => options.ModelBinderProviders.Insert(0, new QueryStringModelBinderProvider())); @@ -365,9 +374,9 @@ private void RegisterRestServices(IMvcBuilder mvcBuilder) try { string[] controllerAssemblyQualifiedName = new string(File.ReadLines(svcFile).First().SkipWhile(c => c != '"') - .Skip(1) - .TakeWhile(c => c != '"') - .ToArray()).Trim().Split(','); + .Skip(1) + .TakeWhile(c => c != '"') + .ToArray()).Trim().Split(','); string controllerAssemblyName = controllerAssemblyQualifiedName.Last(); if (!serviceAssemblies.Contains(controllerAssemblyName)) { @@ -428,17 +437,17 @@ private void DefineCorsPolicy(IServiceCollection services) services.AddCors(options => { options.AddPolicy(name: CORS_POLICY_NAME, - policy => - { - policy.WithOrigins(origins); - if (!corsAllowedOrigins.Contains(CORS_ANY_ORIGIN)) - { - policy.AllowCredentials(); - } - policy.AllowAnyHeader(); - policy.AllowAnyMethod(); - policy.SetPreflightMaxAge(TimeSpan.FromSeconds(CORS_MAX_AGE_SECONDS)); - }); + policy => + { + policy.WithOrigins(origins); + if (!corsAllowedOrigins.Contains(CORS_ANY_ORIGIN)) + { + policy.AllowCredentials(); + } + policy.AllowAnyHeader(); + policy.AllowAnyMethod(); + policy.SetPreflightMaxAge(TimeSpan.FromSeconds(CORS_MAX_AGE_SECONDS)); + }); }); } } @@ -446,7 +455,7 @@ private void DefineCorsPolicy(IServiceCollection services) private void ConfigureSessionService(IServiceCollection services, ISessionService sessionService) { - + if (sessionService is GxRedisSession) { GxRedisSession gxRedisSession = (GxRedisSession)sessionService; @@ -504,8 +513,11 @@ private void ConfigureSessionService(IServiceCollection services, ISessionServic } } } - public void Configure(IApplicationBuilder app, Microsoft.AspNetCore.Hosting.IHostingEnvironment env, ILoggerFactory loggerFactory, IHttpContextAccessor contextAccessor) + public void Configure(IApplicationBuilder app, Microsoft.AspNetCore.Hosting.IHostingEnvironment env, ILoggerFactory loggerFactory, IHttpContextAccessor contextAccessor, Microsoft.Extensions.Hosting.IHostApplicationLifetime applicationLifetime) { + // Registrar para el graceful shutdown + applicationLifetime.ApplicationStopping.Register(OnShutdown); + string baseVirtualPath = string.IsNullOrEmpty(VirtualPath) ? VirtualPath : $"/{VirtualPath}"; LogConfiguration.SetupLog4Net(); AppContext.Configure(contextAccessor); @@ -571,6 +583,17 @@ public void Configure(IApplicationBuilder app, Microsoft.AspNetCore.Hosting.IHos app.UseEndpoints(endpoints => { endpoints.MapControllers(); + + // Endpoints para health checks (Kubernetes probes) + endpoints.MapHealthChecks($"{baseVirtualPath}/_gx/health/live", new HealthCheckOptions + { + Predicate = check => check.Tags.Contains("live") + }); + + endpoints.MapHealthChecks($"{baseVirtualPath }/_gx/health/ready", new HealthCheckOptions + { + Predicate = check => check.Tags.Contains("ready") + }); }); if (log.IsCriticalEnabled && env.IsDevelopment()) { @@ -623,7 +646,7 @@ public void Configure(IApplicationBuilder app, Microsoft.AspNetCore.Hosting.IHos }, ContentTypeProvider = provider }); - + app.UseExceptionHandler(new ExceptionHandlerOptions { ExceptionHandler = new CustomExceptionHandlerMiddleware().Invoke, @@ -666,7 +689,7 @@ public void Configure(IApplicationBuilder app, Microsoft.AspNetCore.Hosting.IHos app.UseGXHandlerFactory(basePath); - app.Run(async context => + app.Run(async context => { await Task.FromException(new PageNotFoundException(context.Request.Path.Value)); }); @@ -692,13 +715,13 @@ private void ConfigureSwaggerUI(IApplicationBuilder app, string baseVirtualPath) app.UseSwaggerUI(options => { options.SwaggerEndpoint($"../../{finfo.Name}", finfo.Name); - options.RoutePrefix =$"{baseVirtualPathWithSep}{finfo.Name}/{SWAGGER_SUFFIX}"; + options.RoutePrefix = $"{baseVirtualPathWithSep}{finfo.Name}/{SWAGGER_SUFFIX}"; }); if (finfo.Name.Equals(SWAGGER_DEFAULT_YAML, StringComparison.OrdinalIgnoreCase) && File.Exists(Path.Combine(LocalPath, DEVELOPER_MENU))) app.UseSwaggerUI(options => { options.SwaggerEndpoint($"../../{SWAGGER_DEFAULT_YAML}", SWAGGER_DEFAULT_YAML); - options.RoutePrefix =$"{baseVirtualPathWithSep}{DEVELOPER_MENU}/{SWAGGER_SUFFIX}"; + options.RoutePrefix = $"{baseVirtualPathWithSep}{DEVELOPER_MENU}/{SWAGGER_SUFFIX}"; }); } @@ -709,11 +732,17 @@ private void ConfigureSwaggerUI(IApplicationBuilder app, string baseVirtualPath) } } + private void OnShutdown() + { + GXLogging.Info(log, "Application gracefully shutting down... Waiting for in-process requests to complete."); + ThreadUtil.WaitForEnd(); + } + private void AddRewrite(IApplicationBuilder app, string rewriteFile, string baseURL) { string rules = File.ReadAllText(rewriteFile); rules = rules.Replace("{BASEURL}", baseURL); - + using (var apacheModRewriteStreamReader = new StringReader(rules)) { var options = new RewriteOptions().AddApacheModRewrite(apacheModRewriteStreamReader); @@ -726,10 +755,10 @@ public class CustomExceptionHandlerMiddleware static readonly IGXLogger log = GXLoggerFactory.GetLogger(); public async Task Invoke(HttpContext httpContext) { - string httpReasonPhrase=string.Empty; + string httpReasonPhrase = string.Empty; Exception ex = httpContext.Features.Get()?.Error; HttpStatusCode httpStatusCode = (HttpStatusCode)httpContext.Response.StatusCode; - if (ex!=null) + if (ex != null) { if (ex is PageNotFoundException) { @@ -747,7 +776,7 @@ public async Task Invoke(HttpContext httpContext) GXLogging.Error(log, $"Internal error", ex); } } - if (httpStatusCode!= HttpStatusCode.OK) + if (httpStatusCode != HttpStatusCode.OK) { string redirectPage = Config.MapCustomError(httpStatusCode.ToString(HttpHelper.INT_FORMAT)); if (!string.IsNullOrEmpty(redirectPage)) @@ -761,7 +790,7 @@ public async Task Invoke(HttpContext httpContext) if (!string.IsNullOrEmpty(httpReasonPhrase)) { IHttpResponseFeature responseReason = httpContext.Response.HttpContext.Features.Get(); - if (responseReason!=null) + if (responseReason != null) responseReason.ReasonPhrase = httpReasonPhrase; } } @@ -818,7 +847,7 @@ public IActionResult Index() } internal class SetRoutePrefix : IApplicationModelConvention { - private readonly AttributeRouteModel _routePrefix ; + private readonly AttributeRouteModel _routePrefix; public SetRoutePrefix(IRouteTemplateProvider route) { _routePrefix = new AttributeRouteModel(route);