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
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ against a plaintext local Postgres (the #261/#262 TLS floor).
A fresh database has no admin user — base data is migration-baked, credentials
never are. Provision one: [first admin provisioning](docs/runbooks/first-admin-provisioning.md).

**Aspire local stack:** see [Aspire local development](docs/runbooks/aspire-local-development.md)
to orchestrate Postgres, Redis, API and Vite locally. It is not a deployment path.

### Resetting the dev database

Always wipe through Compose:
Expand Down
12 changes: 12 additions & 0 deletions Cluckwork.sln
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cluckwork.Infrastructure",
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cluckwork.Api", "src/Cluckwork.Api/Cluckwork.Api.csproj", "{10000001-0000-0000-0000-000000000004}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cluckwork.AppHost", "src/Cluckwork.AppHost/Cluckwork.AppHost.csproj", "{10000001-0000-0000-0000-000000000008}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cluckwork.Domain.Tests", "tests/Cluckwork.Domain.Tests/Cluckwork.Domain.Tests.csproj", "{10000001-0000-0000-0000-000000000005}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cluckwork.Application.Tests", "tests/Cluckwork.Application.Tests/Cluckwork.Application.Tests.csproj", "{10000001-0000-0000-0000-000000000006}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cluckwork.Api.IntegrationTests", "tests/Cluckwork.Api.IntegrationTests/Cluckwork.Api.IntegrationTests.csproj", "{10000001-0000-0000-0000-000000000007}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cluckwork.AppHost.Tests", "tests/Cluckwork.AppHost.Tests/Cluckwork.AppHost.Tests.csproj", "{10000001-0000-0000-0000-000000000009}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -39,6 +43,10 @@ Global
{10000001-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000001-0000-0000-0000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000001-0000-0000-0000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU
{10000001-0000-0000-0000-000000000008}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10000001-0000-0000-0000-000000000008}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000001-0000-0000-0000-000000000008}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000001-0000-0000-0000-000000000008}.Release|Any CPU.Build.0 = Release|Any CPU
{10000001-0000-0000-0000-000000000005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10000001-0000-0000-0000-000000000005}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000001-0000-0000-0000-000000000005}.Release|Any CPU.ActiveCfg = Release|Any CPU
Expand All @@ -51,6 +59,10 @@ Global
{10000001-0000-0000-0000-000000000007}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000001-0000-0000-0000-000000000007}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000001-0000-0000-0000-000000000007}.Release|Any CPU.Build.0 = Release|Any CPU
{10000001-0000-0000-0000-000000000009}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10000001-0000-0000-0000-000000000009}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10000001-0000-0000-0000-000000000009}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10000001-0000-0000-0000-000000000009}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
6 changes: 6 additions & 0 deletions aspire.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "https://aspire.dev/reference/cli/configuration/schema.json",
"appHost": {
"path": "./src/Cluckwork.AppHost/Cluckwork.AppHost.csproj"
}
}
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Elsewhere in the repo:
|---|---|
| [`../README.md`](../README.md) | What Cluckwork is, and running it |
| [`../CONTRIBUTING.md`](../CONTRIBUTING.md) | Local development, tests, branches, commits |
| [`runbooks/aspire-local-development.md`](runbooks/aspire-local-development.md) | Local Aspire stack, persistence and safe reset |
| [`../AGENTS.md`](../AGENTS.md) | The canonical rule set — every invariant, for humans and coding agents |
| [`../SECURITY.md`](../SECURITY.md) | Reporting a vulnerability; what CI enforces |
| [`../specs/product/`](../specs/product/) | Product & technical spec, phase plan, [glossary](../specs/product/GLOSSARY.md) |
Expand Down
184 changes: 184 additions & 0 deletions docs/runbooks/aspire-local-development.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# Aspire local development

Aspire is the local orchestration path for the development stack. It starts the
existing PostgreSQL, Redis, API and Vite applications; it does not replace the
production-like Docker Compose workflow or introduce a deployment path.

## Prerequisites

Install the .NET 10 SDK, Node/npm, `jq`, and a Docker-compatible container
engine. Use Aspire CLI 13.5. The following installs that version only under this
worktree; it does not alter a global Aspire installation:

```bash
curl -fsSL https://aspire.dev/install.sh | bash -s -- \
--version 13.5.0 \
--install-path "$PWD/obj/aspire-cli" \
--skip-path
obj/aspire-cli/aspire --version
```

The single AppHost `http` launch profile runs the AppHost in `Development`, and
the AppHost configures the API resource for `Development`. Consequently Aspire
loads the existing generated Postgres parameter from local user-secrets instead
of replacing it. The API performs its normal development migrations and reads
local credentials from user-secrets. Do not put secrets in a checked-in file. A
fresh database has no administrator; use the
[first-admin runbook](first-admin-provisioning.md) if you need one.

## Start and observe

From the repository root, use the AppHost explicitly on every lifecycle and
query command:

```bash
apphost=./src/Cluckwork.AppHost/Cluckwork.AppHost.csproj
aspire=./obj/aspire-cli/aspire

$aspire run --apphost "$apphost" --detach --non-interactive
$aspire wait postgres --apphost "$apphost" --timeout 180 --non-interactive
$aspire wait redis --apphost "$apphost" --timeout 180 --non-interactive
$aspire wait api --apphost "$apphost" --timeout 180 --non-interactive
$aspire wait web --apphost "$apphost" --timeout 180 --non-interactive
$aspire describe --apphost "$apphost" --format Json --non-interactive
```

Read the advertised `web` HTTP endpoint from the final description rather than
assuming a port. Aspire assigns host ports dynamically. The Vite app receives
the API target from Aspire, and binds its supplied port strictly, so calling
`<web-endpoint>/api/...` exercises the development proxy. The dashboard URL and
access mechanism are shown by the CLI; treat any dashboard token as a secret.

