4
0

Program.cs 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. using System.Text.Json.Serialization;
  2. using Microsoft.AspNetCore.Components;
  3. using Microsoft.AspNetCore.DataProtection;
  4. using Microsoft.AspNetCore.Hosting.StaticWebAssets;
  5. using RackPeek.Domain;
  6. using RackPeek.Domain.Git;
  7. using RackPeek.Domain.Persistence;
  8. using RackPeek.Domain.Persistence.Yaml;
  9. using ModelContextProtocol.AspNetCore;
  10. using RackPeek.Mcp;
  11. using RackPeek.Web.Api;
  12. using RackPeek.Web.Components;
  13. using Shared.Rcl;
  14. using Shared.Rcl.Docs;
  15. using Shared.Rcl.Servers;
  16. namespace RackPeek.Web;
  17. public class Program {
  18. public static async Task<WebApplication> BuildApp(WebApplicationBuilder builder) {
  19. StaticWebAssetsLoader.UseStaticWebAssets(
  20. builder.Environment,
  21. builder.Configuration
  22. );
  23. var yamlDir = builder.Configuration.GetValue<string>("RPK_YAML_DIR") ?? "./config";
  24. var yamlFileName = "config.yaml";
  25. var basePath = Directory.GetCurrentDirectory();
  26. var yamlPath = Path.IsPathRooted(yamlDir)
  27. ? yamlDir
  28. : Path.Combine(basePath, yamlDir);
  29. Directory.CreateDirectory(yamlPath);
  30. var yamlFilePath = Path.Combine(yamlPath, yamlFileName);
  31. if (!File.Exists(yamlFilePath)) {
  32. try {
  33. await using var fs = new FileStream(
  34. yamlFilePath,
  35. FileMode.CreateNew,
  36. FileAccess.Write,
  37. FileShare.None);
  38. await using var writer = new StreamWriter(fs);
  39. await writer.WriteLineAsync("# default config");
  40. }
  41. catch (IOException) when (File.Exists(yamlFilePath)) {
  42. // Another instance created the file between the existence
  43. // check and CreateNew — the config is there, carry on.
  44. }
  45. }
  46. // Persist DataProtection keys next to the config so they live on the
  47. // mounted volume: they survive container recreation, and key writes
  48. // no longer depend on a writable user profile or /tmp — both of
  49. // which are unavailable in hardened Docker setups (#312).
  50. var keysPath = Path.Combine(yamlPath, ".dataprotection");
  51. Directory.CreateDirectory(keysPath);
  52. builder.Services.AddDataProtection()
  53. .PersistKeysToFileSystem(new DirectoryInfo(keysPath))
  54. .SetApplicationName("RackPeek");
  55. builder.Services.ConfigureHttpJsonOptions(options => {
  56. options.SerializerOptions.Converters.Add(
  57. new JsonStringEnumConverter());
  58. });
  59. builder.Services.AddScoped<ITextFileStore, PhysicalTextFileStore>();
  60. builder.Services.AddScoped<IDocsContentProvider, StaticWebAssetDocsContentProvider>();
  61. builder.Services.AddGitServices(builder.Configuration, yamlPath);
  62. var resources = new ResourceCollection();
  63. builder.Services.AddSingleton(resources);
  64. builder.Services.AddScoped<RackPeekConfigMigrationDeserializer>();
  65. builder.Services.AddScoped<IResourceYamlMigrationService, ResourceYamlMigrationService>();
  66. builder.Services.AddScoped<IResourceCollection>(sp =>
  67. new YamlResourceCollection(
  68. yamlFilePath,
  69. sp.GetRequiredService<ITextFileStore>(),
  70. sp.GetRequiredService<ResourceCollection>(),
  71. sp.GetRequiredService<IResourceYamlMigrationService>()));
  72. // Infrastructure
  73. builder.Services.AddYamlRepos();
  74. builder.Services.AddUseCases();
  75. builder.Services.AddCommands();
  76. builder.Services.AddScoped<IConsoleEmulator, ConsoleEmulator>();
  77. // MCP server, exposed over streamable HTTP at /mcp whenever the web server
  78. // runs. Stateless: every call is a plain POST (no session affinity behind a
  79. // reverse proxy) and tools resolve their services from the request scope,
  80. // exactly like the inventory API does.
  81. builder.Services.AddMcpServer(options => options.ServerInfo = new() {
  82. Name = McpSetup.ServerName,
  83. Version = RpkConstants.Version
  84. })
  85. .WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateless)
  86. .WithRackPeekTools();
  87. // Razor Components
  88. builder.Services.AddRazorComponents()
  89. .AddInteractiveServerComponents();
  90. WebApplication app = builder.Build();
  91. // Read the config into memory before anything can be served. Blazor reloads it
  92. // on every circuit init, but the inventory API has no circuit — without this it
  93. // would merge against an empty collection and persist that over the user's file,
  94. // destroying the inventory on the first request after a restart.
  95. await using (AsyncServiceScope scope = app.Services.CreateAsyncScope()) {
  96. try {
  97. await scope.ServiceProvider.GetRequiredService<IResourceCollection>().LoadAsync();
  98. }
  99. catch (Exception ex) {
  100. // An unreadable config must not stop the server booting: the web UI is
  101. // how someone fixes it, and a container that will not start is worse
  102. // than one showing the error. Blazor surfaces it on the first page load,
  103. // and every write path re-checks the load before persisting anything,
  104. // so booting in this state cannot overwrite the file.
  105. scope.ServiceProvider.GetRequiredService<ILogger<Program>>()
  106. .LogError(ex, "Could not read the config at {Path}. Fix it in the web UI.", yamlFilePath);
  107. }
  108. }
  109. if (!app.Environment.IsDevelopment()) {
  110. app.UseExceptionHandler("/Error");
  111. app.UseHsts();
  112. }
  113. app.UseHttpsRedirection();
  114. app.UseStaticFiles();
  115. app.UseRouting();
  116. app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
  117. app.UseAntiforgery();
  118. app.MapInventoryApi();
  119. // Same key, same gate as /api/inventory: 503 until RPK_API_KEY is configured,
  120. // so MCP is off by default and never exposes the inventory unauthenticated.
  121. RouteGroupBuilder mcp = app.MapGroup("/mcp");
  122. mcp.AddEndpointFilter<ApiKeyEndpointFilter>();
  123. mcp.MapMcp();
  124. app.MapStaticAssets();
  125. app.MapRazorComponents<App>()
  126. .AddInteractiveServerRenderMode()
  127. .AddAdditionalAssemblies(typeof(ServersListPage).Assembly);
  128. return app;
  129. }
  130. public static async Task Main(string[] args) {
  131. WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
  132. WebApplication app = await BuildApp(builder);
  133. await app.RunAsync();
  134. }
  135. }