Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Craft.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,13 @@
<EmbeddedResource Include="Services\Setup\*.html" LogicalName="Craft.Setup.%(Filename)%(Extension)" />
</ItemGroup>

<!--
So the test project can reach helpers that are correctly internal to the host — the endpoint
dispatch/discovery policies (ExecuteAsync, SelectHandler, BuildScheduledTasks) are authorization
plumbing worth testing directly but have no business being public API.
-->
<ItemGroup>
<InternalsVisibleTo Include="Craft.Tests" />
</ItemGroup>

</Project>
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Craft/
│ ├── Program.cs # Host startup, middleware, endpoint mapping
│ ├── Bridges/ # PowerShell-facing API surface → namespace Craft.Services (PINNED)
│ ├── Configuration/ # Settings types, one per file → Craft.Configuration
│ ├── Endpoints/ # Native C# endpoint/task contracts → Craft.Endpoints
│ ├── PowerShellHost/ # Runspace workers, pool, script repo → Craft.PowerShellHost
│ ├── Orchestration/ # Orchestrator, scheduler, jobs → Craft.Orchestration
│ ├── Storage/ # Azure Table stores, health → Craft.Storage
Expand Down
9 changes: 9 additions & 0 deletions Services/Configuration/CraftSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ public class CraftSettings
/// <summary>Bootstrap setup — built-in first-run wizard for EasyAuth + app registration.</summary>
public SetupSettings Setup { get; set; } = new();

/// <summary>OAuth protected resource metadata (RFC 9728) served for MCP/OAuth discovery.</summary>
public PrmSettings Prm { get; set; } = new();

/// <summary>Historical stats collection — rolling time-series of worker/job metrics.</summary>
public StatsHistorySettings StatsHistory { get; set; } = new();

Expand All @@ -89,6 +92,12 @@ public class CraftSettings
/// <summary>Azure Storage connection policy — see <see cref="StorageSettings"/>. Governs the dev-emulator fallback.</summary>
public StorageSettings Storage { get; set; } = new();

/// <summary>
/// Native C# endpoints hosted alongside the PowerShell ones. Off unless an application names the
/// assemblies to scan. See <see cref="EndpointSettings"/>.
/// </summary>
public EndpointSettings Endpoints { get; set; } = new();

/// <summary>Kestrel request limits (body size, connection cap). See <see cref="KestrelLimitsSettings"/>.</summary>
public KestrelLimitsSettings Limits { get; set; } = new();

Expand Down
113 changes: 113 additions & 0 deletions Services/Configuration/EndpointSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
namespace Craft.Configuration;

