diff --git a/.gitignore b/.gitignore index 9f50e46..2d54d94 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ obj/ # Aspire / tooling *.user + +# JetBrains IDE +.idea/ diff --git a/README.md b/README.md index f9865ac..4262712 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ is also printed in the console). | `altered-decks-api` | http://decks.altered.local.gd:8001 (or http://localhost:8001) | local | Symfony/FrankenPHP + Postgres; admin at `/admin/login` | | `altered-collection-api` | http://collection.altered.local.gd:8002 (or http://localhost:8002) | local | Symfony/API Platform/FrankenPHP + Postgres; docs at `/api/docs` | | `altered-website` | http://website.altered.local.gd:18181 (or http://localhost:18181) | local | Plain PHP/Apache + MariaDB; Keycloak SSO via the `main-site` client | +| `altered-uniques-api` | http://uniques.altered.local.gd:8005 (or http://localhost:8005) | local | Rust in-memory search over **Unique** cards (`/api/v2/*`); no DB/auth. Standalone — NOT the prod cards API | +| `altered-uniques-ui` | http://localhost:8004 | local | Vite/React demo SPA for the uniques API (Vite dev server, HMR; browser → API direct) | | `altered-dbgate` | http://localhost:18182 | local | One web DB client for **all** project DBs (decks + collection Postgres, website MariaDB) | | cards | https://cards.alteredcore.org | **prod** | decks (and the website) read cards from prod | @@ -114,6 +116,79 @@ on start (entrypoint); the DB persists in the `altered-collection-pg-data` volum The local **website** points its `COLLECTION_API_URL` at this service (over the Aspire network), so the collection features use it — though the DB starts empty. +### uniques (uniques-search-api) + +`altered-uniques-api` is [Altered-Re-Union/uniques-search-api](https://github.com/Altered-Re-Union/uniques-search-api) +(the Altered-Re-Union fork of [Taum/rust-cards-api](https://github.com/Taum/rust-cards-api)), +a Rust in-memory search engine over the Altered **Unique** cards. It is **not** the +prod cards API: it has its own contract (`/api/v2/cards`, `/api/v2/card/{reference}`, +`/api/v2/effects`) and only covers Unique characters, so it does **not** replace the +`cards.alteredcore.org` source the other services read. It runs standalone for now — +nothing else is wired to it yet (future consumers reach it at +`http://altered-uniques-api:8080` over the Aspire network). + +It's the simplest service in the stack — no database, no Keycloak, no seed, nothing +in DbGate. Two things to know: + +- **Build** — the repo's root Dockerfile is prod-only (a multi-stage Cloud Run build + that bakes the binary), so the repo ships a separate dev image at + `docker/dev/Dockerfile`: a thin `rust:1.86` image whose entrypoint downloads the + index, then runs `cargo run -p uniques-http-api --release`. The AppHost points the + `uniques` resource at that Dockerfile, bind-mounts the repo source at `/app`, and + keeps the cargo build cache in the `altered-uniques-api-target` volume (first build + is slow, like decks; later starts are fast). +- **Index** — the server loads a ~270 MB prebuilt card index from disk (it doesn't + fetch it itself). The entrypoint downloads `full_index.tar.zst` from + `storage.googleapis.com/taum-reunion-public` into the `altered-uniques-index` volume + on first start only; the loader reads the archive directly (no extraction). Wipe + that volume to re-download. + +Config is driven by env + the app's own `default.toml` (`PORT` override; the index path +comes from `default.toml`) — its per-environment +toml files are optional, so no config file is bind-mounted. + +**Formats.** A "format" (e.g. `frontier`, `living-legend`) is a curated card subset — a +JSON allowlist (`included_refs`) or denylist (`excluded_sets` / `excluded_refs`) of card +references, loaded from a `formats/` dir (`manifest.json` + one file per format) and +compiled against the index. Two dev formats ship in the upstream repo's `formats/`: +**`frontier`** (50 random cards per faction, include) and **`living-legend`** (exclude the +CORE/COREKS sets + 10 random cards per faction). The dev-env enables them via a +bind-mounted [`uniques/local.toml`](uniques/local.toml) (`[formats]` source `/app/formats`) +with **hot-reload** polling (5 s). Query `?format=frontier` / `?format=living-legend`; an +unknown id → `400 unknown format`, a format that fails to load → `500` (check the logs). + +**Hot-reload note:** the poller compares the manifest's per-id `version`, so to pick up an +edit you must bump `version` in BOTH the format file and its `manifest.json` row. + +To re-download the index / rebuild this service from scratch — **without touching any +project DB** — wipe its own two volumes (the downloaded index + the cargo build cache) +and restart: + +```sh +docker volume rm altered-uniques-index altered-uniques-api-target +``` + +### uniques-ui (demo-ui) + +`altered-uniques-ui` runs the repo's `demo-ui/` — a Vite + React SPA for the uniques API +— via the Vite **dev server** (HMR). The dev image lives in the upstream repo at +`demo-ui/docker/dev/Dockerfile` (`npm ci` baked at build time). The AppHost points the +`uniques-ui` resource at it, bind-mounts `demo-ui/` at `/app` for HMR, and keeps a +`node_modules` volume **seeded from the image** (the bind-mount would otherwise shadow the +baked deps) — no runtime install. The volume name is keyed to a hash of `package-lock.json`, +so a dependency change auto-uses a fresh (re-seeded) volume and the AppHost prunes the +stale ones. `npm run dev` serves it. + +The SPA calls the API **straight from the browser** (the API sets `CorsLayer::permissive`), +so `VITE_API_BASE_URL` points at the browser-reachable API (`http://localhost:8005`) — no +Vite proxy, no CORS work. Vite's port is published directly (`-p`, like Keycloak) on the +same internal/external port (8004) so the HMR websocket lines up. **Open it at +http://localhost:8004** — Vite's host allowlist permits `localhost` but not the +`*.local.gd` host (that would need `server.allowedHosts`). Needs the `uniques` service +running to have an API to talk to. The API URL comes from the `VITE_API_BASE_URL` env +var — don't shadow it with a blank value in a `demo-ui/.env` / `.env.local`, which would +route calls through Vite's unused `:8234` proxy instead. + ### dbgate `altered-dbgate` (http://localhost:18182) is a single web DB client for **every** diff --git a/apphost.cs b/apphost.cs index f020761..5bf3029 100644 --- a/apphost.cs +++ b/apphost.cs @@ -47,6 +47,7 @@ ["AlteredOwnership"] = "https://github.com/Altered-Re-Union/AlteredOwnership.git", ["alteredcore-website"] = "https://github.com/Altered-Community/alteredcore-website.git", ["altered-deckbuilder-poc-v2"] = "https://github.com/Altered-Community/altered-deckbuilder-poc-v2.git", + ["uniques-search-api"] = "https://github.com/Altered-Re-Union/uniques-search-api.git", }; // Handles to cross-referenced resources, assigned as each service is mounted, so @@ -55,6 +56,16 @@ IResourceBuilder? decksApp = null; IResourceBuilder? collectionApp = null; +// The DB server resources, captured so DbGate can declare a relationship to each +// (graph edges) — only the enabled ones are non-null. +IResource? decksPgResource = null; +IResource? collectionPgResource = null; +IResource? websiteDbResource = null; + +// The uniques API resource, captured so the demo-ui can declare a graph relationship to +// it (browser-side runtime dependency — edge only, no startup gating). +IResource? uniquesApiResource = null; + // =========================================================================== // auth — Keycloak (local), built from AlteredAuth/build/Dockerfile so it carries // the custom "unique-attribute" provider (pseudo uniqueness) + Altered themes. @@ -525,6 +536,106 @@ }); } +// =========================================================================== +// uniques — uniques-search-api (local; the Altered-Re-Union fork of Taum/rust-cards-api). +// A Rust/Axum in-memory search engine over the Altered "Unique" cards. NOTE: this is +// NOT the prod cards API — it has its own +// contract (/api/v2/cards, /api/v2/card/{ref}, /api/v2/effects) and only covers +// Unique characters, so it does NOT replace ALTERED_CORE_URL/CARDS_API_URL. It's +// the simplest service here: no DB, no Keycloak, no seed, nothing in DbGate. +// +// The repo's root Dockerfile is PROD-only (a multi-stage Cloud Run build that bakes +// the binary), so the repo ships a separate DEV image at docker/dev/Dockerfile that we +// build and bind-mount the source into like decks/collection. Config is driven by +// env + the app's own default.toml (config.rs honours a PORT override; per-env tomls +// are optional, and default.toml already ships the right index path), so no custom +// toml is needed. The +// ~270 MB prebuilt index is downloaded once into a volume by the entrypoint (the +// binary loads it from disk and won't fetch it itself). The server binds +// 0.0.0.0:$PORT, so it's reachable on the Aspire network by its resource name — +// future consumers (website, decks-api) can point at http://altered-uniques-api:8080 +// with no change here. CORS is already CorsLayer::permissive in the app's http.rs, so +// even browser-direct callers (the demo-ui) work out of the box. +// =========================================================================== +if (Enabled("uniques")) +{ + var uniquesRepo = Repo("uniques-search-api"); + + var uniquesApi = builder.AddDockerfile("altered-uniques-api", Path.Combine(uniquesRepo, "docker", "dev")) + .WithBindMount(uniquesRepo, "/app") + // Dev formats wiring: a dev-env-owned local.toml bind-mounted over the checkout's + // config/local.toml — enables [formats] (source = /app/formats = the repo's + // committed formats/ dir) with hot-reload polling. Env alone can't set the reload + // interval, only the toml can. + .WithBindMount(Path.Combine(appHostDir, "uniques", "local.toml"), + "/app/uniques-http-api/config/local.toml", isReadOnly: true) + // target/ (cargo build cache) and build/ (the downloaded index) in + // container-managed volumes — they shadow the host checkout's subpaths, so + // the first build is slow then cached, and the index persists across + // restarts. Same idea as decks/collection's vendor/ volume. + .WithVolume("altered-uniques-api-target", "/app/target") + .WithVolume("altered-uniques-index", "/app/build") + // Host port 8005: 8003 is taken by the ownership service (PR #3), 8004 by uniques-ui. + .WithHttpEndpoint(port: 8005, targetPort: 8080, name: "http") + // PORT is honoured by config.rs (no custom toml needed). The index path is left + // to the app's default.toml (./build/full_index.tar.zst -> /app/build/...), which + // the entrypoint downloads into — setting INDEX_PATH too would just duplicate it + // and log a "prefer index.path in config" warning on every start. + .WithEnvironment("PORT", "8080") + // Dashboard link: a friendly *.local.gd host (resolves to 127.0.0.1 -> the + // Aspire proxy on :8005) hitting a tiny sample query. + .WithUrl("http://uniques.altered.local.gd:8005/api/v2/cards?limit=1", "cards (sample)"); + + uniquesApiResource = uniquesApi.Resource; +} + +// =========================================================================== +// uniques-ui — uniques-search-api/demo-ui (local). A Vite 6 + React 19 SPA demo for +// the uniques API, run via the Vite dev server (HMR). Browser-only: it calls the API +// directly (the API sets CorsLayer::permissive), so VITE_API_BASE_URL points at the +// browser-reachable API URL — no proxy, no CORS work. The repo ships the dev image at +// demo-ui/docker/dev/ (npm ci is baked at build time). demo-ui/ is bind-mounted for +// HMR; the node_modules volume is seeded from the image (the bind-mount would otherwise +// shadow the baked node_modules) — no runtime install. +// +// We publish Vite's port directly with -p (like Keycloak), NOT through the Aspire +// proxy, and run Vite on the same port we publish (8004) so the HMR websocket — which +// the browser opens to the page's own host:port — lines up. Open it at +// http://localhost:8004 (Vite's host allowlist permits localhost; the *.local.gd host +// would need server.allowedHosts). No WaitFor: the SPA needs no backend to start +// serving, and not blocking on the API's long first build keeps the UI available fast. +// =========================================================================== +if (Enabled("uniques-ui")) +{ + var uniquesUiRepo = Repo("uniques-search-api"); + + // node_modules is baked into the image; this volume only un-shadows it under the + // source bind-mount, seeded from the image. Key the volume name to a hash of + // package-lock.json so a dependency change automatically gets a FRESH (re-seeded) + // volume — no manual wipe. Stale volumes from older hashes are pruned (best-effort). + var nmLock = Path.Combine(uniquesUiRepo, "demo-ui", "package-lock.json"); + var nmTag = File.Exists(nmLock) + ? Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(nmLock)))[..8].ToLowerInvariant() + : "default"; + var nmVolume = $"altered-uniques-ui-node-modules-{nmTag}"; + PruneStaleVolumes("altered-uniques-ui-node-modules-", keep: nmVolume); + + var uniquesUi = builder.AddDockerfile("altered-uniques-ui", Path.Combine(uniquesUiRepo, "demo-ui"), "docker/dev/Dockerfile") + .WithBindMount(Path.Combine(uniquesUiRepo, "demo-ui"), "/app") + .WithVolume(nmVolume, "/app/node_modules") + // Publish Vite on 0.0.0.0:8004 directly (bypass the Aspire proxy) so the HMR + // websocket works; internal port == published port so the HMR client lines up. + .WithContainerRuntimeArgs("-p", "0.0.0.0:8004:8004") + // Must be reachable FROM THE BROWSER (not the Aspire alias). Vite reads VITE_- + // prefixed vars from the environment (see demo-ui/README). + .WithEnvironment("VITE_API_BASE_URL", "http://localhost:8005") + .WithUrl("http://localhost:8004/", "demo-ui"); + + // Express the runtime (browser-side) dependency on the API as a graph edge ONLY — + // no startup gating (same mechanism DbGate uses for the DBs). Null if uniques is off. + if (uniquesApiResource is not null) uniquesUi.WithReferenceRelationship(uniquesApiResource); +} + // =========================================================================== // dbgate — one web DB client for ALL project databases (replaces the per-service // phpMyAdmin). It reaches each DB container over the Aspire network by its @@ -605,6 +716,43 @@ // Absolute directory containing this apphost.cs file (compile-time path). static string AppHostDirectory([CallerFilePath] string path = "") => Path.GetDirectoryName(path)!; +// Best-effort cleanup: remove docker volumes whose name starts with `prefix` except +// `keep` — i.e. stale node_modules volumes from previous package-lock hashes. Old-hash +// volumes aren't used by any running container, so removal is safe; any failure (docker +// not ready, or a volume still in use) is ignored so it never blocks startup. +static void PruneStaleVolumes(string prefix, string keep) +{ + try + { + using var list = Process.Start(new ProcessStartInfo("docker", $"volume ls -q --filter name={prefix}") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }); + if (list is null) return; + var names = list.StandardOutput.ReadToEnd(); + list.WaitForExit(); + foreach (var name in names.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (name == keep || !name.StartsWith(prefix)) continue; + try + { + using var rm = Process.Start(new ProcessStartInfo("docker", $"volume rm {name}") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }); + rm?.WaitForExit(); + if (rm?.ExitCode == 0) Console.WriteLine($"[altered] pruned stale node_modules volume {name}"); + } + catch { /* in use or already gone — ignore */ } + } + } + catch { /* docker unavailable — ignore */ } +} + // A presentational-only initial snapshot that keeps a resource OUT of the dashboard // (graph + table) without changing its behaviour — used for the dev DB passwords and // the auto-created database nodes. Aspire updates snapshots with record `with` diff --git a/appsettings.Local.json.example b/appsettings.Local.json.example index fc7dc62..d3f84ec 100644 --- a/appsettings.Local.json.example +++ b/appsettings.Local.json.example @@ -11,6 +11,8 @@ "collection": { "Enabled": true }, "ownership": { "Enabled": false }, "website": { "Enabled": true }, + "uniques": { "Enabled": true }, + "uniques-ui": { "Enabled": true }, "cards": { "Enabled": false } } } diff --git a/appsettings.json b/appsettings.json index 328a3b9..db80522 100644 --- a/appsettings.json +++ b/appsettings.json @@ -5,6 +5,8 @@ "collection": { "Enabled": true }, "ownership": { "Enabled": true }, "website": { "Enabled": true }, + "uniques": { "Enabled": true }, + "uniques-ui": { "Enabled": true }, "cards": { "Enabled": false } } } diff --git a/run.sh b/run.sh old mode 100644 new mode 100755 diff --git a/uniques/local.toml b/uniques/local.toml new file mode 100644 index 0000000..3e66c36 --- /dev/null +++ b/uniques/local.toml @@ -0,0 +1,17 @@ +# Dev formats config for the uniques service, owned by altered-dev-environment and +# bind-mounted (read-only) over the checkout's config/local.toml. It enables the +# `[formats]` filters from the repo's committed formats/ dir, with hot-reload polling. +# +# Why a file (not just env): the FORMATS_PATH env var enables formats but can't set the +# reload interval — only the [formats] toml can. The server loads default.toml then this +# local.toml from CONFIG_DIR (/app/uniques-http-api/config); PORT still comes from env. +# +# Hot-reload note: the poller compares the manifest's per-id `version` numbers, so to +# pick up an edit you must bump `version` in BOTH the format file and its manifest row. + +[formats] +reload_interval_secs = 5 + +[formats.source] +type = "disk" +path = "/app/formats" # = uniques-search-api/formats/ (repo bind-mounted at /app)