PocketBase as the data layer: client, compose stack, dev environment and tests #28
No files matched your search
+5
-4
@@ -1,14 +1,14 @@
|
|||||||
# Build-Ausgaben (werden im Container frisch erzeugt)
|
# Build outputs (regenerated fresh inside the container)
|
||||||
**/bin/
|
**/bin/
|
||||||
**/obj/
|
**/obj/
|
||||||
**/out/
|
**/out/
|
||||||
|
|
||||||
# IDE- und Tooling-Kram
|
# IDE and tooling files
|
||||||
.vs/
|
.vs/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
# Versionskontrolle und Doku, im Image nicht benoetigt
|
# Version control and docs, not needed in the image
|
||||||
.git/
|
.git/
|
||||||
.gitea/
|
.gitea/
|
||||||
.gitignore
|
.gitignore
|
||||||
@@ -16,8 +16,9 @@
|
|||||||
docs/
|
docs/
|
||||||
*.md
|
*.md
|
||||||
|
|
||||||
# Docker-Dateien selbst
|
# The Docker files themselves
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
compose.yaml
|
compose.yaml
|
||||||
compose.dev.yaml
|
compose.dev.yaml
|
||||||
|
compose.test.yaml
|
||||||
@@ -4,9 +4,7 @@
|
|||||||
<settings>
|
<settings>
|
||||||
<option name="envFilePath" value="" />
|
<option name="envFilePath" value="" />
|
||||||
<option name="envFilePaths">
|
<option name="envFilePaths">
|
||||||
<list>
|
<list />
|
||||||
<option value="" />
|
|
||||||
</list>
|
|
||||||
</option>
|
</option>
|
||||||
<option name="commandLineOptions" value="--build" />
|
<option name="commandLineOptions" value="--build" />
|
||||||
<!-- Base compose (loaded first); the dev overlay in sourceFilePath wins. -->
|
<!-- Base compose (loaded first); the dev overlay in sourceFilePath wins. -->
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ nachlesen, bevor eine davon in Frage gestellt wird.
|
|||||||
| Thema | Datei |
|
| Thema | Datei |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Warum SSR statt WASM, warum keine DB (AE-1 bis AE-4) | `docs/architektur.md` |
|
| Warum SSR statt WASM, warum keine DB (AE-1 bis AE-4) | `docs/architektur.md` |
|
||||||
|
| Stack lokal starten, Tests, compose-Overlays | `docs/entwicklung.md` |
|
||||||
| Unraid, compose, NPM-Proxy-Host, Registry, Rollback | `docs/deployment.md` |
|
| Unraid, compose, NPM-Proxy-Host, Registry, Rollback | `docs/deployment.md` |
|
||||||
| Inhalte anlegen und ändern | `docs/inhalte-pflegen.md` |
|
| Inhalte anlegen und ändern (PocketBase-Admin) | `docs/redaktion.md` |
|
||||||
| Impressum, Datenschutz, Fotos | `docs/recht.md` |
|
| Impressum, Datenschutz, Fotos | `docs/recht.md` |
|
||||||
| Offene Arbeit, Meilensteine, offene Punkte | Gitea-Issues (Milestone „Elternbeirat-Website") |
|
| Offene Arbeit, Meilensteine, offene Punkte | Gitea-Issues (Milestone „Elternbeirat-Website") |
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||||
<PackageVersion Include="Testcontainers" Version="4.15.0" />
|
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
<PackageReference Include="Shouldly" />
|
<PackageReference Include="Shouldly" />
|
||||||
<PackageReference Include="Testcontainers" />
|
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Tests;
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using DotNet.Testcontainers.Builders;
|
|
||||||
using DotNet.Testcontainers.Containers;
|
|
||||||
using Elternbeirat.PocketBase;
|
using Elternbeirat.PocketBase;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Tests;
|
namespace Elternbeirat.Web.Tests;
|
||||||
@@ -12,51 +12,128 @@ namespace Elternbeirat.Web.Tests;
|
|||||||
/// against this instance instead of the live one, so they are hermetic (no network
|
/// against this instance instead of the live one, so they are hermetic (no network
|
||||||
/// to Unraid), reproducible (fixed data) and safe (isolated from production).
|
/// to Unraid), reproducible (fixed data) and safe (isolated from production).
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The seed data is deliberately fixed here rather than exported from the real
|
/// The container is started from the real <c>compose.yaml</c> + <c>compose.dev.yaml</c>
|
||||||
/// instance, so tests assert against values this file controls. Requires Docker.
|
/// (only the <c>eb-pocketbase</c> service, not the web app) by shelling out to
|
||||||
|
/// <c>docker compose</c>, so the image version, superuser env and port stay defined
|
||||||
|
/// in one place -- the compose files -- and the tests always exercise the same
|
||||||
|
/// PocketBase the stack runs. The seed data is deliberately fixed here rather than
|
||||||
|
/// exported from the real instance, so tests assert against values this file
|
||||||
|
/// controls. Requires Docker with the Compose plugin.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PocketBaseFixture : IAsyncLifetime
|
public sealed class PocketBaseFixture : IAsyncLifetime
|
||||||
{
|
{
|
||||||
// Same image tag as production, so the tests exercise the real PocketBase
|
// The service name and container port as defined in the compose files. Everything
|
||||||
// version. Superuser credentials are only used to set up the container.
|
// else about the container (image version, superuser env, host port) comes from
|
||||||
private const string Image = "ghcr.io/muchobien/pocketbase:0.40.4";
|
// compose, so there is nothing to keep in sync with it here.
|
||||||
|
private const string ServiceName = "eb-pocketbase";
|
||||||
private const int PocketBasePort = 8090;
|
private const int PocketBasePort = 8090;
|
||||||
|
|
||||||
|
// The superuser the dev overlay creates (PB_ADMIN_EMAIL/PASSWORD in
|
||||||
|
// compose.dev.yaml); used only to authenticate for the one-time seed.
|
||||||
private const string AdminEmail = "test@example.com";
|
private const string AdminEmail = "test@example.com";
|
||||||
private const string AdminPassword = "test-password"; // >= 8 chars (PB rule)
|
private const string AdminPassword = "test-password"; // >= 8 chars (PB rule)
|
||||||
|
|
||||||
private readonly IContainer _container = new ContainerBuilder(Image)
|
// A fixed compose project name for the tests, sibling to the dev stack
|
||||||
// PB_ADMIN_EMAIL/PASSWORD make the entrypoint upsert a superuser on start.
|
// ("eb-stack"). Fixed (not per-run) so the
|
||||||
// The command must stay empty, or the entrypoint skips that step.
|
// container names are predictable and a leftover from an aborted run can be
|
||||||
.WithEnvironment("PB_ADMIN_EMAIL", AdminEmail)
|
// cleaned up before the next start. Because it is its own project, it never
|
||||||
.WithEnvironment("PB_ADMIN_PASSWORD", AdminPassword)
|
// touches the dev stack -- the container_name is cleared in the test overlay so
|
||||||
.WithPortBinding(PocketBasePort, assignRandomHostPort: true)
|
// both projects can coexist.
|
||||||
.WithWaitStrategy(Wait.ForUnixContainer()
|
private const string Project = "eb-test-stack";
|
||||||
.UntilHttpRequestIsSucceeded(r => r.ForPath("/api/health").ForPort(PocketBasePort)))
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
// One HttpClient shared by all tests through the client; the fixture owns it and
|
// One HttpClient shared by all tests through the client; the fixture owns it and
|
||||||
// disposes it in DisposeAsync. Its BaseAddress is set once the container is up.
|
// disposes it in DisposeAsync. Its BaseAddress is set once the container is up.
|
||||||
private readonly HttpClient _http = new();
|
private readonly HttpClient _http = new();
|
||||||
|
|
||||||
/// <summary>Base URL of the running container, e.g. http://localhost:49153.</summary>
|
|
||||||
public Uri BaseUrl =>
|
|
||||||
new($"http://{_container.Hostname}:{_container.GetMappedPublicPort(PocketBasePort)}");
|
|
||||||
|
|
||||||
/// <summary>Creates a <see cref="PocketBaseClient"/> pointed at this container.</summary>
|
/// <summary>Creates a <see cref="PocketBaseClient"/> pointed at this container.</summary>
|
||||||
public PocketBaseClient CreateClient() => new(_http);
|
public PocketBaseClient CreateClient() => new(_http);
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
await _container.StartAsync();
|
// Clear any leftover from an earlier run that was aborted before DisposeAsync
|
||||||
_http.BaseAddress = BaseUrl;
|
// (a hard kill), so the fixed-name project starts from a clean, empty volume.
|
||||||
|
await ComposeAsync("down", "--volumes", "--remove-orphans");
|
||||||
|
// `up --wait` blocks until the service is healthy (the compose healthcheck),
|
||||||
|
// so once this returns PocketBase is ready to answer.
|
||||||
|
await ComposeAsync("up", "--detach", "--wait", ServiceName);
|
||||||
|
_http.BaseAddress = await ResolveBaseUrlAsync();
|
||||||
await SeedAsync();
|
await SeedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DisposeAsync()
|
public async Task DisposeAsync()
|
||||||
{
|
{
|
||||||
_http.Dispose();
|
_http.Dispose();
|
||||||
await _container.DisposeAsync();
|
// Remove containers, network and the (dev) volume for this project.
|
||||||
|
await ComposeAsync("down", "--volumes");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the host address compose bound the service port to.</summary>
|
||||||
|
private static async Task<Uri> ResolveBaseUrlAsync()
|
||||||
|
{
|
||||||
|
// `docker compose port <service> <port>` prints e.g. "0.0.0.0:49153".
|
||||||
|
var mapping = (await ComposeAsync(
|
||||||
|
"port", ServiceName, PocketBasePort.ToString(CultureInfo.InvariantCulture))).Trim();
|
||||||
|
var host = mapping[..mapping.LastIndexOf(':')];
|
||||||
|
var port = mapping[(mapping.LastIndexOf(':') + 1)..];
|
||||||
|
// 0.0.0.0 is a bind address, not something to connect to; use loopback.
|
||||||
|
if (host is "0.0.0.0" or "::")
|
||||||
|
host = "localhost";
|
||||||
|
return new Uri($"http://{host}:{port}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs `docker compose -p <project> -f compose.yaml -f compose.dev.yaml <args>`
|
||||||
|
/// from the repo root and returns its stdout, throwing on a non-zero exit.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<string> ComposeAsync(params string[] args)
|
||||||
|
{
|
||||||
|
var start = new ProcessStartInfo("docker")
|
||||||
|
{
|
||||||
|
WorkingDirectory = RepoRoot(),
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
// compose -p <project> -f <base> -f <dev> -f <test> <args...>. The test
|
||||||
|
// overlay swaps the dev overlay's fixed host port for a random one, so the
|
||||||
|
// test stack does not fight a running dev stack over port 8090.
|
||||||
|
start.ArgumentList.Add("compose");
|
||||||
|
start.ArgumentList.Add("-p");
|
||||||
|
start.ArgumentList.Add(Project);
|
||||||
|
start.ArgumentList.Add("-f");
|
||||||
|
start.ArgumentList.Add("compose.yaml");
|
||||||
|
start.ArgumentList.Add("-f");
|
||||||
|
start.ArgumentList.Add("compose.dev.yaml");
|
||||||
|
start.ArgumentList.Add("-f");
|
||||||
|
start.ArgumentList.Add("compose.test.yaml");
|
||||||
|
foreach (var arg in args)
|
||||||
|
start.ArgumentList.Add(arg);
|
||||||
|
|
||||||
|
using var process = Process.Start(start)
|
||||||
|
?? throw new InvalidOperationException("Could not start the docker process.");
|
||||||
|
var stdout = await process.StandardOutput.ReadToEndAsync();
|
||||||
|
var stderr = await process.StandardError.ReadToEndAsync();
|
||||||
|
await process.WaitForExitAsync();
|
||||||
|
|
||||||
|
if (process.ExitCode != 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"`docker compose {string.Join(' ', args)}` failed ({process.ExitCode}): {stderr}");
|
||||||
|
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Repo root, resolved by walking up from the test assembly to compose.yaml.</summary>
|
||||||
|
private static string RepoRoot()
|
||||||
|
{
|
||||||
|
// The test binary sits under <repo>/Elternbeirat.Web.Tests/bin/<config>/<tfm>;
|
||||||
|
// walk up until the directory that holds the compose files (the repo root).
|
||||||
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "compose.yaml")))
|
||||||
|
dir = dir.Parent;
|
||||||
|
|
||||||
|
return dir?.FullName
|
||||||
|
?? throw new InvalidOperationException("Could not locate the repo root (compose.yaml).");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
# swaps the Unraid pb_data bind mount for a throwaway named volume (the /mnt/user
|
# swaps the Unraid pb_data bind mount for a throwaway named volume (the /mnt/user
|
||||||
# path only exists on the server).
|
# path only exists on the server).
|
||||||
|
|
||||||
|
# No name: here on purpose -- the project name is set once in the base file.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
eb-pocketbase:
|
eb-pocketbase:
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Test overlay: layered on top of compose.yaml + compose.dev.yaml, used only by the
|
||||||
|
# integration tests (PocketBaseFixture). Its single job is to drop the fixed host
|
||||||
|
# port 8090 the dev overlay binds, so the test stack does not fight the running dev
|
||||||
|
# stack over that port. An empty host port ("8090" alone) lets Docker pick a random
|
||||||
|
# free one; the tests read it back with `docker compose port`.
|
||||||
|
#
|
||||||
|
# docker compose -f compose.yaml -f compose.dev.yaml -f compose.test.yaml ...
|
||||||
|
|
||||||
|
services:
|
||||||
|
eb-pocketbase:
|
||||||
|
# Drop the fixed name from the base so compose auto-names the container
|
||||||
|
# (eb-test-stack-eb-pocketbase-1). That keeps the test stack from fighting a
|
||||||
|
# running dev stack -- which keeps the readable fixed names -- over the name
|
||||||
|
# "eb-pocketbase". ("!reset null" clears the inherited scalar value.)
|
||||||
|
container_name: !reset null
|
||||||
|
# !override replaces the whole inherited ports list (a plain list would merge
|
||||||
|
# additively and keep the dev overlay's 8090:8090). "8090" without a host port
|
||||||
|
# lets Docker pick a random free one; the tests read it back with `compose port`.
|
||||||
|
ports: !override
|
||||||
|
- "8090"
|
||||||
|
|
||||||
|
eb-web:
|
||||||
|
container_name: !reset null # see eb-pocketbase above (eb-web is not started in tests)
|
||||||
+6
-1
@@ -10,6 +10,11 @@
|
|||||||
# Startup order: PocketBase comes up first; once it reports healthy the web app
|
# Startup order: PocketBase comes up first; once it reports healthy the web app
|
||||||
# starts (depends_on -> service_healthy).
|
# starts (depends_on -> service_healthy).
|
||||||
|
|
||||||
|
# Fixed project name, so the stack has a stable name in Docker regardless of the
|
||||||
|
# folder name. Set once here in the base; overlays don't set their own name: (the
|
||||||
|
# tests override it with -p eb-test-stack).
|
||||||
|
name: eb-stack
|
||||||
|
|
||||||
services:
|
services:
|
||||||
eb-pocketbase:
|
eb-pocketbase:
|
||||||
image: ghcr.io/muchobien/pocketbase:0.40.4
|
image: ghcr.io/muchobien/pocketbase:0.40.4
|
||||||
@@ -29,7 +34,7 @@ services:
|
|||||||
|
|
||||||
eb-web:
|
eb-web:
|
||||||
image: gitea.anticarnist.de/tom/elternbeirat:latest
|
image: gitea.anticarnist.de/tom/elternbeirat:latest
|
||||||
container_name: eb-web
|
container_name: eb-blazor
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
eb-pocketbase:
|
eb-pocketbase:
|
||||||
|
|||||||
+28
-54
@@ -4,11 +4,14 @@ How a new version of the website ends up on Unraid. Current state:
|
|||||||
**manual** — build the image locally, push it to the Gitea registry, pull it on
|
**manual** — build the image locally, push it to the Gitea registry, pull it on
|
||||||
Unraid. Automation via Gitea Actions comes later.
|
Unraid. Automation via Gitea Actions comes later.
|
||||||
|
|
||||||
> **Test vs. production.** As long as the domain has not moved yet and NPM is not
|
The site runs as a **two-container stack** (project `eb-stack`): the Blazor app
|
||||||
> in front of it, the container runs with a port mapping and is directly reachable
|
(`eb-blazor`) plus a PocketBase container (`eb-pocketbase`) that holds the
|
||||||
> on the local network (`http://<unraid>:5000`). In production the mapping is gone
|
content. The stack is described by `compose.yaml` (the production base) with
|
||||||
> — then NPM is the only path to the container. The two compose variants are
|
overlays layered on top.
|
||||||
> described separately below.
|
|
||||||
|
> **Running it locally is a separate document.** For starting the stack on your
|
||||||
|
> own machine and for how the tests run, see `entwicklung.md`. This document is
|
||||||
|
> only about getting a built image onto Unraid.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -44,13 +47,9 @@ The Docker login then stores the token locally, so it only has to be entered onc
|
|||||||
3. Open a **Pull Request** against `main` in Gitea and merge it there.
|
3. Open a **Pull Request** against `main` in Gitea and merge it there.
|
||||||
4. Only then build the release image from `main` (below).
|
4. Only then build the release image from `main` (below).
|
||||||
|
|
||||||
**Dev images stay local.** For trying things out on your own machine:
|
**Dev builds stay local.** For trying things out on your own machine, run the
|
||||||
|
dev stack (`entwicklung.md`) — it builds the web image from source and pushes
|
||||||
```bash
|
nothing, so a development state can never accidentally land in the registry as
|
||||||
scripts/dev-build.sh # builds elternbeirat-web:dev-<branch>, pushes NOTHING
|
|
||||||
```
|
|
||||||
|
|
||||||
This way a development state can never accidentally land in the registry as
|
|
||||||
`:latest`.
|
`:latest`.
|
||||||
|
|
||||||
## Rolling out a new version (manual)
|
## Rolling out a new version (manual)
|
||||||
@@ -101,56 +100,31 @@ pitfalls learned from experience:
|
|||||||
|
|
||||||
In the **Compose Manager Plus** plugin (Unraid web interface):
|
In the **Compose Manager Plus** plugin (Unraid web interface):
|
||||||
|
|
||||||
- **Docker** tab → **Compose** section → stack **elternbeirat** →
|
- **Docker** tab → **Compose** section → stack **eb-stack** →
|
||||||
**Compose Down**, then **Compose Up** (or "Pull" + "Up", depending on the plugin
|
**Compose Down**, then **Compose Up** (or "Pull" + "Up", depending on the plugin
|
||||||
version), so that the new `:latest` is pulled.
|
version), so that the new `:latest` is pulled. Only the `eb-blazor` image
|
||||||
|
changes on a deploy; `eb-pocketbase` and its `pb_data` volume stay as they are.
|
||||||
|
|
||||||
> `docker compose up` does **not** automatically re-pull a `:latest` if an image of
|
> `docker compose up` does **not** automatically re-pull a `:latest` if an image of
|
||||||
> the same name is already present locally. When in doubt, explicitly "Pull" first.
|
> the same name is already present locally. When in doubt, explicitly "Pull" first.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## compose variants
|
## The compose files
|
||||||
|
|
||||||
### Test (now: without NPM, directly reachable on the LAN)
|
`compose.yaml` is the **production** base and is the file Unraid uses. It defines
|
||||||
|
both services with no host port mappings — in production NPM is the only path to
|
||||||
|
the web app, and the editors reach PocketBase through an NPM subdomain (see #9).
|
||||||
|
`eb-pocketbase` keeps its data on the bind mount
|
||||||
|
`/mnt/user/appdata/pocketbase/pb_data`, which only exists on the server.
|
||||||
|
|
||||||
Present in the repo as `compose.yaml`. Pulls the registry image and maps port
|
The two overlays (`compose.dev.yaml`, `compose.test.yaml`) are for local
|
||||||
**5000 → 8080**:
|
development and the tests and are **not** used on Unraid — see `entwicklung.md`.
|
||||||
|
|
||||||
```yaml
|
> **NPM network (still open).** The base does not yet attach `eb-blazor` to the
|
||||||
services:
|
> external NPM Docker network; while the domain has not moved, add a temporary
|
||||||
eb-web:
|
> `ports:` mapping (e.g. `5000:8080`) to reach the app on the LAN, and swap it for
|
||||||
image: gitea.anticarnist.de/tom/elternbeirat:latest
|
> the `npm` external network once NPM sits in front. Tracked with #9.
|
||||||
container_name: eb-web
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
ASPNETCORE_URLS: http://+:8080
|
|
||||||
TZ: Europe/Berlin
|
|
||||||
ports:
|
|
||||||
- "5000:8080" # TEST access, remove in production
|
|
||||||
```
|
|
||||||
|
|
||||||
Reachable at `http://<unraid>:5000`.
|
|
||||||
|
|
||||||
### Production (later: only via NPM)
|
|
||||||
|
|
||||||
No `ports:` block, instead the external NPM Docker network:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
eb-web:
|
|
||||||
image: gitea.anticarnist.de/tom/elternbeirat:latest
|
|
||||||
container_name: eb-web
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
ASPNETCORE_URLS: http://+:8080
|
|
||||||
TZ: Europe/Berlin
|
|
||||||
networks: [npm]
|
|
||||||
|
|
||||||
networks:
|
|
||||||
npm:
|
|
||||||
external: true
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -162,7 +136,7 @@ which — unlike the moving `:latest` — never shifts. To roll back, replace
|
|||||||
and bring it back up:
|
and bring it back up:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
image: gitea.anticarnist.de/tom/elternbeirat:99dd052 # statt :latest
|
image: gitea.anticarnist.de/tom/elternbeirat:99dd052 # instead of :latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Which SHA tags are in the registry is shown by Gitea under
|
Which SHA tags are in the registry is shown by Gitea under
|
||||||
@@ -190,7 +164,7 @@ Requires two repository secrets (Gitea → repo → **Settings** → **Actions**
|
|||||||
The workflow currently only builds and pushes — it does **not** run the tests
|
The workflow currently only builds and pushes — it does **not** run the tests
|
||||||
yet, and it does **not** deploy. Those open steps (tests in the workflow,
|
yet, and it does **not** deploy. Those open steps (tests in the workflow,
|
||||||
auto-deploy with a health gate via Watchtower, encrypting the registry token on
|
auto-deploy with a health gate via Watchtower, encrypting the registry token on
|
||||||
Unraid) are tracked in `tasks/002-deployment-automatisieren.md`.
|
Unraid) are tracked as Gitea issues under the "Elternbeirat-Website" milestone.
|
||||||
|
|
||||||
Two known pitfalls: the `act_runner` needs Docker socket access to build, and it
|
Two known pitfalls: the `act_runner` needs Docker socket access to build, and it
|
||||||
must offer the `ubuntu-latest` label the workflow asks for. To check the runner:
|
must offer the `ubuntu-latest` label the workflow asks for. To check the runner:
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Local development
|
||||||
|
|
||||||
|
How to run the site on your own machine and how the tests run. The app is one
|
||||||
|
container (`eb-blazor`) that reads its content from a second container running
|
||||||
|
PocketBase (`eb-pocketbase`); both come up together as a compose stack.
|
||||||
|
|
||||||
|
For deploying a built image to Unraid, see `deployment.md`. For the architecture
|
||||||
|
reasoning, see `architektur.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The three compose files
|
||||||
|
|
||||||
|
There is one stack, described by a base file plus two overlays. You never edit
|
||||||
|
the base for a local run — you layer an overlay on top of it.
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|---|---|
|
||||||
|
| `compose.yaml` | Base, production-shaped: both services, **no** host ports, PocketBase data on the Unraid bind mount. Also holds the fixed project name `eb-stack`. |
|
||||||
|
| `compose.dev.yaml` | Dev overlay: adds host ports (5000, 8090), builds the web image from local source instead of pulling it, swaps the Unraid bind mount for a throwaway volume, and seeds a local PocketBase superuser. |
|
||||||
|
| `compose.test.yaml` | Test overlay: used **only** by the integration tests. Drops the fixed host port and container names so a test run does not collide with a running dev stack. |
|
||||||
|
|
||||||
|
> **Project name.** `name: eb-stack` is set once, in the base. The overlays
|
||||||
|
> deliberately don't set their own `name:` — a competing `name:` across `-f`
|
||||||
|
> files resolves depending on load order (and the CLI and Rider load them in
|
||||||
|
> opposite orders), so keeping it in one place avoids surprises. The tests
|
||||||
|
> override it with `-p eb-test-stack`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running the dev stack
|
||||||
|
|
||||||
|
### From Rider (one click)
|
||||||
|
|
||||||
|
The run configuration **DevEnvironment** (`.run/DevEnvironment.run.xml`, checked
|
||||||
|
in) starts the stack with `--build` and force-recreate. It loads the dev overlay
|
||||||
|
on top of the base for you — just press run.
|
||||||
|
|
||||||
|
### From the terminal
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.yaml -f compose.dev.yaml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
PocketBase starts first; once its healthcheck passes, the web app starts
|
||||||
|
(`depends_on: service_healthy`). Then:
|
||||||
|
|
||||||
|
- Web app: <http://localhost:5000>
|
||||||
|
- PocketBase admin UI: <http://localhost:8090/_/>
|
||||||
|
- PocketBase health: <http://localhost:8090/api/health>
|
||||||
|
|
||||||
|
> The dev overlay auto-creates a PocketBase superuser (`test@example.com` /
|
||||||
|
> `test-password`) on a **fresh** volume, so you skip the `/_/` setup screen.
|
||||||
|
> These are throwaway local credentials, not the production ones.
|
||||||
|
|
||||||
|
Stop and clean up (removes the throwaway PocketBase volume too):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.yaml -f compose.dev.yaml down --volumes
|
||||||
|
```
|
||||||
|
|
||||||
|
If the admin UI already existed once and you want the superuser recreated from
|
||||||
|
scratch, remove the volume first: `docker volume rm eb-stack_pb_data_dev`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running the tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
|
||||||
|
The integration tests (`PocketBaseFixture` in `Elternbeirat.Web.Tests`) start
|
||||||
|
their **own** PocketBase from the very same compose files — the fixture shells
|
||||||
|
out to:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -p eb-test-stack -f compose.yaml -f compose.dev.yaml -f compose.test.yaml up --detach --wait eb-pocketbase
|
||||||
|
```
|
||||||
|
|
||||||
|
so the image version, environment and port are defined in one place, not
|
||||||
|
duplicated in the test code. Only `eb-pocketbase` is started; the web app is not
|
||||||
|
built for the tests.
|
||||||
|
|
||||||
|
Because the test stack has its own project name (`eb-test-stack`), auto-named
|
||||||
|
containers and a **random** host port, it runs happily **alongside** a running
|
||||||
|
dev stack — you can have DevEnvironment up in Rider and run `dotnet test` at the
|
||||||
|
same time. The fixture cleans up before and after each run, so a hard-killed
|
||||||
|
earlier run leaves nothing behind.
|
||||||
|
|
||||||
|
**Requirements:** Docker with the Compose plugin must be available when the tests
|
||||||
|
run (they call `docker compose`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Content while developing
|
||||||
|
|
||||||
|
The app reads its content from PocketBase over the compose network
|
||||||
|
(`PocketBase__BaseUrl=http://eb-pocketbase:8090` — the *service* name, resolved
|
||||||
|
inside the compose network, independent of the container name). Editing content
|
||||||
|
means editing records in the PocketBase admin UI at
|
||||||
|
<http://localhost:8090/_/>, not editing files. See `redaktion.md`.
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# Editing content
|
|
||||||
|
|
||||||
How to add or change the visitor-facing content of the site. All content lives
|
|
||||||
as files under `Elternbeirat.Web/Content/` — there is no database and no admin
|
|
||||||
interface. Every change needs a **commit** and a **rebuild** of the image, since
|
|
||||||
the content ships inside the image, not in a volume.
|
|
||||||
|
|
||||||
> **Language convention.** File and folder names, front-matter keys and YAML keys
|
|
||||||
> are English (`posts/`, `title:`, `start:`). The text a visitor reads stays
|
|
||||||
> German — that includes the *value* after a key (`title: Vorstandsteam`) and the
|
|
||||||
> Markdown body. So you write English keys with German values.
|
|
||||||
|
|
||||||
The three content kinds:
|
|
||||||
|
|
||||||
| Kind | Where | New entry = |
|
|
||||||
|---|---|---|
|
|
||||||
| Page | `Content/pages/*.md` | one Markdown file |
|
|
||||||
| Post (news) | `Content/posts/*.md` | one Markdown file |
|
|
||||||
| Event (calendar) | `Content/events.yml` | one entry in the list |
|
|
||||||
|
|
||||||
None of these touch a `.razor` file.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pages
|
|
||||||
|
|
||||||
A page is a standalone Markdown file in `Content/pages/`. The file name (without
|
|
||||||
`.md`) is the slug and therefore the URL: `vorstandsteam.md` is served at
|
|
||||||
`/vorstandsteam`.
|
|
||||||
|
|
||||||
Front matter at the top sets the title; the body is the content:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
title: Vorstandsteam
|
|
||||||
---
|
|
||||||
|
|
||||||
# Vorstandsteam
|
|
||||||
|
|
||||||
Hier stellt sich das Team vor …
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Slugs have no umlauts.** Use `ueber-uns`, not `über-uns`; `foerderverein`,
|
|
||||||
not `förderverein`. The visible heading in the body may of course use umlauts.
|
|
||||||
- If you omit `title:`, the slug is used as the title — so always set it.
|
|
||||||
- Add the new page to the navigation only if it should appear there: edit
|
|
||||||
`Components/Layout/MainLayout.razor`. Pages that are only linked from other
|
|
||||||
pages (like the FAQ sub-pages) do not need a nav entry.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Posts (news)
|
|
||||||
|
|
||||||
A post is a Markdown file in `Content/posts/`, same idea as a page but with a
|
|
||||||
date. Posts show up in the news list at `/beitraege`, newest first, and each has
|
|
||||||
its own URL at `/beitraege/<slug>`.
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
title: Neue Sporthalle feierlich eröffnet
|
|
||||||
date: 2025-10-05
|
|
||||||
---
|
|
||||||
|
|
||||||
# Neue Sporthalle feierlich eröffnet
|
|
||||||
|
|
||||||
Der Text des Beitrags …
|
|
||||||
```
|
|
||||||
|
|
||||||
- `date:` is an ISO date, **`yyyy-MM-dd`**. It drives the sort order (newest
|
|
||||||
first) and the displayed date. A missing or malformed date sorts the post last
|
|
||||||
rather than breaking the build.
|
|
||||||
- The three most recent posts are also teased on the home page automatically —
|
|
||||||
nothing to do there.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Events (calendar)
|
|
||||||
|
|
||||||
Events are **not** separate files. They all live in the single list
|
|
||||||
`Content/events.yml`. Add an entry to the list:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- title: Elternbeiratssitzung
|
|
||||||
start: "2026-10-08 19:30"
|
|
||||||
location: Lehrerzimmer
|
|
||||||
note: Themen bitte vorab per E-Mail einreichen.
|
|
||||||
```
|
|
||||||
|
|
||||||
Keys:
|
|
||||||
|
|
||||||
| Key | Required | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `title` | yes | Short name shown in the list and calendar |
|
|
||||||
| `start` | yes | When it starts — see date formats below |
|
|
||||||
| `end` | no | End, for entries that span hours or days |
|
|
||||||
| `location` | no | e.g. `Aula`, `Mensa` |
|
|
||||||
| `note` | no | Free text shown below the entry |
|
|
||||||
|
|
||||||
**Date formats** for `start` and `end`:
|
|
||||||
|
|
||||||
- Date only → all-day event: `"2026-03-15"`
|
|
||||||
- Date with time: `"2026-03-15 19:30"` (24-hour clock)
|
|
||||||
|
|
||||||
Always keep the value in quotes so YAML treats it as text.
|
|
||||||
|
|
||||||
The overview at `/termine` splits the list automatically: entries today or later
|
|
||||||
appear under *Kommende Termine* (earliest first), past ones under *Vergangene
|
|
||||||
Termine* (most recent first). You do not sort the file yourself — order in the
|
|
||||||
YAML does not matter.
|
|
||||||
|
|
||||||
Visitors can subscribe to `/termine.ics` in their own calendar app; that feed is
|
|
||||||
generated from the same file, so a new entry appears there too.
|
|
||||||
|
|
||||||
> A malformed `start` drops just that one entry instead of breaking the whole
|
|
||||||
> page, so a typo in one event will not take the calendar down — but the entry
|
|
||||||
> silently disappears. If an event does not show up, check its `start` value.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Publishing a change
|
|
||||||
|
|
||||||
A content change is not live until the image is rebuilt and redeployed:
|
|
||||||
|
|
||||||
1. Edit or add the file under `Content/`.
|
|
||||||
2. Check it locally with `dotnet run --project Elternbeirat.Web`. Content is read
|
|
||||||
once at startup, so after editing a file **restart the process** to see the
|
|
||||||
change (or use `dotnet watch` to restart on save automatically).
|
|
||||||
3. Commit the change.
|
|
||||||
4. Rebuild and redeploy the image — see [deployment](deployment.md).
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Redaktion
|
||||||
|
|
||||||
|
Wie die sichtbaren Inhalte der Website gepflegt werden. Der Inhalt liegt in
|
||||||
|
**PocketBase** — einem kleinen Server mit eigenem Admin-Login — und wird dort
|
||||||
|
über die Weboberfläche bearbeitet. Keine Dateien, kein Commit, kein Rebuild: eine
|
||||||
|
Änderung im Admin ist sofort live.
|
||||||
|
|
||||||
|
> **Übergang (Stand #8).** Der Inhalt ist bereits vollständig in PocketBase; die
|
||||||
|
> Blazor-App wird gerade darauf umgestellt, ihn von dort zu lesen (Issue #8).
|
||||||
|
> Solange das läuft, kann die ausgelieferte Seite noch aus den alten Dateien
|
||||||
|
> unter `Content/` stammen. Sobald #8 durch ist, ist PocketBase die einzige
|
||||||
|
> Quelle und dieser Abschnitt der einzige Pflegeweg.
|
||||||
|
|
||||||
|
> **Sprachkonvention.** Feldnamen und Slugs sind **englisch** (`title`, `slug`,
|
||||||
|
> `board`, `posts`). Der Text, den ein Besucher liest, bleibt **deutsch** — also
|
||||||
|
> der Wert eines Feldes (`title: Vorstandsteam`) und der Fließtext. Englische
|
||||||
|
> Schlüssel, deutsche Werte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anmelden
|
||||||
|
|
||||||
|
Das Admin heißt „Redaktion Elternbeirat" und ist unter der PocketBase-Adresse
|
||||||
|
erreichbar:
|
||||||
|
|
||||||
|
- **Lokal:** <http://localhost:8090/_/> (Dev-Stack, siehe `entwicklung.md`).
|
||||||
|
- **Auf dem Server:** über die NPM-Subdomain (Login von außen — noch offen, #9).
|
||||||
|
|
||||||
|
Redakteure sind **Superuser**: jede vertraute Person aus dem Vorstand bekommt
|
||||||
|
einen eigenen Superuser-Zugang (unter *Collections → System → `_superusers`*).
|
||||||
|
Es gibt bewusst keinen eigenen Login und keinen Editor in der Website selbst —
|
||||||
|
gepflegt wird nur im Admin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Die Inhaltsarten (Collections)
|
||||||
|
|
||||||
|
Jede Inhaltsart ist eine **Collection**. Ein neuer Eintrag = ein neuer Record in
|
||||||
|
der passenden Collection (Button *New record*).
|
||||||
|
|
||||||
|
| Collection | Was | Öffentlich sichtbar über |
|
||||||
|
|---|---|---|
|
||||||
|
| `pages` | Feste Seiten (Vorstand, Impressum, Kontakt …) | Slug, z. B. `/board` |
|
||||||
|
| `posts` | Neuigkeiten / Beiträge | `/posts`, neueste zuerst |
|
||||||
|
| `events` | Termine (Kalender) | `/events` und der `.ics`-Feed |
|
||||||
|
| `faqs` | Häufige Fragen | Werden auf der FAQ-Seite gruppiert angezeigt |
|
||||||
|
|
||||||
|
Jede Collection hat als **letztes Feld `public`** (ja/nein). Nur Records mit
|
||||||
|
`public = true` erscheinen auf der Website — so lässt sich ein Entwurf anlegen,
|
||||||
|
ohne dass er schon sichtbar ist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seiten (`pages`)
|
||||||
|
|
||||||
|
Eine Seite hat `title` (Überschrift für Menschen, Umlaute erlaubt), `slug` (die
|
||||||
|
URL, klein und **ohne Umlaute**: `board`, nicht `über-uns`) und `body` (der
|
||||||
|
Text, als Editor-Feld).
|
||||||
|
|
||||||
|
- **Slug bleibt englisch und ohne Umlaute.** `imprint`, `privacy`, `contact`,
|
||||||
|
`board`, `patrons`. Der `title` darf deutsch mit Umlauten sein (`Förderverein`).
|
||||||
|
- **Menü:** Ob eine Seite ins Menü kommt, steht an der Seite selbst — die Felder
|
||||||
|
`location` (`header` oder `footer`) und `order` (Reihenfolge, ab 1). Die App
|
||||||
|
baut Kopf- und Fußnavigation daraus; nichts wird im Markup angefasst.
|
||||||
|
- **Eingebettete Blöcke:** Das Feld `embed` (Mehrfachauswahl aus
|
||||||
|
`faqs`/`posts`/`events`) hängt unter den Text der Seite dynamische Blöcke. So
|
||||||
|
ist die Startseite (`home`) eine normale Seite mit `embed = [posts, events]`,
|
||||||
|
und `/faqs` eine Seite mit `embed = [faqs]`. Leeres `embed` = reine Textseite.
|
||||||
|
|
||||||
|
> **Impressum und Datenschutz** hängen an ihren Slugs (`imprint`, `privacy`).
|
||||||
|
> Diese Slugs nicht ändern — sonst laufen die rechtlich verlinkten Adressen ins
|
||||||
|
> Leere (404).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Beiträge (`posts`)
|
||||||
|
|
||||||
|
Ein Beitrag hat `date`, `title`, `body`, `slug` und `public`. Er erscheint unter
|
||||||
|
`/posts` (neueste zuerst) und unter `/posts/<slug>`; die neuesten werden auch auf
|
||||||
|
der Startseite als Vorschau angeteasert.
|
||||||
|
|
||||||
|
- `date` steuert Sortierung und angezeigtes Datum.
|
||||||
|
- `slug` ist englisch, klein, ohne Umlaute (z. B. `new-sports-hall-opened`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Termine (`events`)
|
||||||
|
|
||||||
|
Ein Termin hat `start` (Pflicht), `end` (optional, für mehrstündige oder
|
||||||
|
mehrtägige), `title`, `location` (optional), `note` (optional) und `public`.
|
||||||
|
Kein Slug.
|
||||||
|
|
||||||
|
Die Übersicht `/events` trennt automatisch in kommende und vergangene Termine;
|
||||||
|
die Reihenfolge der Records spielt keine Rolle. Besucher können `/events.ics` in
|
||||||
|
ihrer Kalender-App abonnieren — der Feed entsteht aus denselben Records.
|
||||||
|
|
||||||
|
> **Uhrzeit = Ortszeit.** Das Admin-Formular rechnet Datumsfelder in die
|
||||||
|
> Browser-Zeitzone um und zeigt eine gespeicherte `19:30` je nach Sommer-/Winter-
|
||||||
|
> zeit als 20:30/21:30 an. Das ist **kein** Fehler, nur zwei Bezugssysteme: der
|
||||||
|
> gespeicherte Zahlenwert **ist** die Ortszeit (Europe/Berlin), die App zeigt ihn
|
||||||
|
> unverändert. Trage die Uhrzeit ein, die auf der Seite stehen soll.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Häufige Fragen (`faqs`)
|
||||||
|
|
||||||
|
Eine Frage hat `question`, `answer` (Markdown), `topic` (eines von
|
||||||
|
`mensa`/`schliessfach`/`elterneuro`/`elternarbeit`) und `public`. Die App baut
|
||||||
|
die FAQ-Seite generiert: sie gruppiert die Fragen nach `topic`. Der Rahmentext
|
||||||
|
oben auf `/faqs` ist eine eigene Seite in `pages` (Slug `faqs`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regeln beim Speichern
|
||||||
|
|
||||||
|
- **Nach dem Ändern einer Zugriffsregel** immer den Haupt-*Save* der Collection
|
||||||
|
drücken, sonst greift die Änderung nicht.
|
||||||
|
- Die vier Collections sind öffentlich **lesbar** (List/View offen), aber nur
|
||||||
|
eingeloggt **schreibbar** — ein Schreibversuch ohne Login wird abgewiesen. Das
|
||||||
|
ist Absicht; nicht „vereinfachen".
|
||||||
|
- **Backup:** Der gesamte Inhalt liegt in `pb_data`. Ein Backup dieses
|
||||||
|
Verzeichnisses (plus Restore-Test) ist der Sicherungsweg — eingerichtet in #9.
|
||||||
Reference in new issue
Block a user