Skip to content

Commit 9186beb

Browse files
authored
Merge pull request #5 from net0well/fase-5/dashboard
Fase 5: painel embutível (API), DashboardHost e ferramenta psr (specs 007 e 009)
2 parents 3b343d3 + ea5b666 commit 9186beb

72 files changed

Lines changed: 12953 additions & 57 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎Directory.Packages.props‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
<ItemGroup>
66
<PackageVersion Include="Cronos" Version="0.13.0" />
77
<PackageVersion Include="JsonSchema.Net" Version="9.4.1" />
8+
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.13" />
89
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
10+
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
11+
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.13" />
912
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.13" />
1013
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.13" />
1114
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.13" />
@@ -15,8 +18,10 @@
1518
<PackageVersion Include="Microsoft.Extensions.Diagnostics" Version="10.0.13" />
1619
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.13" />
1720
<PackageVersion Include="Microsoft.Extensions.Diagnostics.Testing" Version="10.11.0" />
21+
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.13" />
1822
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.13" />
1923
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.13" />
24+
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.13" />
2025
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.13" />
2126
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.13" />
2227
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.11.0" />

‎PythonScriptRunner.slnx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
<Solution>
22
<Folder Name="/hosts/">
3+
<Project Path="hosts/PythonScriptRunner.DashboardHost/PythonScriptRunner.DashboardHost.csproj" />
34
<Project Path="hosts/PythonScriptRunner.ServiceDefaults/PythonScriptRunner.ServiceDefaults.csproj" />
45
<Project Path="hosts/PythonScriptRunner.WorkerHost/PythonScriptRunner.WorkerHost.csproj" />
56
</Folder>
67
<Folder Name="/src/">
78
<Project Path="src/PythonScriptRunner.Abstractions/PythonScriptRunner.Abstractions.csproj" />
89
<Project Path="src/PythonScriptRunner.Core/PythonScriptRunner.Core.csproj" />
10+
<Project Path="src/PythonScriptRunner.Dashboard/PythonScriptRunner.Dashboard.csproj" />
911
<Project Path="src/PythonScriptRunner.EntityFrameworkCore/PythonScriptRunner.EntityFrameworkCore.csproj" />
1012
<Project Path="src/PythonScriptRunner.RabbitMQ/PythonScriptRunner.RabbitMQ.csproj" />
13+
<Project Path="src/PythonScriptRunner.Tool/PythonScriptRunner.Tool.csproj" />
1114
<Project Path="src/PythonScriptRunner.Worker/PythonScriptRunner.Worker.csproj" />
1215
</Folder>
1316
<Folder Name="/tests/">
1417
<Project Path="tests/PythonScriptRunner.Core.Tests/PythonScriptRunner.Core.Tests.csproj" />
18+
<Project Path="tests/PythonScriptRunner.Dashboard.Tests/PythonScriptRunner.Dashboard.Tests.csproj" />
1519
<Project Path="tests/PythonScriptRunner.EntityFrameworkCore.Tests/PythonScriptRunner.EntityFrameworkCore.Tests.csproj" />
1620
<Project Path="tests/PythonScriptRunner.RabbitMQ.Tests/PythonScriptRunner.RabbitMQ.Tests.csproj" />
1721
<Project Path="tests/PythonScriptRunner.Worker.Tests/PythonScriptRunner.Worker.Tests.csproj" />

‎README.md‎

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ com contrato de entrada/saída, trilhas (**Pipelines**), execução assíncrona
55
cron e painel Vue opcional.
66

77
> Projeto em desenvolvimento. Esta versão entrega o **Core Runner** (execução síncrona de Stages e de
8-
> trilhas), o SDK Python **`psrunner`**, a **persistência em PostgreSQL** (catálogo versionado e histórico) e a
9-
> **execução assíncrona** com RabbitMQ, worker e agendamento cron.
8+
> trilhas), o SDK Python **`psrunner`**, a **persistência em PostgreSQL** (catálogo versionado e histórico), a
9+
> **execução assíncrona** com RabbitMQ, worker e agendamento cron e o **painel embutível** (API, tempo real e acesso
10+
> configurável; a interface Vue chega numa próxima versão).
1011
1112
## Como funciona
1213

@@ -155,6 +156,67 @@ O host `hosts/PythonScriptRunner.WorkerHost` é um worker pronto, configurado po
155156
(`ConnectionStrings__psr`, `ConnectionStrings__rabbitmq`, `PythonScriptRunner__Worker__MaxConcurrency`,
156157
`PythonScriptRunner__Secrets__<NOME>`, `PSR_AUTO_MIGRATE=true`), com `/health/live`, `/health/ready`, logs JSON e OTLP.
157158