The CLI's resource display names are not log/telemetry query arguments. Use
AppHost-targeted, unfiltered queries, then filter by the exact trace ID:

```bash
$aspire logs --apphost "$apphost" --search <32-hex-trace-id> --format Json --tail 300 --non-interactive
$aspire otel traces --apphost "$apphost" --trace-id <32-hex-trace-id> --format Json --non-interactive
$aspire stop --apphost "$apphost" --non-interactive
```

The dashboard is the local surface for resource status, logs, traces and
metrics. Filter telemetry to the current API resource and use a non-health
request when checking HTTP, database and runtime metrics; startup and readiness
traffic is not evidence of application work.

## Persistence and reset

PostgreSQL data is durable across normal AppHost stop/start. Redis is
intentionally ephemeral. Before stopping or resetting anything, resolve the
current `postgres` resource and its container from the current `describe` output
and Docker metadata; never reuse a remembered container or volume name.

To reset only this AppHost database, resolve the current resource each time;
never use a remembered container or volume name. The resource's configured
image is useful context, but validate Docker's immutable top-level `.Image` ID
and its `RepoDigests`; `.Config.Image` is only the requested/display reference.
Then require exactly one writable Docker `volume` mount at `/var/lib/postgresql`.
Its literal name must be nonempty, contain no `/`, and pass `docker volume
inspect`. Stop the AppHost, wait until the exact container is either
`Running=false` or absent from `docker ps -a --no-trunc`, and wait until no
container references the volume before removing it non-force:

```bash
set -euo pipefail

expected_postgres_digest=3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a
expected_describe_image="docker.io/library/postgres@sha256:$expected_postgres_digest"
expected_repo_digest="postgres@sha256:$expected_postgres_digest"
expected_full_repo_digest="docker.io/library/postgres@sha256:$expected_postgres_digest"

describe=$($aspire describe --apphost "$apphost" --format Json --non-interactive)
postgres_resource=$(printf '%s' "$describe" | jq -cer '
[.resources[] | select(.displayName == "postgres")]
| if length == 1 then .[0] else error("expected exactly one postgres resource") end')
postgres_container=$(printf '%s' "$postgres_resource" | jq -er '
.properties["container.id"]
| if type == "string" and length > 0 then . else error("missing postgres container ID") end')
configured_image=$(printf '%s' "$postgres_resource" | jq -er '
.properties["container.image"]
| if type == "string" and length > 0 then . else error("missing postgres image") end')
[ "$configured_image" = "$expected_describe_image" ]

actual_image_id=$(docker inspect --format '{{.Image}}' "$postgres_container")
case "$actual_image_id" in sha256:*) ;; *) exit 1 ;; esac
repo_digests=$(docker image inspect --format '{{json .RepoDigests}}' "$actual_image_id")
printf '%s' "$repo_digests" | jq -e \
--arg expected "$expected_repo_digest" \
--arg expected_full "$expected_full_repo_digest" \
'any(.[]; . == $expected or . == $expected_full)' >/dev/null

mounts=$(docker inspect --format '{{json .Mounts}}' "$postgres_container")
volume_mount=$(printf '%s' "$mounts" | jq -cer '
[.[] | select(.Type == "volume" and .RW == true)]
| if length == 1 and .[0].Destination == "/var/lib/postgresql"
then .[0]
else error("expected exactly one writable volume at the PostgreSQL 18 data path")
end')
volume_name=$(printf '%s' "$volume_mount" | jq -er '.Name')
mount_destination=$(printf '%s' "$volume_mount" | jq -er '.Destination')
case "$volume_name" in ''|*/*) exit 1 ;; esac
docker volume inspect "$volume_name" >/dev/null

printf 'PostgreSQL reset target:\n container: %s\n configured image: %s\n image ID: %s\n volume: %s\n mount: %s\n' \
"$postgres_container" "$configured_image" "$actual_image_id" "$volume_name" "$mount_destination"

$aspire stop --apphost "$apphost" --non-interactive

container_stopped=false
for ((attempt = 0; attempt < 60; attempt++)); do
if running=$(docker inspect --format '{{.State.Running}}' "$postgres_container" 2>/dev/null); then
if [ "$running" = false ]; then
container_stopped=true
break
fi
else
all_container_ids=$(docker ps -a --no-trunc --format '{{json .ID}}')
container_present=$(printf '%s\n' "$all_container_ids" \
| jq -ser --arg expected "$postgres_container" 'index($expected) != null')
if [ "$container_present" = false ]; then
container_stopped=true
break
fi
fi
sleep 1
done
[ "$container_stopped" = true ]

volume_released=false
for ((attempt = 0; attempt < 60; attempt++)); do
volume_references=$(docker ps -a --filter "volume=$volume_name" --format '{{.ID}}')
if [ -z "$volume_references" ]; then
volume_released=true
break
fi
sleep 1
done
[ "$volume_released" = true ]

docker volume inspect "$volume_name" >/dev/null
docker volume rm "$volume_name"
```

Do not use wildcard, recursive, force, or remembered-name deletion. Start the
AppHost again with the same explicit command and wait for `api`; its normal
Development migrations recreate the schema.

## Compose fallback

Docker Compose remains the production-like stack and the dependency-only
fallback for IDE/API work:

```bash
docker compose -f deploy/docker-compose.dev.yml up -d
dotnet run --project src/Cluckwork.Api
```

For the built SPA plus API stack, use:

```bash
docker compose -f deploy/docker-compose.yml up --build
```

Aspire is local orchestration only; it does not change Compose, simulation, or
production operations.
Loading
Loading