forked from votrongdao/FlowX
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathProgram.cs
More file actions
284 lines (248 loc) · 14.9 KB
/
Copy pathProgram.cs
File metadata and controls
284 lines (248 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
using Crm;
using FlowX;
using FlowX.Generated;
using FlowX.Hosting;
using FlowX.Http;
using FlowX.Mcp;
using FlowX.Postgres;
using FlowX.RabbitMq;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Npgsql;
// Before anything is built: this invocation may be a container's health check rather than a
// start. See CrmProbe — the runtime image has no shell to run one with, so the runtime is the
// probe.
if (CrmProbe.WasAsked(args))
{
return await CrmProbe.RunAsync().ConfigureAwait(false);
}
var builder = WebApplication.CreateSlimBuilder(args);
// The one piece of configuration this application has, and it is required rather than
// defaulted.
//
// A CRM is its tables. Starting without a database would give a process that answers every
// probe with `crm.schema_unreachable` and looks exactly like a misconfigured network; failing
// here names what is missing.
var connectionString =
builder.Configuration["FlowX:Postgres"]
?? Environment.GetEnvironmentVariable("FLOWX_POSTGRES_CONNECTION")
?? throw new InvalidOperationException(
"This sample keeps its own tables in PostgreSQL, so it needs one. Set " +
"FLOWX_POSTGRES_CONNECTION (or FlowX:Postgres in configuration) to a connection " +
"string, e.g. \"Host=localhost;Port=5432;Database=postgres;Username=postgres\".");
builder.Services.AddRouting();
// What a browser needs before it can call any of this at all.
//
// ORIGINS ARE CONFIGURED, NEVER WILDCARDED. `AllowAnyOrigin` with credentials is refused by every
// browser, and without credentials it invites any page on the internet to spend a user's session
// on their behalf. FLOWX_CORS_ORIGINS is a comma-separated list; with none set no origin is
// allowed and a browser client is a deployment step somebody has to take deliberately.
//
// A native mobile client needs none of this — CORS is a browser rule and an app is not a browser
// — which is why the absence of the variable does not break one.
var origins = (builder.Configuration["FlowX:CorsOrigins"]
?? Environment.GetEnvironmentVariable("FLOWX_CORS_ORIGINS")
?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
builder.Services.AddCors(cors => cors.AddDefaultPolicy(policy => policy
.WithOrigins(origins)
.WithHeaders("Authorization", "Content-Type", "Idempotency-Key")
.WithMethods("POST")
// The preflight answer is cacheable, and a client that revalidates every write is a client
// making two requests for every one. Ten minutes is short enough that changing the list above
// takes effect within a deployment.
.SetPreflightMaxAge(TimeSpan.FromMinutes(10))));
builder.Services.AddFlowX(options =>
{
options.ApplicationName = "Crm";
// A tenant becomes mandatory here: an invocation naming none is refused at admission with
// `tenant.required` rather than defaulted, because a default tenant is the precise shape of
// a cross-tenant read. The tenant is derived from the caller's `tid` claim and from nothing
// else — never a header, never the payload (ADR-0046).
//
// Row rather than Schema: docs/16 §2's L1, one database, one tenant column, and
// PostgreSQL's own row-level security deciding. That is the level migration 0002's policies
// are written for, and the level §6 specifies.
options.TenantIsolation = TenantIsolation.Row;
options.Tenants.Add(CrmTokens.NorthwindTenant);
options.Tenants.Add(CrmTokens.ContosoTenant);
});
// Authentication, which is the only reason this application has a principal — or a tenant — at
// all. Nothing here names a route or a permission: a rule attached to the endpoint would hold
// over HTTP and not over a broker or an agent, which is exactly the transport-attached
// authorisation the capability stance exists to replace.
builder.Services
.AddAuthentication(CrmTokenHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, CrmTokenHandler>(CrmTokenHandler.SchemeName, null);
// The journal, the lease store, the recovery index — and, for this sample, the NpgsqlDataSource
// every CRM statement is issued on. `crm.schema.probe` is Ephemeral and journals nothing, so
// the first three are registered for the flows packages 4 to 12 add rather than for this one;
// the data source is what CrmSchemaReader resolves.
builder.Services.AddFlowXPostgres(connectionString);
// The outbox and the change feed, over the same table. `.Emit<T>()` stages an event in the
// step's own transaction; the outbox drains those rows to the broker for the three
// subscriptions on `lead.created`, and the change feed offers the identical rows straight to
// `crm.process.transition` with no broker in the path at all.
builder.Services.AddFlowXPostgresOutbox();
builder.Services.AddFlowXPostgresChangeFeed();
builder.Services.AddHostedService<CrmOutboxPump>();
// The broker — §9. Optional, and what it costs to leave it out is stated rather than hidden:
// the configured process still runs, because it is driven by the change feed; the three
// subscriptions on `lead.created` do not, because a [BusTrigger] with no IBusConsumer has
// nothing to read. Nothing else moves, and no flow mentions RabbitMQ.
var broker =
builder.Configuration["FlowX:RabbitMq"]
?? Environment.GetEnvironmentVariable("FLOWX_RABBITMQ_CONNECTION");
if (broker is { Length: > 0 })
{
builder.Services.AddFlowXRabbitMq(broker);
builder.Services.AddFlowXRabbitMqConsumer(broker);
}
else
{
// The half the "optional" claim above needs to be true. AddFlowXPostgresOutbox resolves an
// IEventPublisher when the host starts, so without this the process died on StartAsync with
// a service-not-registered exception — the sentence above, CrmOutboxPump's own remarks and
// the README all described a deployment that could not start.
builder.Services.AddSingleton<IEventPublisher, UnpublishedOutbox>();
}
// What the capabilities are built out of. These are this sample's own types — the stores that
// issue the SQL, the schema reader, the stand-in enrichment provider — and nothing generated
// knows they exist, which is why they are named here and the capabilities are not.
builder.Services.AddSingleton<CrmSchemaReader>();
builder.Services.AddSingleton<ConversionStore>();
builder.Services.AddSingleton<IntakeStore>();
builder.Services.AddSingleton<ProcessStore>();
builder.Services.AddSingleton<SalesStore>();
builder.Services.AddSingleton<WorkStore>();
builder.Services.AddSingleton<EnrichmentProvider>();
builder.Services.AddSingleton<EnrichmentStore>();
builder.Services.AddSingleton<AssistantStore>();
builder.Services.AddSingleton<CustomSchemaStore>();
builder.Services.AddSingleton<ConnectorStore>();
builder.Services.AddSingleton<FieldPolicyStore>();
builder.Services.AddSingleton<RollupStore>();
builder.Services.AddSingleton<QueryStore>();
builder.Services.AddSingleton<FormulaStore>();
builder.Services.AddSingleton<SyncStore>();
builder.Services.AddSingleton<BulkJobStore>();
builder.Services.AddSingleton<ReportStore>();
builder.Services.AddSingleton<LabelStore>();
builder.Services.AddSingleton<PlanningStore>();
builder.Services.AddSingleton<ManagementStore>();
builder.Services.AddSingleton<PerformanceStore>();
builder.Services.AddSingleton<TerritoryStore>();
builder.Services.AddSingleton<ApprovalStore>();
builder.Services.AddSingleton<ApproverResolver>();
builder.Services.AddSingleton<ServiceStore>();
builder.Services.AddSingleton<CampaignStore>();
builder.Services.AddSingleton<EntityQueryStore>();
builder.Services.AddSingleton<ConfigStore>();
builder.Services.AddSingleton<PlanDetailStore>();
builder.Services.AddSingleton<ProcessViewStore>();
builder.Services.AddSingleton<SeedStore>();
builder.Services.AddSingleton<CrmSchemaHealthCheck>();
// Tagged `ready`, beside the runtime's own drain check. Registered here rather than inside
// AddFlowX because the schema is this sample's and not the platform's.
builder.Services
.AddHealthChecks()
.AddCheck<CrmSchemaHealthCheck>(CrmSchemaHealthCheck.Name, tags: [CrmSchemaHealthCheck.Tag]);
builder.Services.AddSingleton<SeedApplier>();
builder.Services.AddSingleton(TimeProvider.System);
// Starting state from a file, when one is configured. Registered after the stores it uses and
// before UseFlowX, so a tenant is configured by the time anything can read it; the migrators
// above run first because they run before the host starts at all.
builder.Services.AddHostedService<CrmSeeder>();
// The one thing in this application that talks to somebody else's system. Registered under the
// interface, so a deployment with a vault or a real Slack renderer replaces this line and
// nothing else; the registry, the queue and the sweep do not know which one they got.
builder.Services.AddHttpClient<IConnectorTransport, HttpConnectorTransport>(
static client => client.Timeout = TimeSpan.FromSeconds(10));
// Every capability the twenty-five steps invoke, and every flow's dispatcher — generated from
// the constructors the generator itself wrote. Forty hand-written lines stood here until the
// runtime settled the lifetime question they were waiting on: the catalogues hold a resolved
// dispatcher for the life of the node, a recovery sweep resumes an instance with no scope to
// resolve another from, and singleton is therefore the only lifetime that is honest. TryAdd, so
// a capability registered above under an interface would still win.
builder.Services.AddFlowXCapabilities();
// The agent surface's tool bindings — §10 package 11. One flow reaches a model, it reads, and
// it meets the same crm.read stance a person meets over HTTP.
builder.Services.AddFlowXAgentTools();
var app = builder.Build();
// Migrating is a decision, not a consequence of building a container: AddFlowXPostgres
// deliberately does not apply DDL, because every replica of a rolling update would then race to
// migrate at start-up. One process, one sample, so it is done here — a real deployment runs its
// schema changes as a deployment step.
//
// Two migrators and two ledgers, in this order because the second's GRANT statements need the
// `flowx_tenant` role and the first is where it is created. Neither is a prerequisite of the
// other beyond that: `schema_migration` is the platform's history and `crm_schema_migration` is
// this sample's, and they advance independently.
await app.Services.GetRequiredService<PostgresMigrator>()
.MigrateAsync(app.Lifetime.ApplicationStopping)
.ConfigureAwait(false);
await new CrmMigrator(app.Services.GetRequiredService<NpgsqlDataSource>())
.MigrateAsync(app.Lifetime.ApplicationStopping)
.ConfigureAwait(false);
// Runs the scheme above, so HttpContext.User carries the token's claims — and its tenant — by
// the time the generated endpoint reads them. Without this line every request is anonymous and
// `crm.schema.count`, which admits any authenticated caller and no anonymous one, refuses them
// all — which looks exactly like a broken token.
// Before authentication, so a preflight — which carries no Authorization header by definition —
// is answered rather than refused. A browser that gets a 401 on the preflight never sends the
// real request, and the failure looks like the endpoint being down.
app.UseCors();
app.UseAuthentication();
// Two probes, because they answer two questions and wiring one to both is how a draining node
// gets restarted mid-drain.
//
// LIVE: no checks at all. "Is this process worth restarting?" — and the honest answer is yes
// whenever the endpoint can be reached, because everything else is somebody else's outage. A
// liveness probe that consulted the database restarts every replica during a failover, and none
// of the restarts can reach the database either.
app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });
// READY: the runtime's own drain state, and whether this build's tables are actually there. The
// second is the one that catches a deployment nobody migrated — which otherwise starts, answers
// every probe, and fails on the first request that touches a new column.
app.MapHealthChecks(
"/health/ready",
new HealthCheckOptions { Predicate = check => check.Tags.Contains(CrmSchemaHealthCheck.Tag) });
// The name everything already points at. Readiness, because that is what it has always run —
// AddFlowX registers its drain check tagged `ready` and this endpoint had no predicate.
app.MapHealthChecks("/health");
// Everything this application declared, in one call: the routes from each [HttpTrigger], the
// three subscriptions on `lead.created`, the change subscription that drives the configured
// process, and the two sweeps. Nothing in this file names a route, a topic or a cron expression
// — they are read off the attributes the manifest was written from.
//
// The two sweeps are why this is one call and not six. They were declared, published, listed in
// the README's table of surfaces, and registered by nothing, because AddFlowXSchedules() was the
// one line of six that nobody wrote.
app.UseFlowX();
// The agent surface, served from the same manifest the HTTP routes are generated from — so the
// tools a model can see are exactly the flows carrying [AgentTrigger] and nothing else.
app.MapFlowXMcp("/mcp");
// The application's own description, generated from the manifest the compiler wrote — so it
// cannot drift from the routes, the contracts or the error codes, because all three came from the
// same source. Anonymous: it names routes and codes, never data, and a description that needs a
// credential is one no client generator or gateway can read.
#pragma warning disable IL2026 // The manifest names contracts as strings; see the note below.
app.MapFlowXOpenApi(FlowXManifest.Json, CrmJsonContext.Default);
#pragma warning restore IL2026
// WHY THE SUPPRESSION, AND WHAT WOULD REMOVE IT. Turning a manifest's type *name* back into a
// type searches the loaded assemblies, which the trimmer cannot follow — so the API is annotated
// and warns here rather than producing a quietly emptier document in a trimmed build. Two things
// make it safe in this application: it is marked IsTrimmable but is not published trimmed (it
// carries an Npgsql data source, which is why there is no PublishAot either), and a contract the
// search fails to find is described as opaque with a note saying so, never with a guessed shape.
//
// The real fix is for the compiler to emit the contract types alongside the manifest it already
// writes — it has the symbols — and hand them to the overload that takes them. That is a change to
// the generator rather than to this line.
app.MapFlowXOpenApiUi();
// The manifest itself, beside the document derived from it. The setup screen that lists what this
// application does needs the flows' profiles and their bus and schedule triggers, and OpenAPI has
// neither — it describes an HTTP surface, and half of what runs here has no path.
app.MapFlowXManifest(FlowXManifest.Json);
await app.RunAsync().ConfigureAwait(false);
return 0;