159+
## Painel (Dashboard)
160+
161+
Com `PythonScriptRunner.Dashboard`, o painel é embutido na sua aplicação, no estilo do Hangfire: API interna, atualizações
162+
em tempo real (SignalR) e a interface servida no prefixo escolhido. Sem `MapPythonScriptRunnerDashboard` não existe painel.
163+
164+
```csharp
165+
builder.Services
166+
.AddPythonScriptRunner()
167+
.AddEntityFrameworkCore(o => o.UseNpgsql(builder.Configuration.GetConnectionString("psr")!))
168+
.AddRabbitMq(o => o.ConnectionString = builder.Configuration.GetConnectionString("rabbitmq"))
169+
.AddDashboard(o => o.UseFixedLogin(login => login.BindConfiguration())); // seção PythonScriptRunner:Dashboard:Login
170+
171+
var app = builder.Build();
172+
app.MapPythonScriptRunnerDashboard("/psr"); // API em /psr/api, tempo real em /psr/hubs/runs, painel em /psr/
173+
```
174+
175+
Escolha **um** modo de acesso; os papéis são cumulativos: `Reader` (vê tudo) → `Executor` (dispara, cancela,
176+
reprocessa) → `Author` (edita, testa, publica, importa, agenda) → `Admin` (exclui, retenção, segredos, auditoria).
177+
178+
| Modo | Configuração |
179+
|------|--------------|
180+
| Login fixo | `o.UseFixedLogin(l => l.BindConfiguration())` com usuários na configuração (abaixo) ou `l.Users` em código |
181+
| Filtro próprio | `o.UseAuthorizationFilter<MeuFiltro>()`: `IDashboardAuthorizationFilter` devolve `DashboardUser(nome, papel)` ou `null` |
182+
| Autenticação do host | `o.UseHostAuthentication(r => { r.Reader = p => p.RequireRole("psr-leitor"); /* Executor, Author, Admin */ r.LoginUrl = "/conta/entrar?returnUrl={returnUrl}"; })` |
183+
| Somente local | `o.AllowLocalRequests = true` — só em Development (fora dele a inicialização falha); soma-se a um modo principal |
184+
185+
```json
186+
"PythonScriptRunner": {
187+
"Dashboard": {
188+
"Login": {
189+
"Users": [
190+
{ "Name": "ana", "Role": "Admin", "PasswordHash": "AQAAAAIAAYagAAAAE..." },
191+
{ "Name": "bruno", "Role": "Reader", "PasswordHash": "AQAAAAIAAYagAAAAE..." }
192+
]
193+
}
194+
}
195+
}
196+
```
197+
198+
A senha nunca vai para a configuração, só o hash (PBKDF2, formato do ASP.NET Identity), gerado pela ferramenta `psr`:
199+
200+
```bash
201+
dotnet tool install -g PythonScriptRunner.Tool
202+
psr hash-password # digita a senha duas vezes, sem eco
203+
echo "senha" | psr hash-password --stdin # em scripts
204+
```
205+
206+
No login fixo a sessão é um cookie `psr.session` (HttpOnly, `SameSite=Lax`) de 8 h deslizantes e no máximo 24 h; 5
207+
falhas seguidas bloqueiam o usuário por 15 min e um IP pode tentar 10 vezes por minuto. Alterações na configuração valem
208+
sem reiniciar (usuário removido ou senha trocada perde a sessão; papel novo vale na hora). As chaves do cookie ficam em
209+
`psr.dashboard_keys`, então a sessão vale em todas as instâncias e sobrevive a reinícios.
210+
211+
Na API, requisições que alteram algo exigem o cabeçalho `X-PSR-Request: 1`, edições usam `ETag`/`If-Match` e erros
212+
seguem `ProblemDetails` com `code`. Em Development, o documento OpenAPI (interno) fica em `/psr/api/openapi/v1.json`.
213+
214+
O host `hosts/PythonScriptRunner.DashboardHost` é um painel pronto: login fixo por variáveis de ambiente
215+
(`PythonScriptRunner__Dashboard__Login__Users__0__Name`, `__Role`, `__PasswordHash`) ou OpenID Connect com
216+
`Oidc__Authority`, `Oidc__ClientId` e `Oidc__ClientSecret` (papéis pela claim `Dashboard__RoleClaim`, padrão `roles`, e
217+
`Dashboard__Roles__Reader|Executor|Author|Admin`). Também aceita `Dashboard__PathPrefix` (padrão `/psr`),
218+
`ASPNETCORE_FORWARDEDHEADERS_ENABLED=true` atrás de proxy e `PSR_AUTO_MIGRATE=true`.
219+
158220
## Requisitos
159221

