This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 74ac4f1b615446486c9ecc4e5eb25df6b643155c Author: Wu Sheng <[email protected]> AuthorDate: Tue May 12 09:56:58 2026 +0800 bff: fastify skeleton with horizon.yaml config and hot reload --- .gitignore | 5 ++ apps/bff/src/config/loader.ts | 76 ++++++++++++++++++++++++++ apps/bff/src/config/schema.ts | 123 ++++++++++++++++++++++++++++++++++++++++++ apps/bff/src/errors.ts | 42 +++++++++++++++ apps/bff/src/logger.ts | 36 +++++++++++++ apps/bff/src/server.ts | 61 +++++++++++++++++++++ horizon.example.yaml | 58 ++++++++++++++++++++ 7 files changed, 401 insertions(+) diff --git a/.gitignore b/.gitignore index dfaacad..8173b86 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,8 @@ Thumbs.db # Local design / planning docs — never committed docs/design/ + +# BFF runtime files +horizon.yaml +horizon-audit.jsonl +horizon-wire.jsonl diff --git a/apps/bff/src/config/loader.ts b/apps/bff/src/config/loader.ts new file mode 100644 index 0000000..02e069a --- /dev/null +++ b/apps/bff/src/config/loader.ts @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import chokidar from 'chokidar'; +import YAML from 'yaml'; +import { configSchema, type HorizonConfig } from './schema.js'; + +export interface ConfigSource { + readonly current: HorizonConfig; + readonly path: string; + onChange(fn: (cfg: HorizonConfig) => void): () => void; + close(): Promise<void>; +} + +function parseFile(absPath: string): HorizonConfig { + let raw = ''; + try { + raw = readFileSync(absPath, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + // No config file → use full defaults. + return configSchema.parse({}); + } + throw err; + } + const parsed = YAML.parse(raw) ?? {}; + return configSchema.parse(parsed); +} + +export function loadConfig(configPath: string): ConfigSource { + const absPath = resolve(configPath); + let current = parseFile(absPath); + const listeners = new Set<(cfg: HorizonConfig) => void>(); + + const watcher = chokidar.watch(absPath, { ignoreInitial: true, awaitWriteFinish: true }); + watcher.on('change', () => { + try { + const next = parseFile(absPath); + current = next; + for (const fn of listeners) fn(next); + } catch { + // Swallow; the server logs the parse error elsewhere when it tries to + // use the new config. We don't want a malformed reload to kill the watcher. + } + }); + + return { + get current() { + return current; + }, + path: absPath, + onChange(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + async close() { + await watcher.close(); + }, + }; +} diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts new file mode 100644 index 0000000..36e7f53 --- /dev/null +++ b/apps/bff/src/config/schema.ts @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; + +const serverSchema = z + .object({ + host: z.string().default('127.0.0.1'), + port: z.number().int().positive().default(8081), + staticDir: z.string().optional(), + }) + .strict(); + +const oapSchema = z + .object({ + // The OAP admin host. Default 17128 per the upstream Armeria binding. + adminUrls: z.array(z.string().url()).default(['http://127.0.0.1:17128']), + // The OAP query/status host (GraphQL + /status/*). + statusUrl: z.string().url().default('http://127.0.0.1:12800'), + timeoutMs: z.number().int().positive().default(15000), + mqe: z + .object({ + host: z.string().optional(), + port: z.number().int().positive().optional(), + }) + .strict() + .default({}), + }) + .strict(); + +const localUserSchema = z + .object({ + username: z.string().min(1), + passwordHash: z.string().min(1), + roles: z.array(z.string().min(1)).default([]), + }) + .strict(); + +const authSchema = z + .object({ + backend: z.literal('local').default('local'), + local: z + .object({ + users: z.array(localUserSchema).default([]), + }) + .strict() + .default({ users: [] }), + }) + .strict() + .default({ backend: 'local', local: { users: [] } }); + +const rbacSchema = z + .object({ + enabled: z.boolean().default(false), + roles: z + .record(z.string(), z.array(z.string().min(1))) + .default({ + viewer: ['*:read'], + editor: ['*:read', 'rule:write', 'rule:debug', 'inspect:read'], + admin: ['*'], + }), + }) + .strict() + .default({ enabled: false, roles: {} }); + +const sessionSchema = z + .object({ + ttlMinutes: z.number().int().positive().default(60), + cookieName: z.string().default('horizon_sid'), + cookieSecure: z.boolean().default(false), + }) + .strict() + .default({ ttlMinutes: 60, cookieName: 'horizon_sid', cookieSecure: false }); + +const auditSchema = z + .object({ + file: z.string().default('./horizon-audit.jsonl'), + }) + .strict() + .default({ file: './horizon-audit.jsonl' }); + +const debugLogSchema = z + .object({ + enabled: z.boolean().default(false), + file: z.string().default('./horizon-wire.jsonl'), + maxBodyChars: z.number().int().nonnegative().default(8192), + redactAuthHeaders: z.boolean().default(true), + }) + .strict() + .default({ + enabled: false, + file: './horizon-wire.jsonl', + maxBodyChars: 8192, + redactAuthHeaders: true, + }); + +export const configSchema = z + .object({ + server: serverSchema.default({}), + oap: oapSchema.default({}), + auth: authSchema, + rbac: rbacSchema, + session: sessionSchema, + audit: auditSchema, + debugLog: debugLogSchema, + }) + .strict(); + +export type HorizonConfig = z.infer<typeof configSchema>; diff --git a/apps/bff/src/errors.ts b/apps/bff/src/errors.ts new file mode 100644 index 0000000..adcbe9f --- /dev/null +++ b/apps/bff/src/errors.ts @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Errors thrown by BFF routes that should map to non-500 HTTP responses. + * The Fastify error handler reads `statusCode` and `code`. + */ +export class HttpError extends Error { + readonly statusCode: number; + readonly code: string; + readonly details?: unknown; + + constructor(statusCode: number, code: string, message: string, details?: unknown) { + super(message); + this.name = 'HttpError'; + this.statusCode = statusCode; + this.code = code; + this.details = details; + } +} + +export const unauthorized = (msg = 'unauthorized') => new HttpError(401, 'unauthorized', msg); +export const forbidden = (msg = 'forbidden') => new HttpError(403, 'forbidden', msg); +export const notFound = (msg = 'not found') => new HttpError(404, 'not_found', msg); +export const badRequest = (msg = 'bad request', details?: unknown) => + new HttpError(400, 'bad_request', msg, details); +export const upstreamFailure = (msg = 'upstream failure', details?: unknown) => + new HttpError(502, 'upstream_failure', msg, details); diff --git a/apps/bff/src/logger.ts b/apps/bff/src/logger.ts new file mode 100644 index 0000000..702478e --- /dev/null +++ b/apps/bff/src/logger.ts @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import pino from 'pino'; + +const isDev = process.env.NODE_ENV !== 'production'; + +export const logger = pino({ + level: process.env.LOG_LEVEL ?? (isDev ? 'debug' : 'info'), + ...(isDev + ? { + transport: { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'HH:MM:ss.l', + ignore: 'pid,hostname', + }, + }, + } + : {}), +}); diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts new file mode 100644 index 0000000..7706acb --- /dev/null +++ b/apps/bff/src/server.ts @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Fastify from 'fastify'; +import { loadConfig, type ConfigSource } from './config/loader.js'; +import { HttpError } from './errors.js'; +import { logger } from './logger.js'; + +const configPath = process.env.HORIZON_CONFIG ?? './horizon.yaml'; + +const source: ConfigSource = loadConfig(configPath); +logger.info({ configPath: source.path }, 'config loaded'); +source.onChange((cfg) => logger.info({ users: cfg.auth.local.users.length }, 'config reloaded')); + +const app = Fastify({ loggerInstance: logger }); + +app.setErrorHandler((err, _req, reply) => { + if (err instanceof HttpError) { + return reply.status(err.statusCode).send({ code: err.code, message: err.message, details: err.details }); + } + const message = err instanceof Error ? err.message : 'internal error'; + reply.log.error({ err }, 'unhandled'); + return reply.status(500).send({ code: 'internal_error', message }); +}); + +app.get('/api/health', async () => ({ + status: 'ok', + version: process.env.HORIZON_VERSION ?? '0.1.0', +})); + +const { host, port } = source.current.server; +app.listen({ host, port }).then( + () => logger.info(`BFF listening on http://${host}:${port}`), + (err) => { + logger.fatal({ err }, 'failed to start BFF'); + process.exit(1); + }, +); + +async function shutdown(signal: string) { + logger.info({ signal }, 'shutting down'); + await app.close(); + await source.close(); + process.exit(0); +} +process.on('SIGINT', () => void shutdown('SIGINT')); +process.on('SIGTERM', () => void shutdown('SIGTERM')); diff --git a/horizon.example.yaml b/horizon.example.yaml new file mode 100644 index 0000000..edb3cb8 --- /dev/null +++ b/horizon.example.yaml @@ -0,0 +1,58 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Copy this file to `horizon.yaml` and edit. Hot-reload picks up changes. + +server: + host: 127.0.0.1 + port: 8081 + +oap: + # OAP admin host (port 17128 by default; runtime-rule / dsl-debugging / inspect / status live here). + adminUrls: + - http://127.0.0.1:17128 + # OAP query/status host (port 12800 by default; GraphQL + /status/*). + statusUrl: http://127.0.0.1:12800 + timeoutMs: 15000 + +auth: + backend: local + local: + users: [] + # Example user (generate the argon2 hash with `pnpm --filter bff cli:hash`): + # - username: admin + # passwordHash: "$argon2id$v=19$..." + # roles: [admin] + +rbac: + enabled: false + roles: + viewer: ["*:read"] + editor: ["*:read", "rule:write", "rule:debug", "inspect:read"] + admin: ["*"] + +session: + ttlMinutes: 60 + cookieName: horizon_sid + cookieSecure: false + +audit: + file: ./horizon-audit.jsonl + +debugLog: + enabled: false + file: ./horizon-wire.jsonl + maxBodyChars: 8192 + redactAuthHeaders: true
