diff --git a/backend/FwHeadless/FwHeadlessKernel.cs b/backend/FwHeadless/FwHeadlessKernel.cs index 51700d8f53..6c442d3b40 100644 --- a/backend/FwHeadless/FwHeadlessKernel.cs +++ b/backend/FwHeadless/FwHeadlessKernel.cs @@ -21,6 +21,10 @@ public static IServiceCollection AddFwHeadless(this IServiceCollection services) .BindConfiguration("FwHeadlessConfig") .ValidateDataAnnotations() .ValidateOnStart(); + services.AddOptions() + .BindConfiguration("MaintenanceMode") + .ValidateDataAnnotations() + .ValidateOnStart(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); diff --git a/backend/FwHeadless/MaintenanceModeConfig.cs b/backend/FwHeadless/MaintenanceModeConfig.cs new file mode 100644 index 0000000000..361e364251 --- /dev/null +++ b/backend/FwHeadless/MaintenanceModeConfig.cs @@ -0,0 +1,7 @@ +namespace FwHeadless; + +public class MaintenanceModeConfig +{ + public bool ReadOnlyMode { get; init; } = false; + public string? MaintenanceMessage { get; set; } = null; +} diff --git a/backend/FwHeadless/MaintenanceModeMiddleware.cs b/backend/FwHeadless/MaintenanceModeMiddleware.cs new file mode 100644 index 0000000000..790c69c996 --- /dev/null +++ b/backend/FwHeadless/MaintenanceModeMiddleware.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Options; + +namespace FwHeadless; + +public class MaintenanceModeMiddleware(RequestDelegate next, IOptions config) +{ + public const string DefaultMaintenanceMessage = "Lexbox is in read-only mode for scheduled maintenance, please try again in an hour or two"; + +#pragma warning disable IDE0022 + public static bool IsWriteMethod(string httpMethod) => httpMethod switch + { + // Sadly, readonly static vars do not count as consts for switch expressions + var m when m == HttpMethods.Post => true, + var m when m == HttpMethods.Put => true, + var m when m == HttpMethods.Patch => true, + var m when m == HttpMethods.Delete => true, + _ => false + }; +#pragma warning restore IDE0022 + + public async Task Invoke(HttpContext context) + { + var readOnly = config.Value.ReadOnlyMode; + var message = config.Value.MaintenanceMessage; + // If read-only mode is set without an explicit message, use the default + if (readOnly) message ??= DefaultMaintenanceMessage; + // But if not in read-only mode, then an empty message should not set the header + + // If we get here without a maintenance message, exit fast + if (string.IsNullOrEmpty(message)) + { + await next(context); + return; + } + + // Non-empty maintenance messages should be set on all requests + context.Response.Headers["maintenance-message"] = message; + + // But request filtering should only happen if we're in read-only mode + if (readOnly && IsWriteMethod(context.Request.Method)) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await context.Response.WriteAsync(message); + return; + } + + await next(context); + } +} diff --git a/backend/FwHeadless/Program.cs b/backend/FwHeadless/Program.cs index c17c1878ff..0d89db46b9 100644 --- a/backend/FwHeadless/Program.cs +++ b/backend/FwHeadless/Program.cs @@ -63,6 +63,9 @@ await next(); }); +// Allow read-only mode during maintenance windows +app.UseMiddleware(); + // Load project ID from request app.Use((context, next) => { diff --git a/backend/FwHeadless/appsettings.Development.json b/backend/FwHeadless/appsettings.Development.json index d98c6306a1..30fca17d94 100644 --- a/backend/FwHeadless/appsettings.Development.json +++ b/backend/FwHeadless/appsettings.Development.json @@ -15,5 +15,9 @@ "Default": "Information" } }, + "MaintenanceMode": { + "ReadOnlyMode": false, + "MaintenanceMessage": null + }, "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317" } diff --git a/backend/FwHeadless/appsettings.json b/backend/FwHeadless/appsettings.json index 6fd1c295aa..d0c8e66136 100644 --- a/backend/FwHeadless/appsettings.json +++ b/backend/FwHeadless/appsettings.json @@ -9,6 +9,10 @@ "SendReceive": "Information" } }, + "MaintenanceMode": { + "ReadOnlyMode": false, + "MaintenanceMessage": null + }, "LcmCrdt": { // This value is explicitly referenced in SnapshotAtCommitService when preserving FieldWorks commits "DefaultAuthorForCommits": "FieldWorks", diff --git a/backend/LexBoxApi/Config/MaintenanceModeConfig.cs b/backend/LexBoxApi/Config/MaintenanceModeConfig.cs new file mode 100644 index 0000000000..ae46dd8e93 --- /dev/null +++ b/backend/LexBoxApi/Config/MaintenanceModeConfig.cs @@ -0,0 +1,7 @@ +namespace LexBoxApi.Config; + +public class MaintenanceModeConfig +{ + public bool ReadOnlyMode { get; init; } = false; + public string? MaintenanceMessage { get; set; } = null; +} diff --git a/backend/LexBoxApi/LexBoxKernel.cs b/backend/LexBoxApi/LexBoxKernel.cs index 25033c4023..593a46f07e 100644 --- a/backend/LexBoxApi/LexBoxKernel.cs +++ b/backend/LexBoxApi/LexBoxKernel.cs @@ -58,6 +58,10 @@ public static void AddLexBoxApi(this IServiceCollection services, .BindConfiguration("FwLiteRelease") .ValidateDataAnnotations() .ValidateOnStart(); + services.AddOptions() + .BindConfiguration("MaintenanceMode") + .ValidateDataAnnotations() + .ValidateOnStart(); services.AddHttpClient(); services.AddServiceDiscovery(); services.AddHttpClient(client => client.BaseAddress = new ("http://fwHeadless")) diff --git a/backend/LexBoxApi/Middleware/MaintenanceModeMiddleware.cs b/backend/LexBoxApi/Middleware/MaintenanceModeMiddleware.cs new file mode 100644 index 0000000000..22d1785c58 --- /dev/null +++ b/backend/LexBoxApi/Middleware/MaintenanceModeMiddleware.cs @@ -0,0 +1,50 @@ +using LexBoxApi.Config; +using Microsoft.Extensions.Options; + +namespace LexBoxApi.Middleware; + +public class MaintenanceModeMiddleware(RequestDelegate next, IOptions config) +{ + public const string DefaultMaintenanceMessage = "Lexbox is in read-only mode for scheduled maintenance, please try again in an hour or two"; + +#pragma warning disable IDE0022 + public static bool IsWriteMethod(string httpMethod) => httpMethod switch + { + // Sadly, readonly static vars do not count as consts for switch expressions + var m when m == HttpMethods.Post => true, + var m when m == HttpMethods.Put => true, + var m when m == HttpMethods.Patch => true, + var m when m == HttpMethods.Delete => true, + _ => false + }; +#pragma warning restore IDE0022 + + public async Task Invoke(HttpContext context) + { + var readOnly = config.Value.ReadOnlyMode; + var message = config.Value.MaintenanceMessage; + // If read-only mode is set without an explicit message, use the default + if (readOnly) message ??= DefaultMaintenanceMessage; + // But if not in read-only mode, then an empty message should not set the header + + // If we get here without a maintenance message, exit fast + if (string.IsNullOrEmpty(message)) + { + await next(context); + return; + } + + // Non-empty maintenance messages should be set on all requests + context.Response.Headers["maintenance-message"] = message; + + // But request filtering should only happen if we're in read-only mode + if (readOnly && IsWriteMethod(context.Request.Method)) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await context.Response.WriteAsync(message); + return; + } + + await next(context); + } +} diff --git a/backend/LexBoxApi/Program.cs b/backend/LexBoxApi/Program.cs index 9898ef873d..c88f7e58d5 100644 --- a/backend/LexBoxApi/Program.cs +++ b/backend/LexBoxApi/Program.cs @@ -7,6 +7,7 @@ using LexBoxApi.Auth.Attributes; using LexBoxApi.ErrorHandling; using LexBoxApi.Hub; +using LexBoxApi.Middleware; using LexBoxApi.Otel; using LexBoxApi.Proxies; using LexBoxApi.Services; @@ -157,6 +158,7 @@ if (!app.Environment.IsDevelopment()) app.UseExceptionHandler(); app.UseHealthChecks("/api/healthz"); +app.UseMiddleware(); // Configure the HTTP request pipeline. //for now allow this to run in prod, maybe later we want to disable it. { diff --git a/backend/LexBoxApi/appsettings.Development.json b/backend/LexBoxApi/appsettings.Development.json index deeb8a6f99..37c52850b6 100644 --- a/backend/LexBoxApi/appsettings.Development.json +++ b/backend/LexBoxApi/appsettings.Development.json @@ -52,6 +52,10 @@ "LfMergeTrustToken": "lf-merge-dev-trust-token", "AutoUpdateLexEntryCountOnSendReceive": true }, + "MaintenanceMode": { + "ReadOnlyMode": false, + "MaintenanceMessage": null + }, "HealthChecks": { "RequireFwHeadlessContainerVersionMatch": false, "RequireHealthyFwHeadlessContainer": false diff --git a/backend/LexBoxApi/appsettings.json b/backend/LexBoxApi/appsettings.json index 95904c48a6..597c30aa69 100644 --- a/backend/LexBoxApi/appsettings.json +++ b/backend/LexBoxApi/appsettings.json @@ -46,6 +46,10 @@ "HgConfig": { "RepoPath": null }, + "MaintenanceMode": { + "ReadOnlyMode": false, + "MaintenanceMessage": null + }, "Authentication": { "Jwt": { "Secret": null, diff --git a/frontend/src/hooks.client.ts b/frontend/src/hooks.client.ts index bbb5fab445..4a550a3fd9 100644 --- a/frontend/src/hooks.client.ts +++ b/frontend/src/hooks.client.ts @@ -1,6 +1,7 @@ import { ensureErrorIsTraced, traceFetch } from '$lib/otel/otel.client'; import { getErrorMessage, validateFetchResponse } from './hooks.shared'; +import { maintenanceMessage } from '$lib/util/maintenance'; import { APP_VERSION } from '$lib/util/version'; import type { HandleClientError } from '@sveltejs/kit'; import { USER_LOAD_KEY } from '$lib/user'; @@ -68,6 +69,12 @@ handleFetch(async ({ fetch, args }) => { return response; }); + if (response.headers.has('maintenance-message')) { + maintenanceMessage.value = response.headers.get('maintenance-message'); + } else { + maintenanceMessage.value = null; + } + // invalidateUserOnJwtRefresh is considered true by default, so only skip if the value is false if (args[1]?.lexboxResponseHandlingConfig?.invalidateUserOnJwtRefresh !== false && response.headers.get('lexbox-jwt-updated') === 'all') { await invalidate(USER_LOAD_KEY); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 9a8ffaea61..6a23849bbe 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,5 +1,6 @@ import {loadI18n, pickBestLocale} from '$lib/i18n'; import {AUTH_COOKIE_NAME, getUser, isAuthn} from '$lib/user'; +import {maintenanceMessage} from '$lib/util/maintenance'; import {apiVersion} from '$lib/util/version'; import {redirect, type Handle, type HandleFetch, type HandleServerError, type RequestEvent, type ResolveOptions} from '@sveltejs/kit'; import {ensureErrorIsTraced, traceRequest, traceFetch} from '$lib/otel/otel.server'; @@ -88,6 +89,12 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { apiVersion.value = response.headers.get('lexbox-version'); } + if (response.headers.has('maintenance-message')) { + maintenanceMessage.value = response.headers.get('maintenance-message'); + } else { + maintenanceMessage.value = null; + } + const lexBoxSetAuthCookieHeader = response.headers.getSetCookie() .find(h => h.startsWith(`${AUTH_COOKIE_NAME}=`)); diff --git a/frontend/src/lib/layout/AppBar.svelte b/frontend/src/lib/layout/AppBar.svelte index 1076a2b5a1..c443e6e64f 100644 --- a/frontend/src/lib/layout/AppBar.svelte +++ b/frontend/src/lib/layout/AppBar.svelte @@ -5,6 +5,7 @@ import {AuthenticatedUserIcon, UserAddOutline, Icon} from '$lib/icons'; import {onMount} from 'svelte'; import type {LexAuthUser} from '$lib/user'; + import {maintenanceMessage} from '$lib/util/maintenance'; import {page} from '$app/state'; import AppLogo from '$lib/icons/AppLogo.svelte'; import DevContent from './DevContent.svelte'; @@ -25,6 +26,11 @@
+ {#if maintenanceMessage.value} + + {maintenanceMessage.value} + + {/if} {#if environmentName !== 'production'} {$t('environment_warning', { environmentName })}