Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Copy to .env to override defaults before running `docker compose up`.
# .env is gitignored — never commit real credentials.

# Passwords for the seeded accounts. Both are OPTIONAL: when unset they fall back
# to the compiled-in defaults (Admin123! / User123!), which are what the login
# page's quick-login buttons use.
#
# Those defaults are published in this repository — set these before exposing the
# deployment to anyone else, and turn off the "Show Test Accounts" setting.
SEED_ADMIN_PASSWORD=
SEED_USER_PASSWORD=

# PostgreSQL password for the bundled database container.
# Defaults to "simplemodule" when unset — override for anything exposed.
POSTGRES_PASSWORD=simplemodule

# Public base URL, used by OpenIddict to register redirect URIs.
APP_BASE_URL=http://localhost:8080
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -451,3 +451,9 @@ baseline/
# Verification artifacts
.verify/
.qa/

# Local environment files — docker-compose reads SEED_ADMIN_PASSWORD and
# POSTGRES_PASSWORD from here, so these must never be committed.
.env
.env.*
!.env.example
7 changes: 6 additions & 1 deletion Dockerfile.worker
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,12 @@ RUN dotnet publish template/SimpleModule.Worker/SimpleModule.Worker.csproj \
--no-restore \
-p:ErrorOnDuplicatePublishOutputFiles=false

FROM mcr.microsoft.com/dotnet/runtime:10.0 AS runtime
# The worker hosts the same modules as the api, and every module carries a
# <FrameworkReference Include="Microsoft.AspNetCore.App" />. The plain runtime
# image does not ship that shared framework, so the worker started and then died
# with "No frameworks were found" on every boot. This must stay in sync with the
# api image in ./Dockerfile.
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app

RUN apt-get update \
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,44 @@ dotnet run --project template/SimpleModule.Host # https://localhost:5001
docker compose up # http://localhost:8080 with PostgreSQL 16
```

#### Seed credentials

Two accounts are seeded on first start, and the login page's quick-login buttons use these exact
credentials:

| Account | Default password | Override with |
|---------|------------------|---------------|
| `[email protected]` | `Admin123!` | `Seed__AdminPassword` |
| `[email protected]` | `User123!` | `Seed__UserPassword` |

The defaults apply in every environment so the app boots and is usable out of the box. Outside
`Development` and `Testing` the app logs a warning when it falls back to them.

> **These passwords are published in this repository.** Before exposing a deployment to anyone else,
> set `Seed__AdminPassword` (or change the password after first login) and turn off the
> **Show Test Accounts** setting, which renders the quick-login buttons and defaults to on.

```bash
docker build -t simplemodule .
docker run -e Seed__AdminPassword='<strong-password>' -p 8080:8080 simplemodule
```

`Seed__AdminPassword` is the environment-variable spelling of the `Seed:AdminPassword` configuration
key — use the latter in `appsettings.*.json`. Accounts are seeded only when they don't already
exist, so changing these values later will **not** rotate an existing password; change it from the
account page instead.

> **Upgrading an existing deployment.** Previously the demo `[email protected]` account was
> skipped outside `Development` and `Testing` when `Seed__UserPassword` was unset, so deployments that set only
> `Seed__AdminPassword` have no such account today. They will gain one, seeded with `User123!`, on
> the next restart. Set `Seed__UserPassword`, or delete the account after it appears, if you don't
> want it. The admin account is unaffected — it already exists, and existing accounts are never
> re-seeded or rotated.

Note that `Production` additionally requires OpenIddict signing and encryption certificates
(`OpenIddict__SigningCertificatePath` / `OpenIddict__EncryptionCertificatePath`, PKCS#12), and
refuses to start without them.

### Development

```bash
Expand Down
20 changes: 10 additions & 10 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ services:
# combined with seeded credentials it would hand out fully-privileged
# tokens with a single POST /connect/token.
OpenIddict__AllowPasswordGrant: "false"
# Required: password for the seeded admin account. There is deliberately
# no default — put SEED_ADMIN_PASSWORD in your .env file.
Seed__AdminPassword: ${SEED_ADMIN_PASSWORD:?Set SEED_ADMIN_PASSWORD in .env}
# Optional: password for the seeded demo user (outside Development the
# demo user is skipped entirely when this is unset).
# Optional: passwords for the seeded accounts. When unset, both fall back to
# the compiled-in defaults (Admin123! / User123!) so the stack comes up
# usable — those are the same credentials the login page's quick-login
# buttons use. They are published in this repository, so set these (or
# change the passwords after first login) before exposing the deployment.
Seed__AdminPassword: ${SEED_ADMIN_PASSWORD:-}
Seed__UserPassword: ${SEED_USER_PASSWORD:-}
# api stays in Producer mode — it enqueues jobs but never runs them.
# All IModuleJob execution lives in the worker service below.
Expand Down Expand Up @@ -56,11 +57,10 @@ services:
Database__Provider: PostgreSQL
BackgroundJobs__WorkerMode: Consumer
# The worker runs the same module set as the api, including the user
# seeder. It must see the SAME seed passwords — otherwise whichever
# process wins the startup race seeds the admin account, and a worker
# without these would silently seed the compiled-in default password,
# defeating the api's required SEED_ADMIN_PASSWORD.
Seed__AdminPassword: ${SEED_ADMIN_PASSWORD:?Set SEED_ADMIN_PASSWORD in .env}
# seeder. It must see the SAME seed passwords as the api — whichever
# process wins the startup race is the one that creates the accounts, so a
# mismatch here makes the seeded password depend on that race.
Seed__AdminPassword: ${SEED_ADMIN_PASSWORD:-}
Seed__UserPassword: ${SEED_USER_PASSWORD:-}
volumes:
- storage_data:/app/storage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,18 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
#pragma warning restore CA1031
{
LogFlushError(logger, ex);
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);

