feat(managementservice): MgmtDeployArea — Deployer-only, scoped by the area's owning site (fail-closed on an unknown area), long-running like MgmtDeploySite (Gitea #40)

This commit is contained in:
Joseph Doherty
2026-10-22 14:10:53 -04:00
parent eb1c9ea7f9
commit f72c72c52f
7 changed files with 154 additions and 5 deletions
@@ -31,6 +31,21 @@ public record MgmtDeployArtifactsCommand(int? SiteId = null);
/// and needs the push anyway. Never overrides the Disabled skip.
/// </param>
public record MgmtDeploySiteCommand(int SiteId, bool Force = false);
/// <summary>
/// Bulk-deploys every instance in one area SUBTREE — the area plus every area
/// nested beneath it at any depth — with exactly the rules of
/// <see cref="MgmtDeploySiteCommand"/>: Disabled instances are always skipped,
/// up-to-date ones are skipped unless <see cref="Force"/> is set, and the batch is
/// not all-or-nothing. Answers with the same site deployment summary. Deployer-only,
/// site-scoped by the area's owning site (Gitea #40).
/// </summary>
/// <param name="AreaId">Database id of the subtree's root area.</param>
/// <param name="Force">
/// Redeploy instances whose config already matches the deployed revision. Never
/// overrides the Disabled skip. Same meaning as <see cref="MgmtDeploySiteCommand.Force"/>.
/// </param>
public record MgmtDeployAreaCommand(int AreaId, bool Force = false);
public record QueryDeploymentsCommand(int? InstanceId = null, string? Status = null, int Page = 1, int PageSize = 50);
/// <summary>
@@ -343,7 +343,8 @@ public class ManagementActor : ReceiveActor
PreviewBundleCommand or ImportBundleCommand => AdminOnly,
// Deployer operations
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtDeploySiteCommand or MgmtEnableInstanceCommand
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtDeploySiteCommand or MgmtDeployAreaCommand
or MgmtEnableInstanceCommand
or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand
@@ -431,6 +432,7 @@ public class ManagementActor : ReceiveActor
CreateInstanceCommand cmd => await HandleCreateInstance(sp, cmd, user),
MgmtDeployInstanceCommand cmd => await HandleDeployInstance(sp, cmd, user),
MgmtDeploySiteCommand cmd => await HandleDeploySite(sp, cmd, user),
MgmtDeployAreaCommand cmd => await HandleDeployArea(sp, cmd, user),
MgmtEnableInstanceCommand cmd => await HandleEnableInstance(sp, cmd, user),
MgmtDisableInstanceCommand cmd => await HandleDisableInstance(sp, cmd, user),
MgmtDeleteInstanceCommand cmd => await HandleDeleteInstance(sp, cmd, user),
@@ -1051,6 +1053,43 @@ public class ManagementActor : ReceiveActor
}
}
/// <summary>
/// Bulk-deploys every deployable instance in an area subtree (Gitea #40) —
/// <see cref="HandleDeploySite"/> narrowed to one area and its descendants.
///
/// <para>
/// Site scope is enforced on the area's OWNING site, the only site the batch
/// can touch. An area that does not resolve fails CLOSED for a site-scoped
/// caller (the same denial as an out-of-scope one, so the gate is not an
/// existence oracle — register row 98); a system-wide caller gets a plain
/// "not found" error. Same overall deadline and <c>BULK_DEPLOY_TIMEOUT</c>
/// mapping as the site deploy.
/// </para>
/// </summary>
private static async Task<object?> HandleDeployArea(IServiceProvider sp, MgmtDeployAreaCommand cmd, AuthenticatedUser user)
{
var area = await sp.GetRequiredService<ITemplateEngineRepository>().GetAreaByIdAsync(cmd.AreaId);
if (area is null)
{
DenyUnresolvedTargetIfScoped(user, $"area {cmd.AreaId}");
throw new ManagementCommandException($"Area with ID {cmd.AreaId} not found.");
}
EnforceSiteScope(user, area.SiteId);
var svc = sp.GetRequiredService<DeploymentService>();
var timeout = sp.GetService<Microsoft.Extensions.Options.IOptions<ManagementServiceOptions>>()?.Value.LongRunningCommandTimeout ?? TimeSpan.FromMinutes(5);
using var deadline = new CancellationTokenSource(timeout);
try
{
var result = await svc.DeployAreaAsync(cmd.AreaId, user.Username, cmd.Force, deadline.Token);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
catch (OperationCanceledException) when (deadline.IsCancellationRequested)
{
throw new ManagementCommandException("Overall bulk deployment deadline exceeded before further apply could be scheduled.", "BULK_DEPLOY_TIMEOUT");
}
}
private static async Task<object?> HandleEnableInstance(IServiceProvider sp, MgmtEnableInstanceCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
@@ -50,10 +50,10 @@ public static class ManagementEndpoints
[
typeof(ImportBundleCommand), typeof(PreviewBundleCommand), typeof(ExportBundleCommand),
typeof(MgmtDeployArtifactsCommand), typeof(MgmtDeployInstanceCommand),
// A bulk site deploy is by construction the longest command in the set —
// A bulk site (or area-subtree) deploy is by construction the longest command in the set —
// it is N instance deploys, so it must never inherit the ordinary Ask
// timeout or the caller would 504 while the batch is still applying.
typeof(MgmtDeploySiteCommand),
typeof(MgmtDeploySiteCommand), typeof(MgmtDeployAreaCommand),
// Flow Debugger (design 2026-09-05 §7.1): a Live run lasts up to the method timeout
// (+ grace) or the value-change wait window — never the ordinary Ask timeout.
typeof(FlowDebugRunMethodCommand), typeof(FlowDebugRunValueChangeCommand),
@@ -73,7 +73,8 @@ public static class ManagementEndpoints
public static TimeSpan ResolveAskTimeout(ManagementServiceOptions? options, Type commandType)
=> LongRunningCommands.Contains(commandType)
? (options is { LongRunningCommandTimeout.Ticks: > 0 } o ? o.LongRunningCommandTimeout : DefaultLongRunningTimeout)
+ (commandType == typeof(MgmtDeploySiteCommand) ? TimeSpan.FromMinutes(1) : TimeSpan.Zero)
+ (commandType == typeof(MgmtDeploySiteCommand) || commandType == typeof(MgmtDeployAreaCommand)
? TimeSpan.FromMinutes(1) : TimeSpan.Zero)
: ResolveAskTimeout(options);
/// <summary>Registers the <c>POST /management</c> endpoint on the given route builder.</summary>