160222
- .NET 10 SDK
@@ -170,7 +232,9 @@ O host `hosts/PythonScriptRunner.WorkerHost` é um worker pronto, configurado po
170232
| `src/PythonScriptRunner.EntityFrameworkCore` | PostgreSQL: catálogo versionado, histórico, arquivos, idempotência, outbox, auditoria, retenção, agendamentos e ciclo das execuções enfileiradas |
171233
| `src/PythonScriptRunner.RabbitMQ` | topologia, relay do outbox, `IRunQueue`, comando público, eventos e assinaturas |
172234
| `src/PythonScriptRunner.Worker` | execução dos passos enfileirados, *lease*, retry, cancelamento, cron |
173-
| `hosts` | `WorkerHost` e `ServiceDefaults` (Serilog, OpenTelemetry, health checks) |
235+
| `src/PythonScriptRunner.Dashboard` | painel embutível: API interna, hub SignalR, modos de acesso (login fixo, filtro, host, local) |
236+
| `src/PythonScriptRunner.Tool` | dotnet tool `psr` (`psr hash-password`) |
237+
| `hosts` | `WorkerHost`, `DashboardHost` e `ServiceDefaults` (Serilog, OpenTelemetry, health checks) |
174238
| `python/psrunner` | SDK Python (`@stage`, `Context`, `File`, `StageError`, `psrunner.testing`, CLI) |
175239
| `schemas` | JSON Schemas do `stage.json`, do `*.pipeline.json` e das mensagens públicas |
176240
| `tests` | testes .NET (unitários e de integração com Python real) e fixtures de trilha |
@@ -183,12 +247,17 @@ dotnet test --project tests/PythonScriptRunner.Core.Tests
183247
dotnet test --project tests/PythonScriptRunner.EntityFrameworkCore.Tests # precisa de Docker
184248
dotnet test --project tests/PythonScriptRunner.RabbitMQ.Tests # precisa de Docker
185249
dotnet test --project tests/PythonScriptRunner.Worker.Tests # precisa de Docker
250+
dotnet test --project tests/PythonScriptRunner.Dashboard.Tests # precisa de Docker
186251