/// <summary>
/// Native C# endpoints, hosted alongside the PowerShell ones.
///
/// <para>
/// Off by default. An application opts in by naming the assemblies its endpoints live in; those load
/// at startup and each <c>[CraftEndpoint]</c> type is mapped at its literal route. Because a literal
/// route outranks the PowerShell catch-all, endpoints migrate one at a time with the PowerShell
/// function left in place as the rollback.
/// </para>
/// </summary>
public class EndpointSettings
{
/// <summary>
/// Master switch. Default false, so a deployment that names no assemblies pays nothing —
/// no assembly loading, no reflection, no route mapping.
/// </summary>
public bool Enabled { get; set; }

/// <summary>
/// Assemblies to scan, absolute or relative to the API base path (e.g.
/// <c>bin/GeoIpDb.Endpoints.dll</c>). Loaded into the default load context and never unloaded —
/// see <c>NativeEndpointRegistry</c> for why a collectible context would be a trap.
/// </summary>
public List<string> Assemblies { get; set; } = [];

/// <summary>
/// What to do when a native endpoint claims a route a PowerShell function already has.
///
/// <list type="bullet">
/// <item><description><c>PreferNative</c> (default) — the native endpoint wins and the
/// shadowing is logged. This is the mode that makes migration work: flip one endpoint, keep the
/// PowerShell one loaded as the rollback.</description></item>
/// <item><description><c>PreferPowerShell</c> — instant rollback with no rebuild.</description></item>
/// <item><description><c>Fail</c> — refuse to start, naming every collision. The right setting
/// for CI, where a route shadow should be caught before it ships.</description></item>
/// </list>
///
/// <c>Fail</c> is arguably the safer runtime default and is deliberately not the one chosen: on a
/// config-driven runtime it turns an accidental shadow into a failed rolling deploy. Set it in CI
/// and leave <c>PreferNative</c> in production.
/// </summary>
public string OnCollision { get; set; } = "PreferNative";

/// <summary>
/// Refuse to start when endpoints declare Central dispatch and no <c>ICraftEndpointHandler</c>
/// was found in the scanned assemblies. Default false: a handler-less application simply
/// dispatches every endpoint directly, which is exactly the pre-handler behaviour.
/// </summary>
/// <remarks>
/// Set it true — in CI at minimum — for applications whose authorization lives in the central
/// handler. For them a missing handler does not mean "less middleware"; it means every Central
/// endpoint is reachable with no auth check at all, and that should fail the deploy, not ship.
/// The same reasoning as <see cref="OnCollision"/>=Fail, and the same split applies: hard-fail
/// is right where a human sees the failure before traffic does.
/// </remarks>
public bool RequireHandler { get; set; }

/// <summary>
/// Blanket in-flight limit for native endpoints that declare none. 0 (default) means unbounded.
/// </summary>
/// <remarks>
/// <para>
/// With <c>Worker:HttpPoolSize=0</c> there is no runspace pool, and the pool was what implicitly
/// capped concurrent work — a request could only run if a worker was free. Native endpoints are
/// async end to end and hold no thread while waiting on an upstream, so nothing stops the process
/// accepting far more concurrent requests than it can finish. Memory is what gives out first: each
/// in-flight request holds its request and response buffers.
/// </para>
/// <para>
/// This is a concurrency limit, not a rate limit, and the two solve different problems.
/// <c>App:RateLimit:*</c> stops one caller monopolising the service and is partitioned per client;
/// this bounds total simultaneous work regardless of who asked. A service behind a trusted
/// single caller needs this one and not the other.
/// </para>
/// </remarks>
public int MaxConcurrency { get; set; }

/// <summary>
/// Per-route in-flight limits, keyed by route (e.g. <c>"GeoDBDownload": 4</c>). Overrides both the
/// endpoint's <c>[CraftEndpoint(MaxConcurrency = n)]</c> and <see cref="MaxConcurrency"/>. Set 0 to
/// explicitly remove a limit the endpoint declared.
/// </summary>
/// <remarks>
/// Resolution order is: this, then the attribute, then <see cref="MaxConcurrency"/>. The attribute
/// outranks the blanket default deliberately — an endpoint that declares a limit is asserting
/// something about itself that an operator setting a global default has no way to know, and
/// silently widening it would turn a safety limit into a footgun. Overriding it still works; it
/// just has to be said explicitly, by name.
/// </remarks>
public Dictionary<string, int> Concurrency { get; set; } = new(StringComparer.OrdinalIgnoreCase);

/// <summary>
/// How long a request waits for a concurrency slot before being shed with 503. Default 0, meaning
/// each endpoint's own <c>QueueTimeoutSeconds</c> applies.
/// </summary>
public int QueueTimeoutSeconds { get; set; }

/// <summary>
/// Resolves the in-flight limit for a route. See <see cref="Concurrency"/> for why the attribute
/// outranks the blanket default.
/// </summary>
public int ResolveConcurrency(string route, int declared)
{
if (Concurrency.TryGetValue(route, out var configured)) return Math.Max(0, configured);
return declared > 0 ? declared : Math.Max(0, MaxConcurrency);
}

/// <summary>Resolves the queue timeout for a route, preferring the global override when set.</summary>
public int ResolveQueueTimeout(int declared) =>
QueueTimeoutSeconds > 0 ? QueueTimeoutSeconds : declared;
}
48 changes: 48 additions & 0 deletions Services/Configuration/PrmSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
namespace Craft.Configuration;

