Skip to content
Open
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ obj/

# Aspire / tooling
*.user

# JetBrains IDE
.idea/
75 changes: 75 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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**
Expand Down
148 changes: 148 additions & 0 deletions apphost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -55,6 +56,16 @@
IResourceBuilder<ContainerResource>? decksApp = null;
IResourceBuilder<ContainerResource>? 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions appsettings.Local.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"collection": { "Enabled": true },
"ownership": { "Enabled": false },
"website": { "Enabled": true },
"uniques": { "Enabled": true },
"uniques-ui": { "Enabled": true },
"cards": { "Enabled": false }
}
}
2 changes: 2 additions & 0 deletions appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
"collection": { "Enabled": true },
"ownership": { "Enabled": true },
"website": { "Enabled": true },
"uniques": { "Enabled": true },
"uniques-ui": { "Enabled": true },
"cards": { "Enabled": false }
}
}
Empty file modified run.sh
100644 → 100755
Empty file.
17 changes: 17 additions & 0 deletions uniques/local.toml
Original file line number Diff line number Diff line change
@@ -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)