This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch feat/template-modes-env-config in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 3a1288a65278bc213d716a2dddbc8b1cd30e5bee Author: Wu Sheng <[email protected]> AuthorDate: Fri Jun 26 10:39:44 2026 +0800 refactor(config): one committed env-driven horizon.yaml (drop the example/copy split) Collapse `horizon.example.yaml` + the gitignored local copy into a single committed `horizon.yaml` — the same fully tokenized file the image ships and the default `pnpm start` reads. Every field is a `${HORIZON_…:default}` token, so all config is supplied via environment variables; nothing is edited in the file. - `git mv horizon.example.yaml horizon.yaml`; un-ignore it in .gitignore + .dockerignore (now a tracked build input, not a local secret-bearing file). - Dockerfile bakes `dist/horizon.yaml` directly as `/app/horizon.yaml` — no more separate `/app/horizon.example.yaml` reference copy, no rename-on-copy. - package.mjs copies the committed `horizon.yaml` into dist/; release.sh + the parity tests (schema/loader) read `horizon.yaml`. - Docs: README, setup/overview, container-image, rbac, local-backend, ports no longer reference an example file; local-backend clarifies the committed file holds only `${HORIZON_…}` tokens (real hashes via HORIZON_AUTH_LOCAL_USERS). - local-boot skill: boots the repo `horizon.yaml` with OAP target + dev users (`dev-users.json`) / LDAP (`dev-ldap.json`) injected via HORIZON_* env vars; the three per-scenario yaml configs are removed. Documents the single-line-JSON constraint for env-injected lists/objects. Validated live: env-native boot (root horizon.yaml + env vars only) against the demo OAP — auth configured, login admin/admin → 200, OAP admin URL injected. type-check / lint / license / 162 BFF + 116 UI tests green. --- .claude/skills/local-boot/SKILL.md | 354 +++++++++++---------------- .claude/skills/local-boot/dev-ldap.json | 1 + .claude/skills/local-boot/dev-users.json | 1 + .claude/skills/local-boot/horizon.demo.yaml | 119 --------- .claude/skills/local-boot/horizon.ldap.yaml | 125 ---------- .claude/skills/local-boot/horizon.local.yaml | 122 --------- .dockerignore | 14 +- .gitignore | 4 +- CHANGELOG.md | 2 +- Dockerfile | 7 +- README.md | 2 +- apps/bff/src/config/loader.test.ts | 6 +- apps/bff/src/config/schema.test.ts | 10 +- docs/access-control/local-backend.md | 4 +- docs/compatibility/ports.md | 2 +- docs/setup/container-image.md | 1 - docs/setup/overview.md | 5 +- docs/setup/rbac.md | 2 +- horizon.example.yaml => horizon.yaml | 0 scripts/package.mjs | 8 +- scripts/release.sh | 2 +- 21 files changed, 184 insertions(+), 607 deletions(-) diff --git a/.claude/skills/local-boot/SKILL.md b/.claude/skills/local-boot/SKILL.md index e623e65..a7993e7 100644 --- a/.claude/skills/local-boot/SKILL.md +++ b/.claude/skills/local-boot/SKILL.md @@ -1,81 +1,71 @@ --- name: local-boot -description: Boot the Horizon UI dev env (BFF + UI) against a local OAP or the public Apache demo OAP, using the static configs bundled with this skill. Handles the apps/bff cwd / HORIZON_CONFIG gotcha and the demo OAP password (kept out of git via ${OAP_PASSWORD}). +description: Boot the Horizon UI dev env (BFF + UI) against a local OAP or the public Apache demo OAP. Uses the repo's committed, env-driven horizon.yaml — the same config the image ships — and injects the OAP target + dev users purely via HORIZON_* environment variables. Handles the apps/bff cwd / HORIZON_CONFIG gotcha and the demo OAP password (kept out of git via the cached oap-password.local). user-invocable: true --- # Boot the Horizon UI local dev env -Two bundled static configs live next to this file: +There is **one config file**: the repo's committed `horizon.yaml` at the +repo root. Every field in it is a `${HORIZON_…:default}` token, so dev boots +use the SAME file the Docker image ships and override only what they need via +**environment variables** — there are no per-scenario config files anymore. -- `horizon.local.yaml` — no-auth OAP. Defaults to `127.0.0.1:12800`, but the OAP URLs are env-overridable (see "Custom OAP target" below) so the same file boots against any no-auth OAP (remote dev cluster, deliberately-unreachable port for the landing-block preview, etc.). -- `horizon.demo.yaml` — public Apache demo OAP, OAP password read from `${OAP_PASSWORD}`. +Two committed helpers live next to this file (throwaway dev values, safe to +commit): -Both define the same throwaway Horizon login users (password == username): -`viewer`, `maintainer`, `operator`, `admin`. +- `dev-users.json` — the four local login users (`viewer` / `maintainer` / + `operator` / `admin`, **password == username**), as a **single-line** JSON + array for `HORIZON_AUTH_LOCAL_USERS`. +- `dev-ldap.json` — the test-OpenLDAP config for `HORIZON_AUTH_LDAP` (single line). -The stack: **BFF** (Fastify) on `:8081`, **UI** (Vite) on `:9091` proxying `/api` → `:8081`. Open **`http://127.0.0.1:9091`** (use the IPv4 literal, not `localhost` — see the proxy/IPv4 section). +The stack: **BFF** (Fastify) on `:8081`, **UI** (Vite) on `:9091` proxying +`/api` → `:8081`. Open **`http://127.0.0.1:9091`** (use the IPv4 literal, not +`localhost` — see the proxy/IPv4 section). Both ports are overridable — see +"Custom ports". -Both ports are overridable — see "Custom ports" below. The defaults are what the examples use throughout this doc. +> **JSON env values must be SINGLE-LINE.** `HORIZON_AUTH_LOCAL_USERS`, +> `HORIZON_OAP_AUTH`, `HORIZON_AUTH_LDAP`, etc. are spliced into the YAML before +> parsing; a multi-line JSON value breaks the parse (`YAMLParseError: Flow +> sequence … must be sufficiently indented`). The `dev-*.json` files are already +> single-line — keep them that way. ## Environment variables (the dev contract) -Local dev runs as **two processes / two ports**: the BFF (Fastify, owns -`/api`) and Vite (serves the UI, proxies `/api` → BFF). They coordinate -through env vars below. The BFF reads them via the YAML loader's -`${VAR:default}` interpolation; Vite reads `BFF_PORT` + `UI_DEV_PORT` -directly in `apps/ui/vite.config.ts`. Set them on **both** processes -when overriding (or the proxy points at the wrong BFF). +Local dev runs as **two processes / two ports**: the BFF (Fastify, owns `/api`) +and Vite (serves the UI, proxies `/api` → BFF). The BFF reads `HORIZON_*` via the +config loader's `${VAR:default}` interpolation; Vite reads `BFF_PORT` + +`UI_DEV_PORT` directly in `apps/ui/vite.config.ts`. -| Variable | Default | Used by | Purpose | +| Variable | Default | Used by | Purpose | |---|---|---|---| -| `HORIZON_CONFIG` | _(none — required)_ | BFF | **Absolute** path to the yaml config. A bare `./horizon.yaml` resolves under `apps/bff/` and silently boots with zero users — see "The one gotcha" below. | -| `BFF_PORT` | `8081` | BFF + UI | BFF listen port (yaml `server.port`) AND Vite's proxy target. Set the same value on both. Prod uses this as the single port (BFF serves the built UI). | -| `UI_DEV_PORT` | `9091` | UI | Vite listen port. Dev-only — meaningless in prod. | -| `OAP_QUERY_URL` | `http://localhost:12800` | BFF | OAP GraphQL endpoint (applies to `horizon.local.yaml`). | -| `OAP_ADMIN_URL` | `http://localhost:12800` | BFF | OAP admin REST endpoint. Often differs from GraphQL on real deployments (e.g. demo splits on `:17128`) — if BFF logs `UITemplate 404`, override this. | -| `OAP_ZIPKIN_URL` | `http://localhost:9412/zipkin` | BFF | Zipkin query endpoint (Zipkin trace layer). | -| `OAP_TIMEOUT_MS` | `15000` | BFF | OAP request timeout. Lower it (`5000`) when previewing the "OAP unreachable" landing block so errors surface fast. | -| `OAP_PASSWORD` | _(none)_ | BFF | Demo OAP basic-auth password (`horizon.demo.yaml` only). Cached at `oap-password.local` (git-ignored). | -| `LDAP_BIND_PW` | `admin` _(test value)_ | BFF | LDAP service-bind password (`horizon.ldap.yaml` only). Set a real value before pointing at a real directory. | - -Minimal local dev (defaults, no overrides): - -```bash -REPO="$(git rev-parse --show-toplevel)" -HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \ - pnpm --filter @skywalking-horizon-ui/bff run dev & # → :8081 -( cd "$REPO/apps/ui" && node_modules/.bin/vite --host 127.0.0.1 & ) # → :9091 -``` - -Custom ports for a parallel env (e.g. coexist with booster-ui on 8080): - -```bash -BFF_PORT=10081 UI_DEV_PORT=10091 \ - HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \ - pnpm --filter @skywalking-horizon-ui/bff run dev & -( cd "$REPO/apps/ui" && BFF_PORT=10081 UI_DEV_PORT=10091 \ - node_modules/.bin/vite --host 127.0.0.1 & ) -``` - -Remote OAP (no auth) via env override: - -```bash -OAP_QUERY_URL=http://oap.dev.example:12800 \ -OAP_ADMIN_URL=http://oap.dev.example:17128 \ -HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \ - pnpm --filter @skywalking-horizon-ui/bff run dev & -``` - -The longer "Custom ports" and "Custom OAP target" sections below cover -the prod single-port model, the GraphQL/admin port split, and the -unreachable-OAP preview. +| `HORIZON_CONFIG` | _(none — required)_ | BFF | **Absolute** path to the config. Always `"$REPO/horizon.yaml"`. A bare `./horizon.yaml` resolves under `apps/bff/` — see "The one gotcha". | +| `HORIZON_SERVER_PORT` | `8081` | BFF | BFF listen port (`server.port`). For a custom port set this AND `BFF_PORT` to the same value. | +| `BFF_PORT` | `8081` | UI | Vite's `/api` proxy target. Must match `HORIZON_SERVER_PORT`. | +| `UI_DEV_PORT` | `9091` | UI | Vite listen port. Dev-only. | +| `HORIZON_OAP_QUERY_URL` | `http://127.0.0.1:12800` | BFF | OAP GraphQL endpoint. | +| `HORIZON_OAP_ADMIN_URL` | `http://127.0.0.1:17128` | BFF | OAP admin REST endpoint. Often a different port from GraphQL (the demo splits on `:17128`) — if the BFF logs `UITemplate 404`, this is wrong. | +| `HORIZON_OAP_ZIPKIN_URL` | `http://127.0.0.1:9412/zipkin` | BFF | Zipkin query endpoint (Zipkin trace layer). | +| `HORIZON_OAP_TIMEOUT_MS` | `15000` | BFF | OAP request timeout. Lower it (`4000`) when previewing the "OAP unreachable" landing block so errors surface fast. | +| `HORIZON_OAP_AUTH` | _(none)_ | BFF | OAP basic-auth as JSON, e.g. `{"username":"admin","password":"…"}`. The demo needs it (password from `oap-password.local`). | +| `HORIZON_AUTH_LOCAL_USERS` | `[]` | BFF | Local login users (JSON array, single-line). Use `$(cat dev-users.json)`. | +| `HORIZON_AUTH_BACKEND` | `local` | BFF | `local` or `ldap`. | +| `HORIZON_AUTH_LDAP` | _(none)_ | BFF | LDAP config (JSON, single-line) when backend=ldap. Use `$(cat dev-ldap.json)`. | +| `HORIZON_TEMPLATES_MODE` | `live` | BFF | `live` (seed + read ui_template) or `readonly` (render the bundle, config read-only). | + +RBAC is left to the built-in role defaults (the `horizon.yaml` token +`roles: ${HORIZON_RBAC_ROLES:null}` falls through to them), so no roles env var +is needed for dev. ## The one gotcha that bites every time -The BFF dev script is `tsx watch src/server.ts` and pnpm runs it with **cwd = `apps/bff`**. The config path is `process.env.HORIZON_CONFIG ?? './horizon.yaml'`, resolved relative to cwd — so a bare `./horizon.yaml` points at the non-existent `apps/bff/horizon.yaml`, and the loader silently falls back to **defaults with zero users** (every login then fails with "invalid credentials", and the boot log warns `auth.local.users is empty`). +The BFF dev script is `tsx watch src/server.ts` and pnpm runs it with **cwd = +`apps/bff`**. The config path defaults to `./horizon.yaml`, resolved relative to +cwd — so a bare `./horizon.yaml` points at the non-existent `apps/bff/horizon.yaml` +(NOT the repo-root one), and the loader silently falls back to schema defaults +with **zero users** (every login then fails "invalid credentials"). -**Always pass `HORIZON_CONFIG` as an ABSOLUTE path.** Use `"$REPO/.claude/skills/local-boot/<file>"`. +**Always pass `HORIZON_CONFIG` as an ABSOLUTE path:** `"$REPO/horizon.yaml"`. ## Proxy + IPv4 (the second gotcha) @@ -91,119 +81,23 @@ env | grep -iE '^(http_proxy|https_proxy|all_proxy)=' && echo "local proxy detec The browser uses its OWN proxy settings (not the shell's), so the developer must also let `127.0.0.1` / `localhost` go direct (ClashX "bypass localhost" / system proxy no-proxy list). The env-level bypass below only fixes CLI tools and Vite's own fetches. -## Custom ports (two parallel envs, prod single-port) - -The defaults are BFF `:8081` and UI `:9091`. To run a second Horizon -side-by-side (e.g. comparing two OAPs, or coexisting with the legacy -booster-ui on 8080) override with two env vars **at boot time**: - -- `BFF_PORT` — the Fastify listen port. Read by the BFF yaml as - `port: ${BFF_PORT:8081}` (interpolated by the loader before parse) AND - by Vite's dev server when building the `/api` proxy target. **Set the - same value on both processes** or the proxy points at the wrong BFF. -- `UI_DEV_PORT` — the Vite listen port. Dev-only, read by - `apps/ui/vite.config.ts`. - -```bash -# parallel env on 10081/10091 (e.g. against a second OAP): -BFF_PORT=10081 UI_DEV_PORT=10091 \ - HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.<file>.yaml" \ - pnpm --filter @skywalking-horizon-ui/bff run dev & -( cd "$REPO/apps/ui" && BFF_PORT=10081 UI_DEV_PORT=10091 \ - env -u http_proxy -u https_proxy -u all_proxy ... \ - node_modules/.bin/vite --host 127.0.0.1 & ) -``` - -The recipes below all use `${BFF_PORT:-8081}` / `${UI_DEV_PORT:-9091}` -shell expansions, so they pick up an override that's already exported -(or just use the defaults if not). - -**Prod is single-port.** The BFF serves the built UI as static files via -`@fastify/static`, so `UI_DEV_PORT` is meaningless outside Vite. In prod -only `server.port` (i.e. `BFF_PORT`) matters. - -## Custom OAP target (point horizon.local.yaml at any no-auth OAP) - -`horizon.local.yaml` is the canonical no-auth config — there are no -separate `horizon.remote.yaml` / `horizon.unreachable.yaml` files -anymore. The four OAP fields all accept env-var overrides via the -loader's `${VAR:default}` syntax, so the same config boots against: - -- **A LOCAL OAP** (default). No env vars needed. -- **A REMOTE OAP** — `OAP_QUERY_URL` + `OAP_ADMIN_URL` + `OAP_ZIPKIN_URL`. -- **A deliberately-unreachable port** — to preview the "OAP query host - unreachable" landing block. Pair with a short `OAP_TIMEOUT_MS` so - errors surface fast. - -Heads-up: many real deployments split the GraphQL surface (12800) and -the admin REST surface (often 17128 — same split as the public demo). -If the BFF startup log warns `UITemplate 404` on `/ui-management/...`, -the admin URL is wrong — override `OAP_ADMIN_URL` separately. - -```bash -# Remote OAP (GraphQL 12800, admin REST split on 17128): -OAP_QUERY_URL=http://oap.dev.example:12800 \ -OAP_ADMIN_URL=http://oap.dev.example:17128 \ -HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \ - pnpm --filter @skywalking-horizon-ui/bff run dev & - -# Unreachable-OAP landing-block preview: -OAP_QUERY_URL=http://127.0.0.1:12801 \ -OAP_ADMIN_URL=http://127.0.0.1:12801 \ -OAP_TIMEOUT_MS=5000 \ -HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \ - pnpm --filter @skywalking-horizon-ui/bff run dev & -``` - ## The stale-process trap (why a config switch "didn't take") -`tsx watch` keeps the old BFF alive, so a freshly launched BFF with a NEW config silently dies on `EADDRINUSE: 127.0.0.1:${BFF_PORT:-8081}` while the OLD process keeps serving the OLD OAP. Symptom: you switch local↔demo, everything looks fine, but the UI still shows the previous OAP's data. +`tsx watch` keeps the old BFF alive, so a freshly launched BFF silently dies on `EADDRINUSE: 127.0.0.1:8081` while the OLD process keeps serving the OLD env. Symptom: you switch demo↔local, everything looks fine, but the UI still shows the previous OAP's data. -Killing matters in two ways: -- `pkill -f "tsx watch src/server.ts"` may miss the actual listener — also `pkill -f "tsx/dist/cli.mjs watch"`, and **verify the port is actually free with `lsof` before relaunching** (loop until free; the watcher can respawn a child). -- After boot, **confirm which OAP the LIVE process is using** — don't trust that your new process won the port. The authoritative check: +- `pkill -f "tsx watch src/server.ts"` may miss the actual listener — also `pkill -f "tsx/dist/cli.mjs watch"`, and **verify the port is free with `lsof` before relaunching** (loop until free; the watcher respawns a child). +- After boot, **confirm which OAP the LIVE process uses** — don't trust that your new process won the port: ```bash - curl -s --noproxy '*' -b /tmp/sw.cookies "http://127.0.0.1:${BFF_PORT:-8081}/api/oap/config" | grep -oE '"adminUrl":"[^"]*"' - # local => http://localhost:12800 ; demo => https://demo.skywalking.apache.org:17128 + curl -s --noproxy '*' -b /tmp/sw.cookies "http://127.0.0.1:8081/api/oap/config" | grep -oE '"adminUrl":"[^"]*"' + # local => http://127.0.0.1:17128 ; demo => https://demo.skywalking.apache.org:17128 ``` - Also grep the boot log for `EADDRINUSE` and the `configPath` line. If the adminUrl is wrong, a stale BFF is still bound — kill it, confirm the port is free, relaunch. - -## Boot against the local OAP - -```bash -REPO="$(git rev-parse --show-toplevel)" -# 1. Kill prior dev servers AND confirm the BFF port is actually free — -# a stale BFF holding it makes the new one die on EADDRINUSE while -# the old config keeps serving (see "stale-process trap"). For a -# parallel env on custom ports, export BFF_PORT / UI_DEV_PORT first. -pkill -f "tsx watch src/server.ts" 2>/dev/null -pkill -f "tsx/dist/cli.mjs watch" 2>/dev/null; pkill -f vite 2>/dev/null -until ! lsof -nP -iTCP:"${BFF_PORT:-8081}" -sTCP:LISTEN >/dev/null 2>&1; do sleep 1; done - -# 2. BFF — absolute config path is mandatory (see gotcha above). The -# yaml resolves ${BFF_PORT:8081} at load time, so just exporting -# BFF_PORT before this command is enough: -HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \ - pnpm --filter @skywalking-horizon-ui/bff run dev & - -# 3. UI — IPv4 host + loopback proxy bypass (run the binary directly so -# --host actually applies). vite.config.ts reads BFF_PORT and -# UI_DEV_PORT from this env: -( cd "$REPO/apps/ui" && \ - env -u http_proxy -u https_proxy -u all_proxy -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \ - no_proxy="localhost,127.0.0.1,::1" NO_PROXY="localhost,127.0.0.1,::1" \ - node_modules/.bin/vite --host 127.0.0.1 & ) -``` - -Then open **`http://127.0.0.1:${UI_DEV_PORT:-9091}`** and log in as `admin` / `admin`. ## Boot against the public demo OAP -The demo OAP needs basic-auth (network username `admin`). The password is -NOT committed — it lives in `oap-password.local` next to this file, which -is git-ignored via the repo-wide `*.local` rule. Source it before booting; -if the file is missing, ask the developer and recreate it (one line, the -password only): +The demo OAP needs basic-auth (network username `admin`). The password is NOT +committed — it lives in `oap-password.local` next to this file (git-ignored via +the repo-wide `*.local` rule). Source it; if missing, ask the developer and +recreate it (one line, the password only). ```bash REPO="$(git rev-parse --show-toplevel)" @@ -215,83 +109,129 @@ else printf '%s\n' "$OAP_PASSWORD" > "$SECRET" && chmod 600 "$SECRET" # cache for next boot fi +# Kill prior dev servers + confirm the port is free (see stale-process trap). pkill -f "tsx watch src/server.ts" 2>/dev/null pkill -f "tsx/dist/cli.mjs watch" 2>/dev/null; pkill -f vite 2>/dev/null -until ! lsof -nP -iTCP:"${BFF_PORT:-8081}" -sTCP:LISTEN >/dev/null 2>&1; do sleep 1; done -HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.demo.yaml" \ +until ! lsof -nP -iTCP:8081 -sTCP:LISTEN >/dev/null 2>&1; do sleep 1; done + +# BFF — repo horizon.yaml + everything via env. JSON values are single-line. +SK="$REPO/.claude/skills/local-boot" +HORIZON_CONFIG="$REPO/horizon.yaml" \ +HORIZON_OAP_QUERY_URL=https://demo.skywalking.apache.org:12800 \ +HORIZON_OAP_ADMIN_URL=https://demo.skywalking.apache.org:17128 \ +HORIZON_OAP_ZIPKIN_URL=https://demo.skywalking.apache.org:9412/zipkin \ +HORIZON_OAP_AUTH="{\"username\":\"admin\",\"password\":\"$OAP_PASSWORD\"}" \ +HORIZON_AUTH_LOCAL_USERS="$(cat "$SK/dev-users.json")" \ pnpm --filter @skywalking-horizon-ui/bff run dev & + +# UI — IPv4 host + loopback proxy bypass (run the binary directly so --host applies). ( cd "$REPO/apps/ui" && \ env -u http_proxy -u https_proxy -u all_proxy -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \ no_proxy="localhost,127.0.0.1,::1" NO_PROXY="localhost,127.0.0.1,::1" \ node_modules/.bin/vite --host 127.0.0.1 & ) ``` -The cached `oap-password.local` is git-ignored, so it is safe to keep on -disk between boots. If it is absent and `OAP_PASSWORD` is unset, ask the -developer rather than guessing. The OAP network username is fixed as -`admin` in `horizon.demo.yaml`. +Then open **`http://127.0.0.1:9091`** and log in as `admin` / `admin`. To boot in +read-only template mode, add `HORIZON_TEMPLATES_MODE=readonly` to the BFF line. + +## Boot against a local / remote no-auth OAP + +Same recipe, different OAP env vars (no `HORIZON_OAP_AUTH` for a no-auth OAP): + +```bash +REPO="$(git rev-parse --show-toplevel)"; SK="$REPO/.claude/skills/local-boot" +pkill -f "tsx watch src/server.ts" 2>/dev/null; pkill -f "tsx/dist/cli.mjs watch" 2>/dev/null; pkill -f vite 2>/dev/null +until ! lsof -nP -iTCP:8081 -sTCP:LISTEN >/dev/null 2>&1; do sleep 1; done + +# LOCAL OAP (defaults already point at 127.0.0.1, so only users are needed): +HORIZON_CONFIG="$REPO/horizon.yaml" \ +HORIZON_AUTH_LOCAL_USERS="$(cat "$SK/dev-users.json")" \ + pnpm --filter @skywalking-horizon-ui/bff run dev & + +# REMOTE no-auth OAP (GraphQL 12800, admin split on 17128): +HORIZON_CONFIG="$REPO/horizon.yaml" \ +HORIZON_OAP_QUERY_URL=http://oap.dev.example:12800 \ +HORIZON_OAP_ADMIN_URL=http://oap.dev.example:17128 \ +HORIZON_AUTH_LOCAL_USERS="$(cat "$SK/dev-users.json")" \ + pnpm --filter @skywalking-horizon-ui/bff run dev & + +# UNREACHABLE-OAP landing-block preview (short timeout so errors surface fast): +HORIZON_CONFIG="$REPO/horizon.yaml" \ +HORIZON_OAP_QUERY_URL=http://127.0.0.1:12801 HORIZON_OAP_ADMIN_URL=http://127.0.0.1:12801 \ +HORIZON_OAP_TIMEOUT_MS=4000 \ +HORIZON_AUTH_LOCAL_USERS="$(cat "$SK/dev-users.json")" \ + pnpm --filter @skywalking-horizon-ui/bff run dev & +``` + +## Custom ports (two parallel envs) + +Defaults are BFF `:8081`, UI `:9091`. To run a second Horizon side-by-side, set +the BFF port via **`HORIZON_SERVER_PORT`** (the config's `server.port` token) AND +**`BFF_PORT`** (Vite's proxy target) to the same value, plus `UI_DEV_PORT`: + +```bash +HORIZON_SERVER_PORT=10081 BFF_PORT=10081 UI_DEV_PORT=10091 \ + HORIZON_CONFIG="$REPO/horizon.yaml" \ + HORIZON_AUTH_LOCAL_USERS="$(cat "$SK/dev-users.json")" \ + pnpm --filter @skywalking-horizon-ui/bff run dev & +( cd "$REPO/apps/ui" && BFF_PORT=10081 UI_DEV_PORT=10091 \ + env -u http_proxy -u https_proxy -u all_proxy \ + node_modules/.bin/vite --host 127.0.0.1 & ) +``` + +**Prod is single-port.** The BFF serves the built UI as static files, so +`UI_DEV_PORT` is meaningless outside Vite; only `HORIZON_SERVER_PORT` matters. ## Boot against an LDAP directory (test) -`horizon.ldap.yaml` points the BFF at a throwaway OpenLDAP seeded from -`ldap-seed.ldif`. Stand the directory up, seed it, then boot: +Stand up a throwaway OpenLDAP, seed it from `ldap-seed.ldif`, then boot with +`HORIZON_AUTH_BACKEND=ldap` and the LDAP config from `dev-ldap.json`. Test logins +mirror the local set (password == username): `admin` → admin, `operator` → +operator, `maintainer` → maintainer, `viewer` → viewer (`*` fallback). The bind +account is `cn=admin,dc=horizon,dc=test` / `admin`. ```bash -REPO="$(git rev-parse --show-toplevel)" +REPO="$(git rev-parse --show-toplevel)"; SK="$REPO/.claude/skills/local-boot" docker rm -f horizon-ldap 2>/dev/null docker run -d --name horizon-ldap -p 389:389 -p 636:636 \ --env LDAP_ORGANISATION="Horizon Test" --env LDAP_DOMAIN="horizon.test" \ --env LDAP_ADMIN_PASSWORD="admin" osixia/openldap:1.5.0 -# wait for slapd, then seed: until docker exec horizon-ldap ldapwhoami -x -H ldap://localhost \ -D "cn=admin,dc=horizon,dc=test" -w admin >/dev/null 2>&1; do sleep 1; done -docker cp "$REPO/.claude/skills/local-boot/ldap-seed.ldif" horizon-ldap:/tmp/seed.ldif +docker cp "$SK/ldap-seed.ldif" horizon-ldap:/tmp/seed.ldif docker exec horizon-ldap ldapadd -x -H ldap://localhost \ -D "cn=admin,dc=horizon,dc=test" -w admin -f /tmp/seed.ldif pkill -f "tsx watch src/server.ts" 2>/dev/null; pkill -f "tsx/dist/cli.mjs watch" 2>/dev/null -until ! lsof -nP -iTCP:"${BFF_PORT:-8081}" -sTCP:LISTEN >/dev/null 2>&1; do sleep 1; done -HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.ldap.yaml" \ +until ! lsof -nP -iTCP:8081 -sTCP:LISTEN >/dev/null 2>&1; do sleep 1; done +HORIZON_CONFIG="$REPO/horizon.yaml" \ +HORIZON_AUTH_BACKEND=ldap \ +HORIZON_AUTH_LDAP="$(cat "$SK/dev-ldap.json")" \ pnpm --filter @skywalking-horizon-ui/bff run dev & ``` -Test accounts (seeded by `ldap-seed.ldif`). Login users are named after -their role and **password == username**, mirroring `horizon.local.yaml`: - -| Login | Group | Role | -|---|---|---| -| `admin` | cn=horizon-admin | admin | -| `operator` | cn=sre | operator | -| `maintainer` | cn=platform | maintainer | -| `viewer` | (none) | viewer (`*` fallback) | - -Directory bind account: `cn=admin,dc=horizon,dc=test` / `admin` (override via `LDAP_BIND_PW`). - -```bash -# verify a login resolves the expected role: -curl -s --noproxy '*' -H 'Content-Type: application/json' -X POST \ - "http://127.0.0.1:${BFF_PORT:-8081}/api/auth/login" -d '{"username":"admin","password":"admin"}' -``` - -Note: group resolution runs on the **service bind**, not the user's -credentials (regular users usually can't read the group subtree). +`dev-ldap.json` reads the bind password as the test value `admin`; for a real +directory, edit it (or build the JSON with your own bind password) and never +commit a real one. Group resolution runs on the **service bind**, not the user's +credentials. ## Verify the BFF is healthy (no browser) ```bash # --noproxy so a local proxy (ClashX etc.) doesn't 502 the loopback call. -until curl -s --noproxy '*' -m2 -o /dev/null "http://127.0.0.1:${BFF_PORT:-8081}/api/auth/health"; do sleep 1; done +until curl -s --noproxy '*' -m2 -o /dev/null "http://127.0.0.1:8081/api/auth/health"; do sleep 1; done curl -s --noproxy '*' -c /tmp/sw.cookies -H 'Content-Type: application/json' -X POST \ - "http://127.0.0.1:${BFF_PORT:-8081}/api/auth/login" -d '{"username":"admin","password":"admin"}' -# Expect 200 with {username, roles, verbs, landingRoute}. A 401 -# "invalid credentials" almost always means the wrong config loaded — -# re-check the absolute HORIZON_CONFIG path and that no stale BFF holds the BFF port. + "http://127.0.0.1:8081/api/auth/login" -d '{"username":"admin","password":"admin"}' +# Expect 200 with {username, roles, verbs, landingRoute}. A 401 "invalid +# credentials" usually means HORIZON_AUTH_LOCAL_USERS wasn't passed (or the +# wrong HORIZON_CONFIG path / a stale BFF holds the port). ``` -## Editing the configs +## Editing the dev values -These files are the source of truth for dev boot. Keep secrets out: -local-user hashes (password == username) are fine to commit; real OAP -passwords must stay in `${OAP_PASSWORD}` (the loader's `interpolateEnv` -expands `${VAR}` / `${VAR:default}` in the raw YAML before parsing). -To mint a new local-user hash: `pnpm --filter @skywalking-horizon-ui/bff cli:hash`. +`horizon.yaml` (repo root) is the committed, env-driven config — leave it as-is +and override via `HORIZON_*` env vars. The dev users / LDAP config live in +`dev-users.json` / `dev-ldap.json` here (throwaway, single-line JSON). Real OAP +or LDAP passwords stay out of git: the demo OAP password in `oap-password.local` +(git-ignored), real bind passwords supplied at boot. To mint a new local-user +hash: `pnpm --filter @skywalking-horizon-ui/bff cli:hash`. diff --git a/.claude/skills/local-boot/dev-ldap.json b/.claude/skills/local-boot/dev-ldap.json new file mode 100644 index 0000000..f52c039 --- /dev/null +++ b/.claude/skills/local-boot/dev-ldap.json @@ -0,0 +1 @@ +{"url":"ldap://localhost:389","bindDn":"cn=admin,dc=horizon,dc=test","bindPassword":"admin","userBaseDn":"ou=people,dc=horizon,dc=test","userFilter":"(uid={username})","displayNameAttr":"cn","groupStrategy":"search","groupBaseDn":"ou=groups,dc=horizon,dc=test","memberAttr":"member","timeoutMs":5000,"tlsInsecure":false,"groupMappings":[{"group":"cn=horizon-admin,ou=groups,dc=horizon,dc=test","role":"admin"},{"group":"cn=sre,ou=groups,dc=horizon,dc=test","role":"operator"},{"group":"cn=pla [...] diff --git a/.claude/skills/local-boot/dev-users.json b/.claude/skills/local-boot/dev-users.json new file mode 100644 index 0000000..a0f7972 --- /dev/null +++ b/.claude/skills/local-boot/dev-users.json @@ -0,0 +1 @@ +[{"username":"viewer","passwordHash":"$argon2id$v=19$m=65536,t=3,p=4$Gp175hqr+EF2iZ7v1fndvw$w6w9hDI59/UA+CRARChDoGRlR1TkVt6kqzApa021K+0","roles":["viewer"]},{"username":"maintainer","passwordHash":"$argon2id$v=19$m=65536,t=3,p=4$w7ULwB3/jzH9FxVoHJ238A$y+qGoX6IPeOoGywLQCpfpAN5VJXcaevoWeJQhaybvQU","roles":["maintainer"]},{"username":"operator","passwordHash":"$argon2id$v=19$m=65536,t=3,p=4$nzoI4RqiobprtzX/mJqe5Q$FY2Hi7mKep0DPHoaE++r/KD++WLUwTgRUFLde87j2Wg","roles":["operator"]},{"username" [...] diff --git a/.claude/skills/local-boot/horizon.demo.yaml b/.claude/skills/local-boot/horizon.demo.yaml deleted file mode 100644 index f68f222..0000000 --- a/.claude/skills/local-boot/horizon.demo.yaml +++ /dev/null @@ -1,119 +0,0 @@ -# Static demo-boot config for the Horizon UI BFF — points at the public -# Apache demo OAP (demo.skywalking.apache.org). The OAP network basic-auth -# password is NOT committed: it is read from ${OAP_PASSWORD} at boot. -# Ask the developer for it before booting and export it, e.g. -# read -rs OAP_PASSWORD && export OAP_PASSWORD -# Boot via the `local-boot` skill (see SKILL.md), which passes this file -# through HORIZON_CONFIG as an ABSOLUTE path. Horizon login users below -# are throwaway dev credentials (password == username). - -server: - host: 127.0.0.1 - port: ${BFF_PORT:8081} - -oap: - queryUrl: https://demo.skywalking.apache.org:12800 - adminUrl: https://demo.skywalking.apache.org:17128 - zipkinUrl: https://demo.skywalking.apache.org:9412/zipkin - timeoutMs: 15000 - auth: - username: admin - # Supplied via the environment so the demo password stays out of git. - # interpolateEnv() in apps/bff/src/config/loader.ts expands ${OAP_PASSWORD}. - password: "${OAP_PASSWORD}" - -auth: - backend: local - local: - users: - - username: viewer - # password: viewer - passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$Gp175hqr+EF2iZ7v1fndvw$w6w9hDI59/UA+CRARChDoGRlR1TkVt6kqzApa021K+0" - roles: [viewer] - - username: maintainer - # password: maintainer - passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$w7ULwB3/jzH9FxVoHJ238A$y+qGoX6IPeOoGywLQCpfpAN5VJXcaevoWeJQhaybvQU" - roles: [maintainer] - - username: operator - # password: operator - passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$nzoI4RqiobprtzX/mJqe5Q$FY2Hi7mKep0DPHoaE++r/KD++WLUwTgRUFLde87j2Wg" - roles: [operator] - - username: admin - # password: admin - passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$joV9AVlyLS3pqq4mLrYokQ$pJLkTKrz9/LzEH6YaFljdz9k8dyBiryjwSB26Diiz9U" - roles: [admin] - -rbac: - enabled: true - roles: - viewer: - - "metrics:read" - - "alarms:read" - - "traces:read" - - "logs:read" - - "browser-errors:read" - - "topology:read" - - "profile:read" - - "overview:read" - maintainer: - - "metrics:read" - - "alarms:read" - - "traces:read" - - "logs:read" - - "browser-errors:read" - - "topology:read" - - "profile:read" - - "overview:read" - - "cluster:read" - - "inspect:read" - - "ttl:read" - - "config:read" - operator: - - "metrics:read" - - "alarms:read" - - "traces:read" - - "logs:read" - - "browser-errors:read" - - "source-map:write" - - "topology:read" - - "profile:read" - - "cluster:read" - - "inspect:read" - - "ttl:read" - - "config:read" - - "overview:read" - - "overview:write" - - "setup:read" - - "setup:write" - - "dashboard:read" - - "dashboard:write" - - "alarm-setup:read" - - "alarm-setup:write" - - "alarm-rule:read" - - "alarm-rule:write" - - "rule:read" - - "rule:write" - - "rule:write:structural" - - "rule:delete" - - "rule:debug" - - "live-debug:read" - - "live-debug:write" - - "profile:enable" - admin: - - "*" - landingByRole: - viewer: / - maintainer: /operate/cluster - operator: / - admin: / - -session: - ttlMinutes: 60 - cookieName: horizon_sid - cookieSecure: false - -audit: { file: ./horizon-audit.jsonl } -setup: { file: ./horizon-setup.json } -alarms: { file: ./horizon-alarms.json } -debugLog: { enabled: false, file: ./horizon-wire.jsonl, maxBodyChars: 8192, redactAuthHeaders: true } -sourceMaps: { enabled: true, maxFileBytes: 67108864, maxTotalBytes: 536870912, maxFileCount: 128, bootMountDir: "" } diff --git a/.claude/skills/local-boot/horizon.ldap.yaml b/.claude/skills/local-boot/horizon.ldap.yaml deleted file mode 100644 index e324a59..0000000 --- a/.claude/skills/local-boot/horizon.ldap.yaml +++ /dev/null @@ -1,125 +0,0 @@ -# LDAP-backend boot config for the Horizon UI BFF. Authenticates against -# an external directory; Horizon stores no passwords. This file targets -# the throwaway test OpenLDAP described in SKILL.md ("Test the LDAP -# backend"): base dc=horizon,dc=test, users under ou=people, groups -# (groupOfNames) under ou=groups, seeded from ldap-seed.ldif. -# -# Test directory accounts (all seeded by ldap-seed.ldif). Login users -# are named after their role and password == username, mirroring -# horizon.local.yaml: -# directory admin (bindDn): cn=admin,dc=horizon,dc=test / admin -# login users (password == username): -# admin -> cn=horizon-admin -> role admin -# operator -> cn=sre -> role operator -# maintainer -> cn=platform -> role maintainer -# viewer -> (no group) -> role viewer (the "*" fallback) -# -# The directory bind password is read from ${LDAP_BIND_PW} (defaults to -# the test value `admin`); for a real directory, export LDAP_BIND_PW and -# never commit it. -# -# OAP still points at a local OAP so the rest of the app works. Boot via -# the local-boot skill with an ABSOLUTE HORIZON_CONFIG path. - -server: - host: 127.0.0.1 - port: ${BFF_PORT:8081} - -oap: - queryUrl: http://localhost:12800 - adminUrl: http://localhost:12800 - zipkinUrl: http://localhost:9412/zipkin - timeoutMs: 15000 - -auth: - backend: ldap - ldap: - url: ldap://localhost:389 - bindDn: "cn=admin,dc=horizon,dc=test" - bindPassword: "${LDAP_BIND_PW:admin}" - userBaseDn: "ou=people,dc=horizon,dc=test" - userFilter: "(uid={username})" - displayNameAttr: cn - # This test directory uses groupOfNames groups without a memberOf - # overlay, so resolve membership by searching ou=groups for the - # user's DN. Switch to `memberOf` against directories that populate it. - groupStrategy: search - groupBaseDn: "ou=groups,dc=horizon,dc=test" - memberAttr: member - timeoutMs: 5000 - tlsInsecure: false - groupMappings: - - { group: "cn=horizon-admin,ou=groups,dc=horizon,dc=test", role: admin } - - { group: "cn=sre,ou=groups,dc=horizon,dc=test", role: operator } - - { group: "cn=platform,ou=groups,dc=horizon,dc=test", role: maintainer } - - { group: "*", role: viewer } - -rbac: - enabled: true - roles: - viewer: - - "metrics:read" - - "alarms:read" - - "traces:read" - - "logs:read" - - "topology:read" - - "profile:read" - - "overview:read" - maintainer: - - "metrics:read" - - "alarms:read" - - "traces:read" - - "logs:read" - - "topology:read" - - "profile:read" - - "overview:read" - - "cluster:read" - - "inspect:read" - - "ttl:read" - - "config:read" - operator: - - "metrics:read" - - "alarms:read" - - "traces:read" - - "logs:read" - - "topology:read" - - "profile:read" - - "cluster:read" - - "inspect:read" - - "ttl:read" - - "config:read" - - "overview:read" - - "overview:write" - - "setup:read" - - "setup:write" - - "dashboard:read" - - "dashboard:write" - - "alarm-setup:read" - - "alarm-setup:write" - - "alarm-rule:read" - - "alarm-rule:write" - - "rule:read" - - "rule:write" - - "rule:write:structural" - - "rule:delete" - - "rule:debug" - - "live-debug:read" - - "live-debug:write" - - "profile:enable" - admin: - - "*" - landingByRole: - viewer: / - maintainer: /operate/cluster - operator: / - admin: / - -session: - ttlMinutes: 60 - cookieName: horizon_sid - cookieSecure: false - -audit: { file: ./horizon-audit.jsonl } -setup: { file: ./horizon-setup.json } -alarms: { file: ./horizon-alarms.json } -debugLog: { enabled: false, file: ./horizon-wire.jsonl, maxBodyChars: 8192, redactAuthHeaders: true } diff --git a/.claude/skills/local-boot/horizon.local.yaml b/.claude/skills/local-boot/horizon.local.yaml deleted file mode 100644 index c21c19b..0000000 --- a/.claude/skills/local-boot/horizon.local.yaml +++ /dev/null @@ -1,122 +0,0 @@ -# Static local-boot config for the Horizon UI BFF. Defaults to a LOCAL -# OAP on 127.0.0.1 with NO network auth, but the four OAP fields below -# accept env-var overrides so the same file boots against a remote OAP -# (any URL), a deliberately-unreachable port (for the "OAP unreachable" -# landing-block preview), or any other no-auth OAP. For demo-OAP access -# use horizon.demo.yaml instead, which keeps the password out of git -# via ${OAP_PASSWORD}. -# -# Boot via the `local-boot` skill (see SKILL.md) which passes this -# file through HORIZON_CONFIG as an ABSOLUTE path. The local-user -# password hashes below are throwaway dev credentials (password == -# username) — safe to commit; do NOT add real secrets here. - -server: - host: 127.0.0.1 - port: ${BFF_PORT:8081} - -oap: - # On the default LOCAL OAP, GraphQL and the admin REST surface share - # the same port (12800). On the public demo and some k8s deployments - # they split (12800 / 17128) — override OAP_ADMIN_URL accordingly. - # Zipkin (9412) may not be exposed locally. - queryUrl: ${OAP_QUERY_URL:http://localhost:12800} - adminUrl: ${OAP_ADMIN_URL:http://localhost:12800} - zipkinUrl: ${OAP_ZIPKIN_URL:http://localhost:9412/zipkin} - timeoutMs: ${OAP_TIMEOUT_MS:15000} - -auth: - backend: local - local: - # One account per role for manual menu-RBAC checks. Password == username. - users: - - username: viewer - # password: viewer - passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$Gp175hqr+EF2iZ7v1fndvw$w6w9hDI59/UA+CRARChDoGRlR1TkVt6kqzApa021K+0" - roles: [viewer] - - username: maintainer - # password: maintainer - passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$w7ULwB3/jzH9FxVoHJ238A$y+qGoX6IPeOoGywLQCpfpAN5VJXcaevoWeJQhaybvQU" - roles: [maintainer] - - username: operator - # password: operator - passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$nzoI4RqiobprtzX/mJqe5Q$FY2Hi7mKep0DPHoaE++r/KD++WLUwTgRUFLde87j2Wg" - roles: [operator] - - username: admin - # password: admin - passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$joV9AVlyLS3pqq4mLrYokQ$pJLkTKrz9/LzEH6YaFljdz9k8dyBiryjwSB26Diiz9U" - roles: [admin] - -rbac: - enabled: true - roles: - viewer: - - "metrics:read" - - "alarms:read" - - "traces:read" - - "logs:read" - - "browser-errors:read" - - "topology:read" - - "profile:read" - - "overview:read" - maintainer: - - "metrics:read" - - "alarms:read" - - "traces:read" - - "logs:read" - - "browser-errors:read" - - "topology:read" - - "profile:read" - - "overview:read" - - "cluster:read" - - "inspect:read" - - "ttl:read" - - "config:read" - operator: - - "metrics:read" - - "alarms:read" - - "traces:read" - - "logs:read" - - "browser-errors:read" - - "source-map:write" - - "topology:read" - - "profile:read" - - "cluster:read" - - "inspect:read" - - "ttl:read" - - "config:read" - - "overview:read" - - "overview:write" - - "setup:read" - - "setup:write" - - "dashboard:read" - - "dashboard:write" - - "alarm-setup:read" - - "alarm-setup:write" - - "alarm-rule:read" - - "alarm-rule:write" - - "rule:read" - - "rule:write" - - "rule:write:structural" - - "rule:delete" - - "rule:debug" - - "live-debug:read" - - "live-debug:write" - - "profile:enable" - admin: - - "*" - landingByRole: - viewer: / - maintainer: /operate/cluster - operator: / - admin: / - -session: - ttlMinutes: 60 - cookieName: horizon_sid - cookieSecure: false - -audit: { file: ./horizon-audit.jsonl } -setup: { file: ./horizon-setup.json } -alarms: { file: ./horizon-alarms.json } -debugLog: { enabled: false, file: ./horizon-wire.jsonl, maxBodyChars: 8192, redactAuthHeaders: true } diff --git a/.dockerignore b/.dockerignore index d3ea2be..4690a97 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,8 +16,8 @@ # Denylist style: the image builds FROM SOURCE (see Dockerfile), so the # context is the source tree. Exclude only what must NOT enter the build — # host-built artifacts (arch-specific node_modules + dist), VCS, CI config, -# local secrets, and scratch dirs. Everything else (source, lockfile, -# horizon.example.yaml) is needed by `pnpm install` + `pnpm package`. +# local secrets, and scratch dirs. Everything else (source, lockfile, the +# committed horizon.yaml) is needed by `pnpm install` + `pnpm package`. **/node_modules **/dist @@ -29,6 +29,10 @@ scripts/.release-work scripts/.finalize-work .claude -# Local runtime config may carry secrets — never ship it. The committed -# horizon.example.yaml stays (package.mjs reads it into dist/). -horizon.yaml +# horizon.yaml is the committed, env-driven config (package.mjs reads it into +# dist/, the Dockerfile bakes it as /app/horizon.yaml) — keep it in the context. +# Runtime state files carry local data; never ship them. +horizon-audit.jsonl +horizon-wire.jsonl +horizon-setup.json +horizon-alarms.json diff --git a/.gitignore b/.gitignore index e06b6d8..c4ae196 100644 --- a/.gitignore +++ b/.gitignore @@ -40,11 +40,11 @@ coverage # OS Thumbs.db -# BFF runtime files -horizon.yaml +# BFF runtime files (horizon.yaml itself is committed — the env-driven config) horizon-audit.jsonl horizon-wire.jsonl horizon-setup.json +horizon-alarms.json # Release scripts/.release-work diff --git a/CHANGELOG.md b/CHANGELOG.md index fedb72b..916148a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The version line is shared by every package in the monorepo (apps + shared packa ### Deployment & configuration - **Run on the bundled templates, read-only — no OAP ui_template API needed.** A new `templates.mode` setting (`HORIZON_TEMPLATES_MODE`) adds a `readonly` mode: Horizon renders every dashboard / overview / alert-page / 3D-map / translation from the **local bundle** and never calls OAP's ui_template admin API. The whole config surface goes **read-only** — the admin pages still open and show the bundled config, but editing and publishing are disabled (and the BFF rejects a write even if it [...] -- **The container image runs with environment variables only — no mounted config file.** The image now bakes a **fully tokenized** `horizon.yaml` where every field is a `${HORIZON_…:default}` env var, so `docker run -e HORIZON_OAP_QUERY_URL=… -e HORIZON_AUTH_LOCAL_USERS='[…]' …` is enough — no `-v` mount, no repackaging. Previously `oap.*`, `auth.*`, users, LDAP, RBAC, and performance tuning were YAML-only. Lists and secrets (users, LDAP, OAP auth) are set as JSON-string env vars; preced [...] +- **The container image runs with environment variables only — no mounted config file.** There is now **one committed, env-driven `horizon.yaml`** (the former `horizon.example.yaml` / local-copy split is gone): every field is a `${HORIZON_…:default}` token, and the image bakes that same file. So `docker run -e HORIZON_OAP_QUERY_URL=… -e HORIZON_AUTH_LOCAL_USERS='[…]' …` is enough — no `-v` mount, no repackaging. Previously `oap.*`, `auth.*`, users, LDAP, RBAC, and performance tuning were [...] - **Cluster Status now reports admin-feature reachability, not just config-presence.** The admin-host pane fires a safe GET at the real REST path each feature calls on OAP — dashboard templates → `/ui-management/templates`, DSL management → `/runtime/rule/list`, live debugger → `/dsl-debugging/status`, Inspect → `/inspect/metrics` — and colors each row by whether that path actually responds. A feature whose module is loaded but whose endpoint 404s (a renamed or forked module, a selector [...] ### General Service — PHP runtime (PHM) diff --git a/Dockerfile b/Dockerfile index 8e39e30..f5c0318 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,12 +58,11 @@ COPY --from=build /src/dist/server.js ./server.js COPY --from=build /src/dist/package.json ./package.json COPY --from=build /src/dist/node_modules ./node_modules COPY --from=build /src/dist/static ./static -COPY --from=build /src/dist/horizon.example.yaml ./horizon.example.yaml -# Bake the (fully tokenized) example AS the active config so the image runs -# with NO mounted file: every field is a `${HORIZON_…:default}` token, so +# The fully tokenized horizon.yaml is the active config — every field is a +# `${HORIZON_…:default}` token, so the image runs with NO mounted file: # `docker run -e HORIZON_OAP_QUERY_URL=… -e HORIZON_AUTH_LOCAL_USERS='[…]' …` # is enough. A bind-mount at /app/horizon.yaml still overrides it. -COPY --from=build /src/dist/horizon.example.yaml ./horizon.yaml +COPY --from=build /src/dist/horizon.yaml ./horizon.yaml COPY --from=build --chown=horizon:horizon /src/dist/bundled_templates ./bundled_templates diff --git a/README.md b/README.md index b25be5d..f24e853 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ See [`docs/setup/container-image.md`](docs/setup/container-image.md) for image t ## Configuration -Horizon UI is configured by a single `horizon.yaml` (hot-reloaded, with `${VAR}` environment-variable interpolation) — see `horizon.example.yaml`. Key sections: +Horizon UI is configured by a single `horizon.yaml` (hot-reloaded, with `${VAR}` environment-variable interpolation) — see `horizon.yaml`. Key sections: - `server` — host / port. - `oap` — `queryUrl`, `adminUrl`, `zipkinUrl`, `timeoutMs`, and optional outbound basic-auth. diff --git a/apps/bff/src/config/loader.test.ts b/apps/bff/src/config/loader.test.ts index aa629f8..bae60ca 100644 --- a/apps/bff/src/config/loader.test.ts +++ b/apps/bff/src/config/loader.test.ts @@ -60,11 +60,11 @@ describe('stripNullish', () => { }); }); -// The env-native contract: the tokenized horizon.example.yaml, interpolated + +// The env-native contract: the tokenized horizon.yaml, interpolated + // stripped + parsed, must accept env overrides for every kind of field. -describe('env-native config (horizon.example.yaml + env)', () => { +describe('env-native config (horizon.yaml + env)', () => { const here = dirname(fileURLToPath(import.meta.url)); - const raw = readFileSync(resolve(here, '../../../../horizon.example.yaml'), 'utf8'); + const raw = readFileSync(resolve(here, '../../../../horizon.yaml'), 'utf8'); const load = (env: NodeJS.ProcessEnv): ReturnType<typeof configSchema.parse> => configSchema.parse(stripNullish(YAML.parse(interpolateEnv(raw, env)) ?? {})); diff --git a/apps/bff/src/config/schema.test.ts b/apps/bff/src/config/schema.test.ts index 74bc26d..ae16cd0 100644 --- a/apps/bff/src/config/schema.test.ts +++ b/apps/bff/src/config/schema.test.ts @@ -29,15 +29,15 @@ describe('configSchema defaults', () => { }); }); -// horizon.example.yaml is the SHIPPED default + the env-var reference: every +// horizon.yaml is the SHIPPED default + the env-var reference: every // field is a `${HORIZON_…:default}` token. Two contracts guarded here: // 1. With NO env set, the tokens' defaults parse to EXACTLY the schema // defaults — so the file is a faithful "this is what you get" reference. // 2. Every top-level config section appears in the example, so a new // section can't be added to the schema without an env-overridable token. -describe('horizon.example.yaml — tokenized default + parity', () => { +describe('horizon.yaml — tokenized default + parity', () => { const here = dirname(fileURLToPath(import.meta.url)); - const examplePath = resolve(here, '../../../../horizon.example.yaml'); + const examplePath = resolve(here, '../../../../horizon.yaml'); const raw = readFileSync(examplePath, 'utf8'); it('parses to exactly the schema defaults (token defaults match the schema)', () => { @@ -63,7 +63,7 @@ describe('horizon.example.yaml — tokenized default + parity', () => { for (const s of sections) { // `infra3d` is the deprecated/ignored passthrough — never tokenized. if (s === 'infra3d') continue; - expect(exampleKeys, `config section "${s}" is missing from horizon.example.yaml`).toContain(s); + expect(exampleKeys, `config section "${s}" is missing from horizon.yaml`).toContain(s); } }); @@ -107,6 +107,6 @@ describe('horizon.example.yaml — tokenized default + parity', () => { uncovered.push(`${path} (leaf not tokenized)`); }; walk(defaults, example, ''); - expect(uncovered, `fields lacking an env token in horizon.example.yaml:\n ${uncovered.join('\n ')}`).toEqual([]); + expect(uncovered, `fields lacking an env token in horizon.yaml:\n ${uncovered.join('\n ')}`).toEqual([]); }); }); diff --git a/docs/access-control/local-backend.md b/docs/access-control/local-backend.md index dc01ce7..6ff3d86 100644 --- a/docs/access-control/local-backend.md +++ b/docs/access-control/local-backend.md @@ -60,10 +60,10 @@ The session captures the role list at authentication time, not on every request. ## File permissions -`horizon.yaml` contains password hashes. Treat it as a secret-bearing file: +The `horizon.yaml` shipped in the repo and image is **env-driven and holds no secrets** — `local.users` defaults to the `HORIZON_AUTH_LOCAL_USERS` variable. Supply your users (with their Argon2id hashes) through that environment variable so hashes never land in a version-controlled file. If you instead write hashes directly into a `horizon.yaml` you deploy, treat that copy as a secret-bearing file: - Filesystem perms `0600` (BFF user only). -- Not in version control. The repo's `.gitignore` excludes `horizon.yaml`; only `horizon.example.yaml` is committed. +- Keep it out of version control — the committed `horizon.yaml` holds only `${HORIZON_…}` tokens, never real hashes. - If you store the file in configuration management (Ansible, Helm secret, etc.), encrypt at rest. ## Mixing with LDAP diff --git a/docs/compatibility/ports.md b/docs/compatibility/ports.md index 6707bb5..0a3d51b 100644 --- a/docs/compatibility/ports.md +++ b/docs/compatibility/ports.md @@ -38,7 +38,7 @@ The OAP defaults. Each module binds its own port: - `:17128` for admin - `:9412` for Zipkin -This is what `horizon.example.yaml` shows. +This is what `horizon.yaml` shows. ### Shared port (Docker / Kubernetes presets) diff --git a/docs/setup/container-image.md b/docs/setup/container-image.md index 75c12de..0e89006 100644 --- a/docs/setup/container-image.md +++ b/docs/setup/container-image.md @@ -28,7 +28,6 @@ The full commit SHA is the canonical, immutable identifier. Moving tags are conv | `/app/server.js` | root | no | Compiled BFF entry point. `CMD` runs `node server.js`. | | `/app/node_modules/` | root | no | Production npm dependencies. | | `/app/static/` | root | no | Built UI assets (Vite `dist/`). | -| `/app/horizon.example.yaml` | root | no | Example config — **read-only reference**, copy from it. | | `/app/horizon.yaml` | root | no | The **active** config — a **baked, fully tokenized default** (every field is a `${HORIZON_…:default}` env token). The image runs with no mounted file; override any field via env (see [Run with env vars only](#run-with-env-vars-only-no-mounted-file)), or bind-mount your own to replace it. | | `/app/bundled_templates/` | **horizon** | **yes** | Layer + overview JSON templates. Owned by `horizon` because the admin **Layer-Templates** and **Overview-Templates** editors write into per-key files here. | | `/data/` | **horizon** | **yes** | Declared `VOLUME`. Default destination for the audit log, setup state, alarm state, and wire debug log. Mount a PVC / named volume / host bind here for durable storage. | diff --git a/docs/setup/overview.md b/docs/setup/overview.md index 143122f..ea218d7 100644 --- a/docs/setup/overview.md +++ b/docs/setup/overview.md @@ -12,15 +12,14 @@ This page is the shortest path from "no Horizon" to "Horizon in front of a runni ### 1. Unpack Horizon -Unpack the binary tarball (substitute the release version you downloaded for `<version>`) and copy the example config: +Unpack the binary tarball (substitute the release version you downloaded for `<version>`): ```sh tar -xzf apache-skywalking-horizon-ui-<version>-bin.tar.gz cd apache-skywalking-horizon-ui-<version>-bin -cp horizon.example.yaml horizon.yaml ``` -The binary is self-contained: `server.js`, `node_modules/`, `static/`, and bundled templates are already present. There is no `pnpm install` step. +The binary is self-contained: `server.js`, `node_modules/`, `static/`, bundled templates, and the config `horizon.yaml` are already present. There is no `pnpm install` step. `horizon.yaml` is **env-driven** — every field is a `${HORIZON_…:default}` variable, so you can leave the file as-is and set only the environment variables you need (starting with your OAP address), or edit the file directly. ### 2. Point Horizon at OAP diff --git a/docs/setup/rbac.md b/docs/setup/rbac.md index 31b195a..346c119 100644 --- a/docs/setup/rbac.md +++ b/docs/setup/rbac.md @@ -24,7 +24,7 @@ rbac: | Field | Type | Default | Required | Notes | |---|---|---|---|---| | `enabled` | boolean | `true` | no | When `false`, every authenticated session is granted `*` (full access). Useful for dev. **Set `true` in production.** | -| `roles` | object | the four built-ins | no | Custom role definitions. Keys are role names; values are arrays of permission strings. **Omitting this block uses the four built-ins** (`viewer`, `maintainer`, `operator`, `admin`) — see `horizon.example.yaml` for the full grants. Defining the block at all overrides the built-ins entirely; redefine all four if you want to keep them. | +| `roles` | object | the four built-ins | no | Custom role definitions. Keys are role names; values are arrays of permission strings. **Omitting this block uses the four built-ins** (`viewer`, `maintainer`, `operator`, `admin`) — see `horizon.yaml` for the full grants. Defining the block at all overrides the built-ins entirely; redefine all four if you want to keep them. | | `landingByRole` | object | see below | no | Post-login redirect route per role. First role on the user wins. | ## Built-in roles (used when `roles` is not set) diff --git a/horizon.example.yaml b/horizon.yaml similarity index 100% rename from horizon.example.yaml rename to horizon.yaml diff --git a/scripts/package.mjs b/scripts/package.mjs index 7355445..b750d47 100644 --- a/scripts/package.mjs +++ b/scripts/package.mjs @@ -30,7 +30,7 @@ * node_modules/ — production deps (npm + workspace dists) * bundled_templates/ — layer + overview JSON templates * static/ — built UI (Vite dist) - * horizon.example.yaml — copy-and-edit starting point + * horizon.yaml — env-driven config (override fields via HORIZON_… env vars) * * The build: * 1. Builds each workspace package under `packages/*` (tsc → dist/). @@ -102,7 +102,7 @@ cpSync( { recursive: true }, ); cpSync(resolve(root, 'apps/ui/dist'), resolve(dist, 'static'), { recursive: true }); -cpSync(resolve(root, 'horizon.example.yaml'), resolve(dist, 'horizon.example.yaml')); +cpSync(resolve(root, 'horizon.yaml'), resolve(dist, 'horizon.yaml')); step('Done'); console.log(` @@ -110,8 +110,8 @@ Target binary: ${resolve(dist, 'server.js')} Boot it: - cp horizon.example.yaml horizon.yaml # configure once - HORIZON_CONFIG=./horizon.yaml \\ + HORIZON_CONFIG=./horizon.yaml \\ # every field is a \${HORIZON_…} env var + HORIZON_OAP_QUERY_URL=http://oap:12800 \\ # override only what you need HORIZON_STATIC_DIR=./dist/static \\ node dist/server.js diff --git a/scripts/release.sh b/scripts/release.sh index 9320107..7b2cf10 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -504,7 +504,7 @@ Guide to build the release from source: * cd into the extracted directory * pnpm install --frozen-lockfile * pnpm package - * node dist/server.js (after copying horizon.example.yaml → horizon.yaml) + * node dist/server.js (after copying horizon.yaml → horizon.yaml) Voting will start now (${VOTE_DATE}) and will remain open for at least 72 hours. PMC members, please cast your vote.
