feat: Improve error handling and logging in installer commands
This commit is contained in:
parent
8b3f9284df
commit
c56fc21915
@ -1,4 +1,7 @@
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
# Reduce noisy SQL statement logs (EF Core):
|
||||
Logging__LogLevel__Microsoft__EntityFrameworkCore=Warning
|
||||
Logging__LogLevel__Microsoft__EntityFrameworkCore__Database__Command=Warning
|
||||
ConnectionStrings__Default=Host=localhost;Database=send_engine;Username=postgres;Password=postgres
|
||||
ESP__Provider=ses
|
||||
Db__AutoMigrate=true
|
||||
|
||||
@ -58,3 +58,5 @@ mass_mail_engine/
|
||||
- SES/SNS 簽章完整驗證(目前 `Ses__SkipSignatureValidation=false` 僅檢查 header 存在)
|
||||
- 事件重試/DLQ 策略補強(目前主要依 SQS redrive policy)
|
||||
- recipient 狀態機擴充(delivery/open/click 的完整優先序與狀態轉換)
|
||||
- 與 Member Center 對齊並同步可讀的 list 名稱(避免 auto-created `list-{uuid}` 成為最終顯示)
|
||||
- SES 郵件 header 補 `List-ID`(優先使用同步後的 list 名稱),避免 Gmail 取消訂閱視窗顯示 `(Unknown)`
|
||||
|
||||
@ -27,6 +27,8 @@
|
||||
- 使用 Installer 建立 webhook client(`id` 自動隨機產生):
|
||||
- `dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <tenant_uuid> [--tenant-name <name>] --client-id <client_id> --name <display_name> --scopes <scope1,scope2>`
|
||||
- 例如:`dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id 11111111-1111-1111-1111-111111111111 --tenant-name "Tenant A" --client-id member-center-webhook --name "Member Center Webhook" --scopes newsletter:events.write`
|
||||
- 若 `client_id` 已存在且屬於同一 tenant,Installer 會重用既有 webhook client(不重複建立),可直接搭配 `--upsert-member-center` 做同步
|
||||
- 若 `client_id` 已存在但屬於不同 tenant,Installer 會中止並回報錯誤
|
||||
- 若 tenant 不存在,Installer 會先自動建立 `tenants` 基本資料,避免 webhook 出現 `tenant_not_found`
|
||||
- 建立成功後,Member Center webhook header `X-Client-Id` 請帶回傳的 `id`
|
||||
- 若要自動同步到 Member Center `POST /integrations/send-engine/webhook-clients/upsert`(保留原手動流程):
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@ -9,57 +9,78 @@ using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
var command = args.Length > 0 ? args[0] : "migrate";
|
||||
var verboseErrors = args.Any(x => string.Equals(x, "--verbose-errors", StringComparison.OrdinalIgnoreCase));
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (command is "-h" or "--help" or "help")
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
|
||||
{
|
||||
Console.WriteLine("SendEngine Installer");
|
||||
Console.WriteLine("Usage:");
|
||||
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- migrate");
|
||||
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- ensure-tenant --tenant-id <uuid> [--tenant-name <string>]");
|
||||
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <uuid> [--tenant-name <string>] --client-id <string> --name <string> --scopes <csv> [--upsert-member-center --mc-base-url <url> --mc-client-id <string> --mc-client-secret <string> [--mc-scope <scope>] [--mc-token-path <path>] [--mc-upsert-path <path>] [--mc-token-url <url>] [--mc-upsert-url <url>]]");
|
||||
return;
|
||||
}
|
||||
WriteError("Unhandled installer exception.");
|
||||
if (eventArgs.ExceptionObject is Exception ex)
|
||||
{
|
||||
WriteError(verboseErrors ? ex.ToString() : ex.Message);
|
||||
}
|
||||
};
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddInfrastructure(configuration);
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
if (command == "migrate")
|
||||
try
|
||||
{
|
||||
using var scope = provider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
|
||||
db.Database.Migrate();
|
||||
Console.WriteLine("Database migration completed.");
|
||||
return;
|
||||
}
|
||||
// Always print a startup line so container logs are never completely empty.
|
||||
WriteError($"[installer] start command={command} utc={startedAt:O}");
|
||||
|
||||
if (command == "add-webhook-client")
|
||||
{
|
||||
var options = ParseOptions(args);
|
||||
options.TryGetValue("tenant-id", out var tenantIdRaw);
|
||||
options.TryGetValue("tenant-name", out var tenantName);
|
||||
options.TryGetValue("client-id", out var clientId);
|
||||
options.TryGetValue("name", out var name);
|
||||
options.TryGetValue("scopes", out var scopesRaw);
|
||||
if (command is "-h" or "--help" or "help")
|
||||
{
|
||||
Console.WriteLine("SendEngine Installer");
|
||||
Console.WriteLine("Usage:");
|
||||
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- migrate");
|
||||
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- ensure-tenant --tenant-id <uuid> [--tenant-name <string>]");
|
||||
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <uuid> [--tenant-name <string>] --client-id <string> --name <string> --scopes <csv> [--upsert-member-center --mc-base-url <url> --mc-client-id <string> --mc-client-secret <string> [--mc-scope <scope>] [--mc-token-path <path>] [--mc-upsert-path <path>] [--mc-token-url <url>] [--mc-upsert-url <url>]]");
|
||||
Console.WriteLine(" optional: --verbose-errors (print full exception stack traces to stderr)");
|
||||
return;
|
||||
}
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddInfrastructure(configuration);
|
||||
Verbose("infrastructure registered");
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
Verbose("service provider built");
|
||||
|
||||
if (command == "migrate")
|
||||
{
|
||||
Verbose("running migrate");
|
||||
using var scope = provider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
|
||||
db.Database.Migrate();
|
||||
Console.WriteLine("Database migration completed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (command == "add-webhook-client")
|
||||
{
|
||||
Verbose("running add-webhook-client");
|
||||
var options = ParseOptions(args);
|
||||
options.TryGetValue("tenant-id", out var tenantIdRaw);
|
||||
options.TryGetValue("tenant-name", out var tenantName);
|
||||
options.TryGetValue("client-id", out var clientId);
|
||||
options.TryGetValue("name", out var name);
|
||||
options.TryGetValue("scopes", out var scopesRaw);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantIdRaw) ||
|
||||
string.IsNullOrWhiteSpace(clientId) ||
|
||||
string.IsNullOrWhiteSpace(name) ||
|
||||
string.IsNullOrWhiteSpace(scopesRaw))
|
||||
{
|
||||
Console.WriteLine("Missing required options.");
|
||||
Console.WriteLine("Required: --tenant-id, --client-id, --name, --scopes");
|
||||
WriteError("Missing required options.");
|
||||
WriteError("Required: --tenant-id, --client-id, --name, --scopes");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(tenantIdRaw, out var tenantId))
|
||||
{
|
||||
Console.WriteLine("Invalid --tenant-id, expected UUID.");
|
||||
WriteError("Invalid --tenant-id, expected UUID.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
@ -70,12 +91,14 @@ if (command == "add-webhook-client")
|
||||
|
||||
if (scopes.Length == 0)
|
||||
{
|
||||
Console.WriteLine("Invalid --scopes, expected comma-separated values.");
|
||||
WriteError("Invalid --scopes, expected comma-separated values.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
using var scope = provider.CreateScope();
|
||||
Verbose("db scope created");
|
||||
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
|
||||
Verbose("db context resolved");
|
||||
|
||||
var tenant = await db.Tenants.FirstOrDefaultAsync(x => x.Id == tenantId);
|
||||
if (tenant is null)
|
||||
@ -91,28 +114,36 @@ if (command == "add-webhook-client")
|
||||
Console.WriteLine($"Tenant created. tenant_id={tenant.Id}");
|
||||
}
|
||||
|
||||
var exists = await db.AuthClients.AsNoTracking().AnyAsync(x => x.ClientId == clientId);
|
||||
if (exists)
|
||||
var entity = await db.AuthClients.FirstOrDefaultAsync(x => x.ClientId == clientId);
|
||||
if (entity is not null)
|
||||
{
|
||||
Console.WriteLine($"client_id already exists: {clientId}");
|
||||
Environment.Exit(1);
|
||||
if (entity.TenantId != tenantId)
|
||||
{
|
||||
WriteError($"client_id already exists under different tenant. client_id={clientId} existing_tenant_id={entity.TenantId} requested_tenant_id={tenantId}");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
Console.WriteLine("Webhook client already exists. Reusing existing record.");
|
||||
}
|
||||
else
|
||||
{
|
||||
entity = new AuthClient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
ClientId = clientId,
|
||||
Name = name,
|
||||
Scopes = scopes,
|
||||
Status = "active",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
db.AuthClients.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Verbose("auth client saved");
|
||||
Console.WriteLine("Webhook client created.");
|
||||
}
|
||||
|
||||
var entity = new AuthClient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
ClientId = clientId,
|
||||
Name = name,
|
||||
Scopes = scopes,
|
||||
Status = "active",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
db.AuthClients.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Console.WriteLine("Webhook client created.");
|
||||
Console.WriteLine($"id={entity.Id}");
|
||||
Console.WriteLine($"tenant_id={entity.TenantId}");
|
||||
Console.WriteLine($"client_id={entity.ClientId}");
|
||||
@ -123,15 +154,15 @@ if (command == "add-webhook-client")
|
||||
var mcSettings = ResolveMemberCenterUpsertSettings(options);
|
||||
if (mcSettings is null)
|
||||
{
|
||||
Console.WriteLine("Missing required Member Center options for --upsert-member-center.");
|
||||
Console.WriteLine("Required: --mc-base-url, --mc-client-id, --mc-client-secret");
|
||||
WriteError("Missing required Member Center options for --upsert-member-center.");
|
||||
WriteError("Required: --mc-base-url, --mc-client-id, --mc-client-secret");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
var token = await ResolveMemberCenterTokenAsync(mcSettings);
|
||||
var token = await ResolveMemberCenterTokenAsync(mcSettings, verboseErrors);
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
Console.WriteLine("Member Center upsert failed: unable to get access token.");
|
||||
WriteError("Member Center upsert failed: unable to get access token.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
@ -145,11 +176,12 @@ if (command == "add-webhook-client")
|
||||
|
||||
try
|
||||
{
|
||||
Verbose($"calling member center upsert url={mcSettings.UpsertWebhookClientUrl}");
|
||||
var response = await client.PostAsJsonAsync(mcSettings.UpsertWebhookClientUrl, upsertPayload);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine($"Member Center upsert failed. status={(int)response.StatusCode} body={Truncate(body, 1000)}");
|
||||
WriteError($"Member Center upsert failed. status={(int)response.StatusCode} body={Truncate(body, 1000)}");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
@ -159,34 +191,38 @@ if (command == "add-webhook-client")
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Member Center upsert request failed: {ex.Message}");
|
||||
WriteError("Member Center upsert request failed.");
|
||||
WriteError(verboseErrors ? ex.ToString() : ex.Message);
|
||||
Environment.Exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (command == "ensure-tenant")
|
||||
{
|
||||
var options = ParseOptions(args);
|
||||
options.TryGetValue("tenant-id", out var tenantIdRaw);
|
||||
options.TryGetValue("tenant-name", out var tenantName);
|
||||
if (command == "ensure-tenant")
|
||||
{
|
||||
Verbose("running ensure-tenant");
|
||||
var options = ParseOptions(args);
|
||||
options.TryGetValue("tenant-id", out var tenantIdRaw);
|
||||
options.TryGetValue("tenant-name", out var tenantName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantIdRaw))
|
||||
{
|
||||
Console.WriteLine("Missing required option: --tenant-id");
|
||||
WriteError("Missing required option: --tenant-id");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(tenantIdRaw, out var tenantId))
|
||||
{
|
||||
Console.WriteLine("Invalid --tenant-id, expected UUID.");
|
||||
WriteError("Invalid --tenant-id, expected UUID.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
using var scope = provider.CreateScope();
|
||||
Verbose("db scope created");
|
||||
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
|
||||
Verbose("db context resolved");
|
||||
var tenant = await db.Tenants.FirstOrDefaultAsync(x => x.Id == tenantId);
|
||||
if (tenant is null)
|
||||
{
|
||||
@ -205,10 +241,17 @@ if (command == "ensure-tenant")
|
||||
Console.WriteLine($"Tenant exists. tenant_id={tenant.Id}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Unknown command: {command}");
|
||||
Console.WriteLine($"Unknown command: {command}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteError("Installer failed with unhandled error.");
|
||||
WriteError(verboseErrors ? ex.ToString() : ex.Message);
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
static Dictionary<string, string> ParseOptions(string[] args)
|
||||
{
|
||||
@ -262,7 +305,7 @@ static MemberCenterUpsertSettings? ResolveMemberCenterUpsertSettings(IReadOnlyDi
|
||||
return new MemberCenterUpsertSettings(tokenUrl, upsertUrl, clientId, clientSecret, scope);
|
||||
}
|
||||
|
||||
static async Task<string?> ResolveMemberCenterTokenAsync(MemberCenterUpsertSettings settings)
|
||||
static async Task<string?> ResolveMemberCenterTokenAsync(MemberCenterUpsertSettings settings, bool verboseErrors)
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
var form = new Dictionary<string, string>
|
||||
@ -282,7 +325,7 @@ static async Task<string?> ResolveMemberCenterTokenAsync(MemberCenterUpsertSetti
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine($"Member Center token request failed. status={(int)response.StatusCode} body={Truncate(body, 1000)}");
|
||||
WriteError($"Member Center token request failed. status={(int)response.StatusCode} body={Truncate(body, 1000)}");
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -298,7 +341,8 @@ static async Task<string?> ResolveMemberCenterTokenAsync(MemberCenterUpsertSetti
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Member Center token request error: {ex.Message}");
|
||||
WriteError("Member Center token request error.");
|
||||
WriteError(verboseErrors ? ex.ToString() : ex.Message);
|
||||
}
|
||||
|
||||
return null;
|
||||
@ -319,6 +363,19 @@ static string Truncate(string? input, int maxLen)
|
||||
return input.Length <= maxLen ? input : $"{input[..maxLen]}...(truncated)";
|
||||
}
|
||||
|
||||
static void WriteError(string message)
|
||||
{
|
||||
Console.Error.WriteLine(message);
|
||||
}
|
||||
|
||||
void Verbose(string message)
|
||||
{
|
||||
if (verboseErrors)
|
||||
{
|
||||
WriteError($"[installer][verbose] {message}");
|
||||
}
|
||||
}
|
||||
|
||||
sealed record MemberCenterUpsertSettings(
|
||||
string TokenUrl,
|
||||
string UpsertWebhookClientUrl,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user