187252
cd python/psrunner
188253
uv sync
189254
uv run pytest
190255
```
191256

257+
O teste de desempenho do painel usa ~200 mil execuções; `PSR_PERF_RUNS_PER_DAY=50000` roda o volume médio completo (90 dias).
258+
O documento OpenAPI do painel é comparado com `tests/PythonScriptRunner.Dashboard.Tests/Snapshots/openapi-v1.verified.json`;
259+
depois de revisar uma mudança intencional, `PSR_ACCEPT_SNAPSHOTS=1` atualiza o snapshot.
260+
192261
## Licença
193262

194263
[MIT](LICENSE)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
using Microsoft.AspNetCore.Authentication;
2+
using Microsoft.AspNetCore.Authentication.Cookies;
3+
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
4+
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
5+
6+
// Standalone dashboard of PythonScriptRunner (spec 009): API, real-time updates and panel of spec 007, plus the outbox relay.
7+
// Fixed login by default, with users from environment variables (PythonScriptRunner__Dashboard__Login__Users__0__Name, __Role,
8+
// __PasswordHash); OIDC when Oidc__Authority is set (D-125). Also: ConnectionStrings__psr, ConnectionStrings__rabbitmq,
9+
// Dashboard__PathPrefix (default /psr), PSR_AUTO_MIGRATE and ASPNETCORE_FORWARDEDHEADERS_ENABLED behind a proxy.
10+
var builder = WebApplication.CreateBuilder(args);
11+
builder.AddServiceDefaults("psr-dashboard");
12+
13+
var configuration = builder.Configuration;
14+
var prefix = configuration["Dashboard:PathPrefix"] is { Length: > 0 } path ? "/" + path.Trim('/') : "/psr";
15+
var oidc = configuration.GetSection("Oidc");
16+
var useOidc = !string.IsNullOrWhiteSpace(oidc["Authority"]);
17+
if (!useOidc && !configuration.GetSection("PythonScriptRunner:Dashboard:Login:Users").GetChildren().Any())
18+
{
19+
throw new InvalidOperationException(
20+
"The dashboard has no way to sign in: set the fixed login users (PythonScriptRunner__Dashboard__Login__Users__0__Name, __Role and "
21+
+ "__PasswordHash, with the hash from 'psr hash-password') or enable OIDC (Oidc__Authority, Oidc__ClientId and Oidc__ClientSecret).");
22+
}
23+
24+
var roleClaim = configuration["Dashboard:RoleClaim"] ?? "roles";
25+
if (useOidc)
26+
{
27+
// Host authentication (BFF): the cookie keeps the session, OpenID Connect signs in; tokens never reach the browser.
28+
builder.Services.AddAuthentication(o =>
29+
{
30+
o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
31+
o.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
32+
})
33+
.AddCookie(o =>
34+
{
35+
o.Cookie.Name = "psr.host";
36+
o.Cookie.HttpOnly = true;
37+
o.Cookie.SameSite = SameSiteMode.Lax;
38+
o.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
39+
o.ExpireTimeSpan = TimeSpan.FromHours(8);
40+
o.SlidingExpiration = true;
41+
})
42+
.AddOpenIdConnect(o =>
43+
{
44+
o.Authority = oidc["Authority"];
45+
o.ClientId = oidc["ClientId"];
46+
o.ClientSecret = oidc["ClientSecret"];
47+
o.RequireHttpsMetadata = oidc.GetValue("RequireHttpsMetadata", true);
48+
o.ResponseType = OpenIdConnectResponseType.Code;
49+
o.UsePkce = true;
50+
o.SaveTokens = false;
51+
o.GetClaimsFromUserInfoEndpoint = true;
52+
o.MapInboundClaims = false;
53+
o.Scope.Add("profile");
54+
o.TokenValidationParameters.NameClaimType = "name";
55+
o.TokenValidationParameters.RoleClaimType = roleClaim;
56+
});
57+
}
58+
59+
builder.Services
60+
.AddPythonScriptRunner(o => configuration.GetSection("PythonScriptRunner").Bind(o))
61+
.AddEntityFrameworkCore(o =>
62+
{
63+
configuration.GetSection("PythonScriptRunner:EntityFrameworkCore").Bind(o);
64+
o.UseNpgsql(configuration.GetConnectionString("psr")
65+
?? throw new InvalidOperationException("Set the PostgreSQL connection string ConnectionStrings__psr."));
66+
})
67+
.AddRabbitMq(o =>
68+
{
69+
configuration.GetSection("PythonScriptRunner:RabbitMQ").Bind(o);
70+
o.ConnectionString = configuration.GetConnectionString("rabbitmq")
71+
?? throw new InvalidOperationException("Set the RabbitMQ connection string ConnectionStrings__rabbitmq.");
72+
})
73+
.AddDashboard(o =>
74+
{
75+
if (!useOidc)
76+
{
77+
o.UseFixedLogin(login => login.BindConfiguration());
78+
return;
79+
}
80+
81+
// Each role needs its own claim value (Dashboard__Roles__Reader...); the highest one the user has wins.
82+
string Role(string name, string fallback) => configuration[$"Dashboard:Roles:{name}"] ?? fallback;
83+
o.UseHostAuthentication(roles =>
84+
{
85+
roles.Reader = p => p.RequireAuthenticatedUser().RequireClaim(roleClaim, Role("Reader", "psr-leitor"));
86+
roles.Executor = p => p.RequireAuthenticatedUser().RequireClaim(roleClaim, Role("Executor", "psr-executor"));
87+
roles.Author = p => p.RequireAuthenticatedUser().RequireClaim(roleClaim, Role("Author", "psr-autor"));
88+
roles.Admin = p => p.RequireAuthenticatedUser().RequireClaim(roleClaim, Role("Admin", "psr-admin"));
89+
roles.LoginUrl = "/account/login?returnUrl={returnUrl}";
90+
roles.LogoutUrl = "/account/logout";
91+
});
92+
});
93+
string[] ready = [ServiceDefaultsExtensions.ReadyTag];
94+
builder.Services.AddHealthChecks().AddPythonScriptRunnerDatabase(tags: ready).AddPythonScriptRunnerBroker(tags: ready);
95+
96+
var app = builder.Build();
97+
if (configuration.GetValue<bool>("PSR_AUTO_MIGRATE"))
98+
{
99+
await app.Services.MigratePythonScriptRunnerAsync();
100+
}
101+
102+
app.UseAuthentication();
103+
app.UseAuthorization();
104+
if (useOidc)
105+
{
106+
// The panel itself is challenged (redirect to the provider); the API answers 401 JSON and the hub refuses (spec 009, AC-10).
107+
app.Use(async (context, next) =>
108+
{
109+
if (context.Request.Path.StartsWithSegments(prefix, out var rest)
110+
&& !rest.StartsWithSegments("/api") && !rest.StartsWithSegments("/hubs")
111+
&& context.User.Identity?.IsAuthenticated != true)
112+
{
113+
await context.ChallengeAsync(new AuthenticationProperties { RedirectUri = context.Request.PathBase + context.Request.Path + context.Request.QueryString });
114+
return;
115+
}
116+
117+
await next(context);
118+
});
119+
app.MapGet("/account/login", (string? returnUrl) =>
120+
TypedResults.Challenge(new AuthenticationProperties { RedirectUri = IsLocal(returnUrl) ? returnUrl : prefix + "/" }));
121+
app.MapGet("/account/logout", () => TypedResults.SignOut(new AuthenticationProperties { RedirectUri = prefix + "/" },
122+
[CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]));
123+
}
124+
125+
app.MapDefaultEndpoints();
126+
app.MapPythonScriptRunnerDashboard(prefix);
127+
await app.RunAsync();
128+
129+
// Only paths of this site are accepted as return addresses (no open redirect).
130+
static bool IsLocal(string? url) =>
131+
url is { Length: > 0 } && url[0] == '/' && (url.Length == 1 || (url[1] != '/' && url[1] != '\\'));
132+
133+
/// <summary>Entry point (public for the integration tests).</summary>
134+
public partial class Program;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<RootNamespace>PythonScriptRunner.DashboardHost</RootNamespace>
5+
</PropertyGroup>
6+
7+
<ItemGroup>
8+
<ProjectReference Include="..\..\src\PythonScriptRunner.Dashboard\PythonScriptRunner.Dashboard.csproj" />
9+
<ProjectReference Include="..\PythonScriptRunner.ServiceDefaults\PythonScriptRunner.ServiceDefaults.csproj" />
10+
</ItemGroup>
11+
12+
<ItemGroup>
13+
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
14+
</ItemGroup>
15+
16+
</Project>

src/PythonScriptRunner.Core/Pipelines/TypeCompatibility.cs renamed to src/PythonScriptRunner.Abstractions/Fields/FieldCompatibility.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1-
namespace PythonScriptRunner.Core.Pipelines;
1+
namespace PythonScriptRunner;
22

33
/// <summary>Which source types can feed which target types in a binding (spec 003, safe widenings only).</summary>
4-
internal static class TypeCompatibility
4+
public static class FieldCompatibility
55
{
6-
public static bool IsAssignable(FieldDefinition source, FieldDefinition target) =>
7-
IsAssignable(source.Type, target.Type, source, target);
6+
/// <summary>Whether a value of <paramref name="source"/> can be bound to <paramref name="target"/>.</summary>
7+
/// <param name="source">Field that produces the value.</param>
8+
/// <param name="target">Field that receives it.</param>
9+
public static bool IsAssignable(FieldDefinition source, FieldDefinition target)
10+
{
11+
ArgumentNullException.ThrowIfNull(source);
12+
ArgumentNullException.ThrowIfNull(target);
13+
return IsAssignable(source.Type, target.Type, source, target);
14+
}
815

916
private static bool IsAssignable(FieldType source, FieldType target, FieldDefinition sourceField, FieldDefinition targetField)
1017
{

‎src/PythonScriptRunner.Abstractions/Fields/FieldDefinition.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
using System.Text.Json.Nodes;
2+
using System.Text.Json.Serialization;
23

34
namespace PythonScriptRunner;
45

56
/// <summary>Declaration of one input or output of a stage.</summary>
67
/// <param name="Name">Field name (<c>^[a-z_][a-z0-9_]{0,63}$</c>).</param>
78
/// <param name="Type">Field type.</param>
9+
[JsonConverter(typeof(FieldDefinitionJsonConverter))]
810
public sealed record FieldDefinition(string Name, FieldType Type)
911
{
1012
/// <summary>Whether a value must be provided (inputs) or produced (outputs). Default: <c>true</c>.</summary>

0 commit comments

Comments
 (0)