UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

239 lines (221 loc) 10.1 kB
/** * Lake configuration — **entirely env-driven, zero hardcoding**. No endpoint, bucket, or * prefix is ever baked into source; all of it arrives from the environment (or an explicit * override object) so the accessor is publication-safe and portable across any * S3-compatible object store (e.g. Cloudflare R2, GCS, MinIO). * * **Object storage is the ONLY source of truth.** Local disk is either an engine-managed * cache (`cacheDir`) or an explicit dev/fixture root (`devLocalRoot`) — never a production * data source. * * ## Resolution precedence (first non-empty wins) * 1. explicit `LakeConfigInput` passed to {@link resolveLakeConfig} / `lake(config)` * 2. process environment * 3. an rclone remote (only for endpoint + keys), when `LAKE_RCLONE_REMOTE` names one * * ## Environment * - `LAKE_S3_ENDPOINT` — full scheme+host of the object store, e.g. `https://<host>` (canonical source) * - `LAKE_S3_BUCKET` — bucket name * - `LAKE_S3_PREFIX` — key prefix under the bucket (may be empty) * - `LAKE_ACCESS_KEY_ID` — access key (falls back to `AWS_ACCESS_KEY_ID`) * - `LAKE_SECRET_ACCESS_KEY` — secret key (falls back to `AWS_SECRET_ACCESS_KEY`) * - `LAKE_RCLONE_REMOTE` — name of a remote in the rclone config to source endpoint+keys from * - `RCLONE_CONFIG` — override path to the rclone config (defaults to `~/.config/rclone/rclone.conf`) * - `LAKE_CACHE_DIR` — on-disk engine cache dir (default under the OS cache dir) * - `LAKE_CACHE_MAX_BYTES` — cache size cap, LRU within it (default 25 GiB; `0` disables caching) * - `LAKE_DEV_LOCAL_ROOT` — **explicit dev/fixture** root; when set, reads resolve local-first * (creds-free tests/CI) and are labelled `source: "dev-local"`. * Never consulted when unset. * * ## Secret handling * Keys resolved here are held in memory only. They are never logged and never written to * disk. When an S3 table function must be built, the secret is interpolated into the * in-memory SQL handed to the engine — never into anything persisted or emitted. */ import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { LakeConfigError } from "./errors.ts"; /** Default cache cap: 25 GiB. */ export const DEFAULT_CACHE_MAX_BYTES = 25 * 1024 ** 3; /** Fully-resolved S3 remote — present only when every field is available. */ export interface LakeS3Config { /** Full scheme+host, no trailing slash (e.g. `https://<host>`). */ endpoint: string; bucket: string; /** Key prefix under the bucket, no leading/trailing slash (may be empty). */ prefix: string; accessKeyId: string; secretAccessKey: string; } export interface LakeConfig { /** Resolved S3 remote (the canonical source), or `undefined` when not fully described. */ s3: LakeS3Config | undefined; /** On-disk engine cache dir (always set; reads materialize segments here). */ cacheDir: string; /** Cache size cap in bytes; `0` disables caching entirely. */ cacheMaxBytes: number; /** * **Explicit** dev/fixture root, absolute, or `undefined`. When set, reads resolve * local-first from here (labelled `source: "dev-local"`); when unset it is never consulted. * This is NOT a production data source — object storage is the only source of truth. */ devLocalRoot: string | undefined; } /** Caller override: any subset of the config; provided fields win over the environment. */ export interface LakeConfigInput { s3?: Partial<LakeS3Config>; /** Explicit dev/fixture root (else `LAKE_DEV_LOCAL_ROOT`). */ devLocalRoot?: string; /** Engine cache dir (else `LAKE_CACHE_DIR`, else a default under the OS cache dir). */ cacheDir?: string; /** Cache cap in bytes (else `LAKE_CACHE_MAX_BYTES`, else 25 GiB; `0` disables). */ cacheMaxBytes?: number; /** Override the rclone config path (else `RCLONE_CONFIG` env, else `~/.config/rclone/rclone.conf`). */ rcloneConfigPath?: string; /** Override the rclone remote name (else `LAKE_RCLONE_REMOTE` env). */ rcloneRemote?: string; /** Environment to read from; defaults to `process.env` (injected for tests). */ env?: Record<string, string | undefined>; } /** Default engine-cache dir under the OS cache location (XDG on Linux, Caches on macOS). */ export function defaultCacheDir(env: Record<string, string | undefined>): string { const base = process.platform === "darwin" ? join(homedir(), "Library", "Caches") : (nonEmpty(env.XDG_CACHE_HOME) ?? join(homedir(), ".cache")); return join(base, "kestrel", "lake"); } const nonEmpty = (v: string | undefined): string | undefined => { if (v === undefined) return undefined; const t = v.trim(); return t.length > 0 ? t : undefined; }; const stripTrailingSlash = (s: string): string => s.replace(/\/+$/, ""); const stripSlashes = (s: string): string => s.replace(/^\/+/, "").replace(/\/+$/, ""); /** * Parse an rclone config (INI) and return one remote's key/value map, or `undefined` if the * named remote is absent. Comments (`#`/`;`) and blank lines are ignored. This is the only * place a secret is read off disk; the caller keeps it in memory and never re-serializes it. */ export function parseRcloneRemote( configText: string, remote: string, ): Record<string, string> | undefined { let current: string | undefined; let found: Record<string, string> | undefined; for (const raw of configText.split(/\r?\n/)) { const line = raw.trim(); if (line.length === 0 || line.startsWith("#") || line.startsWith(";")) continue; const section = /^\[(.+)\]$/.exec(line); if (section) { current = section[1]?.trim(); if (current === remote) found = {}; continue; } if (current !== remote || found === undefined) continue; const eq = line.indexOf("="); if (eq < 0) continue; const key = line.slice(0, eq).trim(); const value = line.slice(eq + 1).trim(); if (key.length > 0) found[key] = value; } return found; } /** endpoint+keys sourced from an rclone remote; bucket/prefix still come from env/override. */ function rcloneS3( input: LakeConfigInput, env: Record<string, string | undefined>, ): Partial<LakeS3Config> { const fromCaller = nonEmpty(input.rcloneRemote); const fromEnv = nonEmpty(env.LAKE_RCLONE_REMOTE); const remote = fromCaller ?? fromEnv; if (remote === undefined) return {}; // Name the remote the way it was actually chosen. A caller-supplied remote is usually a DEFAULT // (`CORPUS_DEFAULTS.rcloneRemote` is a bare `"r2"`), and reporting that as `LAKE_RCLONE_REMOTE=r2 // set` sends the reader hunting for an env var nothing ever set. const named = fromEnv !== undefined ? `LAKE_RCLONE_REMOTE=${remote}` : `rclone remote '${remote}' (a caller default — nothing set LAKE_RCLONE_REMOTE)`; const path = nonEmpty(input.rcloneConfigPath) ?? nonEmpty(env.RCLONE_CONFIG) ?? join(homedir(), ".config", "rclone", "rclone.conf"); let text: string; try { text = readFileSync(path, "utf8"); } catch (cause) { throw new LakeConfigError(`${named}, but the rclone config is unreadable at ${path}`, { cause }); } const r = parseRcloneRemote(text, remote); if (r === undefined) { throw new LakeConfigError(`${named}, but that remote is not in ${path}`); } const out: Partial<LakeS3Config> = {}; const endpoint = nonEmpty(r.endpoint); const accessKeyId = nonEmpty(r.access_key_id); const secretAccessKey = nonEmpty(r.secret_access_key); if (endpoint !== undefined) out.endpoint = stripTrailingSlash(endpoint); if (accessKeyId !== undefined) out.accessKeyId = accessKeyId; if (secretAccessKey !== undefined) out.secretAccessKey = secretAccessKey; return out; } /** Parse a byte-count from env; non-numeric/absent → fallback. `0`/negative → 0 (disabled). */ function parseBytes(raw: string | undefined, fallback: number): number { const s = nonEmpty(raw); if (s === undefined) return fallback; const n = Number(s); if (!Number.isFinite(n)) return fallback; return n <= 0 ? 0 : Math.floor(n); } /** * Resolve the effective {@link LakeConfig}. Never throws for a *missing* S3 remote (a * legitimate dev-only setup); it throws only when an rclone remote is explicitly named but * unreadable. An incomplete S3 remote resolves to `s3: undefined` — reads that need it then * fail loudly at read time with the field that was missing. */ export function resolveLakeConfig(input: LakeConfigInput = {}): LakeConfig { const env = input.env ?? process.env; const rc = rcloneS3(input, env); const ov = input.s3 ?? {}; const devLocalRoot = nonEmpty(input.devLocalRoot) ?? nonEmpty(env.LAKE_DEV_LOCAL_ROOT); const cacheDir = nonEmpty(input.cacheDir) ?? nonEmpty(env.LAKE_CACHE_DIR) ?? defaultCacheDir(env); const cacheMaxBytes = input.cacheMaxBytes !== undefined ? input.cacheMaxBytes <= 0 ? 0 : Math.floor(input.cacheMaxBytes) : parseBytes(env.LAKE_CACHE_MAX_BYTES, DEFAULT_CACHE_MAX_BYTES); const endpoint = nonEmpty(ov.endpoint) ?? nonEmpty(env.LAKE_S3_ENDPOINT) ?? nonEmpty(rc.endpoint); const bucket = nonEmpty(ov.bucket) ?? nonEmpty(env.LAKE_S3_BUCKET); const prefixRaw = ov.prefix ?? env.LAKE_S3_PREFIX ?? ""; const accessKeyId = nonEmpty(ov.accessKeyId) ?? nonEmpty(env.LAKE_ACCESS_KEY_ID) ?? nonEmpty(env.AWS_ACCESS_KEY_ID) ?? nonEmpty(rc.accessKeyId); const secretAccessKey = nonEmpty(ov.secretAccessKey) ?? nonEmpty(env.LAKE_SECRET_ACCESS_KEY) ?? nonEmpty(env.AWS_SECRET_ACCESS_KEY) ?? nonEmpty(rc.secretAccessKey); let s3: LakeS3Config | undefined; if ( endpoint !== undefined && bucket !== undefined && accessKeyId !== undefined && secretAccessKey !== undefined ) { s3 = { endpoint: stripTrailingSlash(endpoint), bucket, prefix: stripSlashes(prefixRaw), accessKeyId, secretAccessKey, }; } return { s3, cacheDir, cacheMaxBytes, devLocalRoot }; }