Add deployment
CI / frontend (push) Failing after 57s
CI / backend (push) Failing after 6m27s

This commit is contained in:
voltsrage
2026-08-05 00:26:20 +08:00
parent 9e88ff6113
commit 2a3ef62a7d
86 changed files with 2320 additions and 174 deletions
@@ -39,7 +39,7 @@ public class EsIndexerService : BackgroundService
// Consumers must be idempotent. See idempotency contract above.
EnableAutoCommit = false,
EnablePartitionEof = false
};
}.ApplySecurity(_kafkaOptions);
using var consumer = new ConsumerBuilder<string, string>(config).Build();
@@ -26,7 +26,7 @@ public class GcsScoringService : BackgroundService
GroupId = "gcs-scoring",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false
};
}.ApplySecurity(_kafkaOptions);
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
@@ -18,7 +18,7 @@ public class KafkaTopicProvisioner : IHostedService
using var admin = new AdminClientBuilder(new AdminClientConfig
{
BootstrapServers = _options.BootstrapServers
}).Build();
}.ApplySecurity(_options)).Build();
var topicNames = new[]
{
@@ -48,7 +48,8 @@ public sealed class KafkaConsumerLagCollector : BackgroundService
private async Task CollectGroupLagAsync(string groupId, CancellationToken ct)
{
var adminConfig = new AdminClientConfig
{ BootstrapServers = _kafkaOptions.BootstrapServers };
{ BootstrapServers = _kafkaOptions.BootstrapServers }
.ApplySecurity(_kafkaOptions);
using var admin = new AdminClientBuilder(adminConfig).Build();
@@ -69,7 +70,7 @@ public sealed class KafkaConsumerLagCollector : BackgroundService
{
BootstrapServers = _kafkaOptions.BootstrapServers,
GroupId = $"__lag-probe",
}).Build();
}.ApplySecurity(_kafkaOptions)).Build();
long totalLag = 0;
foreach (var tpo in partitions)
@@ -26,7 +26,7 @@ public class News2ScoringService : BackgroundService
GroupId = "news2-scoring",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false
};
}.ApplySecurity(_kafkaOptions);
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
@@ -42,7 +42,7 @@ public sealed class NotificationPublisherService : BackgroundService
AutoOffsetReset = Enum.Parse<AutoOffsetReset>(
_kafkaOptions.NotificationPublisherAutoOffsetReset, ignoreCase: true),
EnableAutoCommit = false,
};
}.ApplySecurity(_kafkaOptions);
using var consumer = new ConsumerBuilder<string, string>(consumerConfig).Build();
consumer.Subscribe(new[]
@@ -43,7 +43,7 @@ public class OutboxRelayService : BackgroundService
EnableIdempotence = true,
MessageSendMaxRetries = 3,
RetryBackoffMs = 100
}).Build();
}.ApplySecurity(_options)).Build();
var factory = RabbitMqConnectionFactory.Create(_rabbitOpts);
_rabbitConnection = factory.CreateConnection("outbox-relay");
@@ -31,7 +31,7 @@ public class SepsisEngineService : BackgroundService
GroupId = "sepsis-engine",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false
};
}.ApplySecurity(_kafkaOptions);
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
@@ -26,7 +26,7 @@ public class SofaScoringService : BackgroundService
GroupId = "sofa-scoring",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false
};
}.ApplySecurity(_kafkaOptions);
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe(new[]
@@ -26,7 +26,7 @@ public class TrendAnalyzerService : BackgroundService
GroupId = "trend-analyzer",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false
};
}.ApplySecurity(_kafkaOptions);
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
@@ -26,7 +26,7 @@ public class WarningAlertService : BackgroundService
GroupId = "warning-evaluator",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false
};
}.ApplySecurity(_kafkaOptions);
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
@@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore;
/// <summary>
/// One-shot bootstrap: creates the first Admin user when the ClinicalUsers
/// table is empty. Refuses to run if any user already exists so it cannot
/// be used as a backdoor after the system is in use.
///
/// Usage:
/// dotnet VigilCareClinicalAPI.dll create-admin --username &lt;u&gt; --password &lt;p&gt; --display-name "&lt;n&gt;"
/// </summary>
public static class CreateAdminCommand
{
public static async Task<int> RunAsync(IServiceProvider services, string[] args)
{
var username = RequireArg(args, "--username");
var password = RequireArg(args, "--password");
var displayName = RequireArg(args, "--display-name");
if (username.Length > 100)
{
Console.Error.WriteLine("Username must be 100 characters or fewer.");
return 1;
}
if (password.Length < 8)
{
Console.Error.WriteLine("Password must be at least 8 characters.");
return 1;
}
if (displayName.Length > 200)
{
Console.Error.WriteLine("Display name must be 200 characters or fewer.");
return 1;
}
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
if (await db.ClinicalUsers.AnyAsync())
{
Console.Error.WriteLine(
"Refusing to create admin: ClinicalUsers already contains at least one row. " +
"create-admin is a one-shot bootstrap and cannot be used as a backdoor.");
return 1;
}
var user = new ClinicalUser
{
Id = Guid.NewGuid(),
Username = username.Trim(),
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
DisplayName = displayName.Trim(),
Role = ClinicalRole.Admin,
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow,
};
db.ClinicalUsers.Add(user);
await db.SaveChangesAsync();
Console.WriteLine($"Created admin user '{user.Username}' (id={user.Id}).");
return 0;
}
private static string RequireArg(string[] args, string name)
{
for (var i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
{
var value = args[i + 1];
if (string.IsNullOrWhiteSpace(value) || value.StartsWith("--", StringComparison.Ordinal))
break;
return value;
}
}
throw new ArgumentException($"Missing required argument: {name}");
}
}
@@ -0,0 +1,125 @@
using Microsoft.EntityFrameworkCore;
/// <summary>
/// One-shot / idempotent registration of a clinical site + ward gateway with
/// caller-supplied IDs. Production cannot rely on GatewayRegistrySeeder (demo
/// data is gated off); the gateway authenticates using Gateway:GatewayId /
/// Gateway:SiteId from its environment, so those GUIDs must exist in the API DB.
///
/// Usage:
/// dotnet VigilCareClinicalAPI.dll register-gateway \
/// --site-id &lt;guid&gt; --gateway-id &lt;guid&gt; \
/// --site-code SITE-01 --site-name "General Hospital" \
/// --gateway-code GW-ICU-1 --department ICU \
/// [--address "123 Main St"]
///
/// Re-running with the same IDs is a no-op success. Conflicting codes or IDs fail.
/// </summary>
public static class RegisterGatewayCommand
{
public static async Task<int> RunAsync(IServiceProvider services, string[] args)
{
var siteId = RequireGuid(args, "--site-id");
var gatewayId = RequireGuid(args, "--gateway-id");
var siteCode = RequireArg(args, "--site-code").Trim();
var siteName = RequireArg(args, "--site-name").Trim();
var gatewayCode = RequireArg(args, "--gateway-code").Trim();
var department = RequireArg(args, "--department").Trim();
var address = GetArg(args, "--address")?.Trim();
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var existingGateway = await db.WardGateways
.Include(g => g.Site)
.FirstOrDefaultAsync(g => g.Id == gatewayId);
if (existingGateway is not null)
{
if (existingGateway.SiteId != siteId
|| !string.Equals(existingGateway.GatewayCode, gatewayCode, StringComparison.Ordinal)
|| !string.Equals(existingGateway.Department, department, StringComparison.Ordinal))
{
Console.Error.WriteLine(
$"Gateway id {gatewayId} already exists with different site/code/department. Aborting.");
return 1;
}
Console.WriteLine(
$"Gateway '{existingGateway.GatewayCode}' (id={gatewayId}) already registered — nothing to do.");
return 0;
}
var existingSite = await db.ClinicalSites.FirstOrDefaultAsync(s => s.Id == siteId);
if (existingSite is null)
{
var codeTaken = await db.ClinicalSites.AnyAsync(s => s.SiteCode == siteCode);
if (codeTaken)
{
Console.Error.WriteLine($"Site code '{siteCode}' is already registered under a different id.");
return 1;
}
var site = new ClinicalSite(siteCode, siteName, address);
db.Entry(site).Property(nameof(ClinicalSite.Id)).CurrentValue = siteId;
db.ClinicalSites.Add(site);
Console.WriteLine($"Created site '{siteCode}' (id={siteId}).");
}
else if (!string.Equals(existingSite.SiteCode, siteCode, StringComparison.Ordinal))
{
Console.Error.WriteLine(
$"Site id {siteId} already exists as code '{existingSite.SiteCode}', " +
$"not '{siteCode}'. Aborting.");
return 1;
}
var duplicateCode = await db.WardGateways.AnyAsync(g =>
g.SiteId == siteId && g.GatewayCode == gatewayCode);
if (duplicateCode)
{
Console.Error.WriteLine(
$"Gateway code '{gatewayCode}' already exists for site {siteId} under a different id.");
return 1;
}
var gateway = new WardGateway(siteId, gatewayCode, department);
db.Entry(gateway).Property(nameof(WardGateway.Id)).CurrentValue = gatewayId;
db.WardGateways.Add(gateway);
await db.SaveChangesAsync();
Console.WriteLine($"Registered gateway '{gatewayCode}' (id={gatewayId}) on site {siteId}.");
return 0;
}
private static Guid RequireGuid(string[] args, string name)
{
var raw = RequireArg(args, name);
if (!Guid.TryParse(raw, out var id) || id == Guid.Empty)
throw new ArgumentException($"{name} must be a non-empty GUID.");
return id;
}
private static string RequireArg(string[] args, string name)
{
var value = GetArg(args, name);
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException($"Missing required argument: {name}");
return value;
}
private static string? GetArg(string[] args, string name)
{
for (var i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
{
var value = args[i + 1];
if (string.IsNullOrWhiteSpace(value) || value.StartsWith("--", StringComparison.Ordinal))
return null;
return value;
}
}
return null;
}
}
@@ -11,4 +11,11 @@ public class KafkaOptions
public int MaxPoisonRetries { get; set; } = 5;
public string NotificationPublisherGroupId { get; set; } = "notification-publisher";
public string NotificationPublisherAutoOffsetReset { get; set; } = "Earliest";
/// <summary>Plaintext for local compose; SaslSsl (etc.) for production.</summary>
public string SecurityProtocol { get; set; } = "Plaintext";
public string? SaslMechanism { get; set; }
public string? SaslUsername { get; set; }
public string? SaslPassword { get; set; }
public string? SslCaLocation { get; set; }
}
@@ -0,0 +1,29 @@
using Confluent.Kafka;
public static class KafkaSecurityExtensions
{
/// <summary>
/// Applies the configured security protocol and SASL credentials to any
/// Confluent client config. Called by every producer, consumer, and admin
/// client so credentials are configured in exactly one place.
/// </summary>
public static T ApplySecurity<T>(this T config, KafkaOptions options)
where T : ClientConfig
{
if (Enum.TryParse<SecurityProtocol>(options.SecurityProtocol, true, out var protocol))
config.SecurityProtocol = protocol;
if (!string.IsNullOrWhiteSpace(options.SaslMechanism)
&& Enum.TryParse<SaslMechanism>(options.SaslMechanism, true, out var mechanism))
{
config.SaslMechanism = mechanism;
config.SaslUsername = options.SaslUsername;
config.SaslPassword = options.SaslPassword;
}
if (!string.IsNullOrWhiteSpace(options.SslCaLocation))
config.SslCaLocation = options.SslCaLocation;
return config;
}
}
@@ -9,4 +9,10 @@ public sealed class RabbitMqOptions
// Drives both the paging worker poll timeout and the DLQ x-message-ttl.
// In production: 300000 (5 min). In tests: 5000 (5 sec).
public int PagingAckTimeoutMs { get; init; } = 300000;
}
/// <summary>
/// When true, enables TLS on the AMQP connection (typical production port 5671).
/// Local compose uses plaintext on 5674/5672 — leave false.
/// </summary>
public bool UseSsl { get; init; } = false;
}
@@ -3,4 +3,12 @@ public class ElasticsearchOptions
public const string Section = "Elasticsearch";
public string Uri { get; set; } = null!;
public ElasticIndexOptions Indices { get; set; } = null!;
}
// Production clusters run with xpack.security enabled. Supply either an
// API key (preferred) or basic credentials; leave all null for the
// security-disabled development cluster.
public string? ApiKey { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public bool DisableCertificateValidation { get; set; }
}
@@ -40,7 +40,7 @@ public sealed class DataLakeWriterService : BackgroundService
GroupId = "data-lake-writer",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false,
};
}.ApplySecurity(_kafkaOptions);
using var consumer = new ConsumerBuilder<string, string>(consumerConfig).Build();
consumer.Subscribe(new[]
+47
View File
@@ -0,0 +1,47 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Restore layer — copy only project files so NuGet restore is cached
# independently of source changes. ClinicalContracts must be present
# because VigilCareClinicalAPI.csproj ProjectReferences it.
COPY VigilCareClinical.sln ./
COPY VigilCare.ClinicalContracts/VigilCare.ClinicalContracts.csproj VigilCare.ClinicalContracts/
COPY VigilCareClinicalAPI/VigilCareClinicalAPI.csproj VigilCareClinicalAPI/
RUN dotnet restore VigilCareClinicalAPI/VigilCareClinicalAPI.csproj
COPY VigilCare.ClinicalContracts/ VigilCare.ClinicalContracts/
COPY VigilCareClinicalAPI/ VigilCareClinicalAPI/
RUN dotnet publish VigilCareClinicalAPI/VigilCareClinicalAPI.csproj \
-c Release -o /app/publish --no-restore
# Scrub development secrets from the published appsettings.json (Step 4).
# Production supplies Jwt / PHI / API keys via environment variables only.
RUN sed -i \
-e 's/"SigningKey": "[^"]*"/"SigningKey": ""/' \
-e 's/"SearchTokenKey": "[^"]*"/"SearchTokenKey": ""/' \
-e 's/"Gateway": "dev-[^"]*"/"Gateway": ""/' \
-e 's/"ApiKey": "dev-integration[^"]*"/"ApiKey": ""/' \
/app/publish/appsettings.json
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
# curl is required by the container healthcheck; the aspnet image does not ship it.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish .
# The keyring directory must be owned by the runtime user — see Step 10.
# The aspnet:8.0 image ships a non-root `app` user (uid 1654).
RUN mkdir -p /app/data-protection-keys && chown -R app:app /app
USER app
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD curl -fsS http://localhost:8080/health/live || exit 1
ENTRYPOINT ["dotnet", "VigilCareClinicalAPI.dll"]
@@ -14,7 +14,7 @@ public sealed class KafkaHealthCheck : IHealthCheck
using var admin = new AdminClientBuilder(new AdminClientConfig
{
BootstrapServers = _options.BootstrapServers
}).Build();
}.ApplySecurity(_options)).Build();
var metadata = await Task.Run(
() => admin.GetMetadata(TimeSpan.FromSeconds(5)), cancellationToken);
@@ -2,13 +2,27 @@ using RabbitMQ.Client;
public static class RabbitMqConnectionFactory
{
public static ConnectionFactory Create(RabbitMqOptions o, bool dispatchConsumersAsync = false) => new()
public static ConnectionFactory Create(RabbitMqOptions o, bool dispatchConsumersAsync = false)
{
HostName = o.Host,
Port = o.Port,
UserName = o.Username,
Password = o.Password,
VirtualHost = o.VirtualHost,
DispatchConsumersAsync = dispatchConsumersAsync,
};
var factory = new ConnectionFactory
{
HostName = o.Host,
Port = o.Port,
UserName = o.Username,
Password = o.Password,
VirtualHost = o.VirtualHost,
DispatchConsumersAsync = dispatchConsumersAsync,
};
if (o.UseSsl)
{
factory.Ssl = new SslOption
{
Enabled = true,
ServerName = o.Host,
};
}
return factory;
}
}
+67 -9
View File
@@ -94,8 +94,21 @@ try
.GetSection(ElasticsearchOptions.Section)
.Get<ElasticsearchOptions>()!;
builder.Services.AddSingleton(
new ElasticsearchClient(new Uri(esOptions.Uri)));
builder.Services.AddSingleton(_ =>
{
var settings = new ElasticsearchClientSettings(new Uri(esOptions.Uri));
if (!string.IsNullOrWhiteSpace(esOptions.ApiKey))
settings = settings.Authentication(new Elastic.Transport.ApiKey(esOptions.ApiKey));
else if (!string.IsNullOrWhiteSpace(esOptions.Username))
settings = settings.Authentication(
new Elastic.Transport.BasicAuthentication(esOptions.Username, esOptions.Password ?? ""));
if (esOptions.DisableCertificateValidation)
settings = settings.ServerCertificateValidationCallback((_, _, _, _) => true);
return new ElasticsearchClient(settings);
});
builder.Services.Configure<RabbitMqOptions>(
builder.Configuration.GetSection(RabbitMqOptions.Section));
@@ -326,7 +339,21 @@ try
var app = builder.Build();
if (!app.Environment.IsEnvironment("Testing"))
// One-shot CLI verbs exit before hosting. Skip demo seeding for them so
// create-admin / register-gateway never race the demo UserSeeder.
var isCliCommand = args.Contains("encrypt-phi")
|| args.Contains("create-admin")
|| args.Contains("register-gateway");
// Demo data — including the seeded demo users with well-known passwords —
// must never be created in production. Seeding:EnableDemoData defaults to
// true so local development and the existing verification scripts are
// unaffected; appsettings.Production.json sets it to false.
var enableDemoData = builder.Configuration.GetValue("Seeding:EnableDemoData", true)
&& !app.Environment.IsEnvironment("Testing")
&& !isCliCommand;
if (enableDemoData)
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@@ -345,11 +372,14 @@ try
});
}
app.UseSwagger();
app.UseSwaggerUI(options =>
if (builder.Configuration.GetValue("Swagger:Enabled", !app.Environment.IsProduction()))
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "VigilCare Clinical API v1");
});
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "VigilCare Clinical API v1");
});
}
app.UseMiddleware<CorrelationIdMiddleware>();
app.UseMiddleware<FhirApiKeyOrJwtMiddleware>();
@@ -372,7 +402,7 @@ try
ResponseWriter = HealthCheckResponseWriter.WriteAsync
}).AllowAnonymous();
app.MapMetrics("/metrics");
app.MapMetrics("/metrics").AllowAnonymous();
app.MapControllers();
if (args.Contains("encrypt-phi"))
@@ -380,7 +410,35 @@ try
await EncryptPhiCommand.RunAsync(app.Services);
return;
}
if (args.Contains("create-admin"))
{
try
{
Environment.ExitCode = await CreateAdminCommand.RunAsync(app.Services, args);
}
catch (ArgumentException ex)
{
Console.Error.WriteLine(ex.Message);
Environment.ExitCode = 1;
}
return;
}
if (args.Contains("register-gateway"))
{
try
{
Environment.ExitCode = await RegisterGatewayCommand.RunAsync(app.Services, args);
}
catch (ArgumentException ex)
{
Console.Error.WriteLine(ex.Message);
Environment.ExitCode = 1;
}
return;
}
app.Run();
}
@@ -37,6 +37,7 @@
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="2.3.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
<PackageReference Include="StackExchange.Redis" Version="3.0.0" />
@@ -0,0 +1,43 @@
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.Seq" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"WriteTo": [
{ "Name": "Console", "Args": { "formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact" } },
{ "Name": "Seq", "Args": { "serverUrl": "http://localhost:5341" } }
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ],
"Properties": {
"Application": "VigilCareClinicalAPI"
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"Kafka": {
"ReplicationFactor": 3,
"NumPartitions": 6,
"SecurityProtocol": "SaslSsl"
},
"Minio": {
"UseSSL": true
},
"RabbitMq": {
"UseSsl": true
},
"PhiEncryption": {
"LogListAccess": true
},
"Swagger": { "Enabled": false },
"Seeding": { "EnableDemoData": false }
}
+10 -1
View File
@@ -48,6 +48,8 @@
"GcsScored": "gcs.scored"
},
"NumPartitions": 6,
"ReplicationFactor": 1,
"SecurityProtocol": "Plaintext",
"OutboxBatchSize": 100,
"OutboxPollIntervalMs": 1000
},
@@ -64,7 +66,8 @@
"Port": 5674,
"Username": "guest",
"Password": "guest",
"PagingAckTimeoutMs": 300000
"PagingAckTimeoutMs": 300000,
"UseSsl": false
},
"Minio": {
"Endpoint": "localhost:9005",
@@ -219,5 +222,11 @@
"AlertQuality": {
"IntervalMinutes": 60,
"WindowHours": 1
},
"Swagger": {
"Enabled": true
},
"Seeding": {
"EnableDemoData": true
}
}