/// <summary>
/// OAuth 2.0 Protected Resource Metadata (RFC 9728) served by Craft itself at
/// <c>/.well-known/oauth-protected-resource</c>, so OAuth clients (MCP clients in particular) can
/// discover how to obtain a token for the hosted API.
///
/// <para>
/// The document is NOT assembled from configuration — the hosted application writes the complete
/// JSON into one app setting (<see cref="SettingName"/>) and Craft serves it verbatim, so the
/// application controls every field. A literal <c>{origin}</c> anywhere in the JSON is replaced
/// per-request with <c>https://{host}</c>: RFC 9728 requires <c>resource</c> to equal the URL the
/// client is actually connecting to, which only the request knows once custom domains are in play.
/// </para>
///
/// <para>
/// Why not the platform's own PRM (preview): it derives <c>authorization_servers</c> from the
/// EasyAuth provider's <c>openIdIssuer</c> with no independent override — a multi-tenant SSO setup
/// therefore advertises the <c>/common</c> endpoint, which a single-tenant resource app
/// registration cannot authorize on (AADSTS50194). Craft only serves metadata: token issuance and
/// validation remain entirely with Entra and EasyAuth. The platform's PRM must stay dormant for
/// Craft's to be reachable — do NOT set WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES (it activates the
/// platform document, which intercepts the well-known path before the container sees the request).
/// The well-known path is appended to EasyAuth's excludedPaths automatically while this feature is
/// enabled.
/// </para>
/// </summary>
public class PrmSettings
{
/// <summary>
/// The well-known path (RFC 9728 §3). Suffixed variants
/// (<c>/.well-known/oauth-protected-resource/api/Foo</c>) identify a specific resource path.
/// </summary>
public const string WellKnownPath = "/.well-known/oauth-protected-resource";

/// <summary>
/// Master switch. When enabled and the app setting named by <see cref="SettingName"/> holds
/// valid JSON, Craft serves it and the setup reconcile keeps the well-known path in EasyAuth's
/// excludedPaths. When the setting is absent, nothing is served — its presence is the
/// per-instance "an OAuth resource exists here" signal, written and cleared by the hosted app.
/// </summary>
public bool Enabled { get; set; }

/// <summary>
/// Name of the app setting (environment variable) holding the complete PRM JSON document.
/// </summary>
public string SettingName { get; set; } = "CRAFT_PRM";
}
37 changes: 36 additions & 1 deletion Services/Configuration/WorkerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@ namespace Craft.Configuration;
/// </summary>
public class WorkerSettings
{
/// <summary>Number of workers reserved for HTTP request handling.</summary>
/// <summary>
/// Number of workers reserved for HTTP request handling.
///
/// <para>
/// <b>0 means no PowerShell HTTP hosting at all</b> — for an application whose HTTP endpoints are
/// all native C#. The pool is then never built, so the node pays neither runspace construction at
/// startup nor their resident memory, and readiness is signalled immediately so the startup gate
/// does not wait for a pool that will never exist.
/// </para>
/// </summary>
public int HttpPoolSize { get; set; } = 2;

/// <summary>Number of workers reserved for background jobs (scheduler, orchestrator, queue).</summary>
Expand All @@ -26,6 +35,32 @@ public class WorkerSettings
/// </summary>
public bool IgnoreSkuProfiles { get; set; }

/// <summary>
/// Minimum .NET thread-pool worker/completion threads. <b>0 (default) = derive from the pool
/// sizes</b>, which is almost always what you want; set a number only to pin it.
///
/// <para>
/// This matters far more than it looks. PowerShell has no async story, so every outbound call a
/// script makes — <c>Invoke-RestMethod</c>, or any <c>.GetAwaiter().GetResult()</c> against an
/// HttpClient — blocks a thread for the whole round trip. A pool of N workers can therefore have
/// N threads parked at once. Above the thread-pool minimum the CLR injects new threads at roughly
/// <b>one per second</b>, so a worker pool larger than the minimum cannot actually reach its own
/// concurrency until that ramp finishes.
/// </para>
///
/// <para>
/// Measured on a 1-core container with the old fixed floor of 32: a pool of 48 served 5.5 req/s
/// with a 17.5s p95 over a 15-second window, and 120 req/s with a 0.8s p95 over 60 seconds —
/// same configuration, the difference being only whether the injection ramp fell inside the
/// measurement. In production that ramp is a real cold-start cost on every restart.
/// </para>
///
/// Env override: <c>CRAFT_MIN_THREADS</c>. Note that the .NET
/// <c>DOTNET_ThreadPool_MinThreads</c> variable does NOT work here — the host calls
/// <c>ThreadPool.SetMinThreads</c> at startup, which overwrites it.
/// </summary>
public int MinThreads { get; set; }

/// <summary>
/// Maximum execution time in seconds for a single HTTP request handler.
/// When exceeded, the PowerShell pipeline is stopped and the worker is reclaimed.
Expand Down
90 changes: 90 additions & 0 deletions Services/Endpoints/CraftEndpointAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@

namespace Craft.Endpoints;

/// <summary>
/// Whether a native endpoint is dispatched through the application's central handler
/// (<see cref="ICraftEndpointHandler"/>) or invoked directly.
/// </summary>
public enum EndpointDispatch
{
/// <summary>
/// Routed through the central handler when the application registered one. The default, and the
/// safe direction for it: an endpoint that forgets to declare a dispatch mode gets the
/// application's authorization, not an accidentally-public route.
/// </summary>
Central,

/// <summary>
/// Bypasses the central handler. For endpoints that authenticate differently (a webhook
/// verifying a signature) or deliberately serve anonymous callers (a public redirect). This is a
/// property of the endpoint's security design, which is why it is declared here in code rather
/// than in configuration — flipping a route to Direct should be a code review, not a YAML edit.
/// </summary>
Direct,
}

/// <summary>
/// Marks a class as a native endpoint and declares its route and metadata. The C# counterpart of the
/// PowerShell convention where <c>Invoke-GetIPInfo</c> becomes <c>/API/GetIPInfo</c> and the
/// <c>.ROLE</c> / <c>.FUNCTIONALITY</c> doc tags feed the permission map.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class CraftEndpointAttribute : Attribute
{
public CraftEndpointAttribute(string route)
{
ArgumentException.ThrowIfNullOrWhiteSpace(route);
Route = route.Trim('/');
}

/// <summary>
/// Route segment beneath <c>/API/</c>. Declared explicitly rather than derived from the type name
/// so that migrating an endpoint from PowerShell cannot change its URL — the existing callers are
/// the reason this whole exercise has to be invisible from outside.
/// </summary>
public string Route { get; }

/// <summary>HTTP methods this endpoint answers. Defaults to the same set the PS dispatcher accepts.</summary>
public string[] Methods { get; init; } = ["GET", "POST", "PUT", "DELETE", "PATCH"];

/// <summary>Equivalent of the PowerShell <c>.ROLE</c> doc tag; feeds function-permissions.json.</summary>
public string? Role { get; init; }

/// <summary>Equivalent of the PowerShell <c>.FUNCTIONALITY</c> doc tag.</summary>
public string? Functionality { get; init; }

/// <summary>
/// Ceiling on concurrent executions of THIS endpoint. 0 (default) means unbounded, which is
/// usually right for an async endpoint and is much of the point of being native.
///
/// <para>
/// Set it when unbounded concurrency would hurt something downstream. The worker pool is what
/// bounds PowerShell endpoints today — it sheds to a 503 once saturated — and a native endpoint
/// has no equivalent, so it is limited only by Kestrel's connection cap. Two things that makes
/// worse: fanning out to a rate-limited upstream (the failure moves off the host and onto the
/// bill), and endpoints whose memory scales with concurrency.
/// </para>
/// </summary>
public int MaxConcurrency { get; init; }

/// <summary>
/// How long a request waits for a slot when <see cref="MaxConcurrency"/> is reached, before being
/// shed with a 503. Matches the PowerShell pool's 30s checkout timeout so the client-visible
/// behaviour does not change as endpoints migrate.
/// </summary>
public int QueueTimeoutSeconds { get; init; } = 30;

/// <summary>
/// Singleton by default: it matches the process-wide state a PowerShell module holds, costs no
/// per-request allocation, and lets an endpoint keep a pooled HttpClient in a field.
/// </summary>
public ServiceLifetime Lifetime { get; init; } = ServiceLifetime.Singleton;

/// <summary>
/// Whether requests to this endpoint go through the application's central handler
/// (<see cref="ICraftEndpointHandler"/>). Defaults to <see cref="EndpointDispatch.Central"/>;
/// with no handler registered the two modes behave identically, so existing applications are
/// unaffected until they ship one.
/// </summary>
public EndpointDispatch Dispatch { get; init; } = EndpointDispatch.Central;
}
20 changes: 20 additions & 0 deletions Services/Endpoints/CraftJson.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Text.Json;

namespace Craft.Endpoints;

/// <summary>
/// The JSON conventions native endpoints read and write by default.
///
/// <para>
/// <see cref="JsonSerializerDefaults.Web"/>, which means camelCase property names and case-insensitive
/// reads — the same options ASP.NET Core's own minimal APIs and MVC use. Using
/// <c>System.Text.Json</c>'s bare defaults instead would make <c>{"hostname":"x"}</c> fail to bind to a
/// <c>Hostname</c> property and would emit <c>{"Hostname":…}</c> back, so every application would
/// discover the same trap and paste the same options object into every endpoint.
/// </para>
/// </summary>
internal static class CraftJson
{
/// <summary>Shared instance — <see cref="JsonSerializerOptions"/> caches its metadata, so reusing one is what keeps serialization fast.</summary>
internal static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web);
}
Loading