// This backoff runs inside a catch block, so the enclosing try
// cannot observe its cancellation — guard it here or a shutdown
// during the backoff escapes ExecuteAsync as a spurious crash.
try
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
}
}

Expand All @@ -93,7 +104,21 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}
if (batch.Count > 0)
{
await FlushBatchAsync(batch);
// The service provider is frequently already disposed by the time a
// failed startup unwinds to here, so this final flush can throw. It
// runs outside the loop's try, so letting it escape would surface a
// normal shutdown as a crashed BackgroundService — the exact noise
// this class of fix exists to remove — and skip LogStopped too.
try
{
await FlushBatchAsync(batch);
}
#pragma warning disable CA1031
catch (Exception ex)
#pragma warning restore CA1031
{
LogFlushError(logger, ex);
}
}

LogStopped(logger);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,41 @@ ILogger<AuditRetentionService> logger
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
try
{
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);

var checkInterval = moduleOptions.Value.RetentionCheckInterval;
var checkInterval = moduleOptions.Value.RetentionCheckInterval;

while (!stoppingToken.IsCancellationRequested)
{
try
{
await RunCleanupAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
while (!stoppingToken.IsCancellationRequested)
{
break;
}
try
{
await RunCleanupAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
#pragma warning disable CA1031
catch (Exception ex)
catch (Exception ex)
#pragma warning restore CA1031
{
LogError(logger, ex);
}
{
LogError(logger, ex);
}

await Task.Delay(checkInterval, stoppingToken);
await Task.Delay(checkInterval, stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Normal shutdown while waiting on the startup delay or the interval
// delay. Returning quietly keeps this off the BackgroundService failure
// path, which would otherwise log a critical "BackgroundService failed"
// on top of — and obscuring — whatever actually stopped the host.
}

LogStopped(logger);
}

private async Task RunCleanupAsync(CancellationToken ct)
Expand Down Expand Up @@ -87,4 +99,7 @@ private async Task RunCleanupAsync(CancellationToken ct)

[LoggerMessage(Level = LogLevel.Error, Message = "Retention cleanup failed")]
private static partial void LogError(ILogger logger, Exception ex);

[LoggerMessage(Level = LogLevel.Information, Message = "AuditRetentionService stopped")]
private static partial void LogStopped(ILogger logger);
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,18 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
#pragma warning restore CA1031
{
LogFlushError(logger, ex);
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);

// This backoff runs inside a catch block, so the enclosing try
// cannot observe its cancellation — guard it here or a shutdown
// during the backoff escapes ExecuteAsync as a spurious crash.
try
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
}
}

Expand All @@ -69,7 +80,25 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}
if (batch.Count > 0)
{
await FlushBatchAsync(batch, moduleOptions.Value.MaxLogEntries, CancellationToken.None);
// The service provider is frequently already disposed by the time a
// failed startup unwinds to here, so this final flush can throw. It
// runs outside the loop's try, so letting it escape would surface a
// normal shutdown as a crashed BackgroundService — the exact noise
// this class of fix exists to remove — and skip LogStopped too.
try
{
await FlushBatchAsync(
batch,
moduleOptions.Value.MaxLogEntries,
CancellationToken.None
);
}
#pragma warning disable CA1031
catch (Exception ex)
#pragma warning restore CA1031
{
LogFlushError(logger, ex);
}
}

LogStopped(logger);
Expand Down

This file was deleted.

This file was deleted.

Loading
Loading