openobserve/web/scripts/fetch-datasource-content.mjs

201 lines
7.3 KiB
JavaScript

#!/usr/bin/env node
// Fetch AI data-source UI markdown from the content repo into a generated dir
// that Vite bundles via import.meta.glob. See ~/designs/ai-datasource-cards/.
//
// Behavior:
// - shallow-clones DS_CONTENT_REPO @ DS_CONTENT_REF (with retries + a per-clone
// timeout), then copies <slug>/data-source-ui.md AND the repo's manifest.json
// into src/assets/ai-datasource-content/generated/, and writes .fetch.json
// (our own metadata: resolved SHA + slug list; distinct from manifest.json).
// - skips the fetch when generated/ already exists, unless DS_CONTENT_FORCE=1.
// - DS_CONTENT_STRICT=1 (set for builds / CI): a failed fetch (or missing
// manifest) exits non-zero so the build fails loudly instead of shipping
// stale or missing content. Without strict (dev server) it keeps any cached
// generated/ and otherwise continues — the UI falls back to the basic snippet.
// - Env knobs: DS_CONTENT_REPO / _REF / _SUBDIR / _FORCE / _STRICT / _TIMEOUT_MS.
// - Runnable standalone: `DS_CONTENT_FORCE=1 node scripts/fetch-datasource-content.mjs`.
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdtempSync,
rmSync,
mkdirSync,
cpSync,
readdirSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const WEB_ROOT = resolve(__dirname, "..");
// AI data-source markdown lives under src/assets (data), separate from the
// rendering code under src/components/ingestion/ai/content.
const CONTENT_DIR = resolve(WEB_ROOT, "src/assets/ai-datasource-content");
const GENERATED = join(CONTENT_DIR, "generated");
const REPO =
process.env.DS_CONTENT_REPO ||
"https://github.com/openobserve/o2-datasource.git";
const REF = process.env.DS_CONTENT_REF || "main";
const SUBDIR = process.env.DS_CONTENT_SUBDIR || "datasource-ui-content";
const FORCE = process.env.DS_CONTENT_FORCE === "1";
const STRICT = process.env.DS_CONTENT_STRICT === "1";
// Retry harder for builds/CI (freshness matters); fail fast for the dev server.
const ATTEMPTS = STRICT ? 3 : 1;
// Cap each git clone so an unresponsive network can't stall startup/CI.
const TIMEOUT_MS =
Number(process.env.DS_CONTENT_TIMEOUT_MS) || (STRICT ? 60_000 : 30_000);
const GIT_OPTS = { stdio: "inherit", timeout: TIMEOUT_MS };
// Validate the git inputs before they reach `git`. execFileSync already prevents
// shell injection (no shell is spawned), but an attacker who can set these env
// vars could otherwise pass a value that git reads as a flag (e.g. a REF like
// `--upload-pack=…`). Restrict to an https(s) URL and to ref/path characters
// that can't begin with a dash.
const isSafeRepoUrl = (s) => /^https?:\/\/[\w.@:/\-~%]+$/.test(s);
const isSafeRefOrPath = (s) => /^[\w][\w.\-/]*$/.test(s);
for (const [name, val, ok] of [
["DS_CONTENT_REPO", REPO, isSafeRepoUrl(REPO)],
["DS_CONTENT_REF", REF, isSafeRefOrPath(REF)],
["DS_CONTENT_SUBDIR", SUBDIR, isSafeRefOrPath(SUBDIR)],
]) {
if (!ok) {
console.error(`[ds-content] refusing unsafe ${name}: ${JSON.stringify(val)}`);
process.exit(1);
}
}
const log = (m) => console.log(`[ds-content] ${m}`);
// `.fetch.json` is our own fetch metadata (sha/time); `manifest.json` is the
// placement manifest copied verbatim from the content repo and consumed by the UI.
const hasContent = (dir) => existsSync(join(dir, ".fetch.json"));
function cloneOnce() {
const tmp = mkdtempSync(join(tmpdir(), "ds-content-"));
try {
// fast path: works for a branch or a tag
execFileSync(
"git",
["clone", "--depth", "1", "--branch", REF, REPO, tmp],
GIT_OPTS,
);
} catch {
// REF is probably a commit SHA — full clone then checkout
rmSync(tmp, { recursive: true, force: true });
mkdirSync(tmp, { recursive: true });
execFileSync("git", ["clone", REPO, tmp], GIT_OPTS);
execFileSync("git", ["-C", tmp, "checkout", REF], GIT_OPTS);
}
const sha = execFileSync("git", ["-C", tmp, "rev-parse", "HEAD"])
.toString()
.trim();
return { tmp, sha };
}
function cloneRepo() {
let lastErr;
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
try {
return cloneOnce();
} catch (e) {
lastErr = e;
log(`clone attempt ${attempt}/${ATTEMPTS} failed: ${e.message}`);
}
}
throw lastErr;
}
function writeCards(srcRoot, sha, destDir) {
const root = resolve(srcRoot, SUBDIR);
if (!existsSync(root)) throw new Error(`subdir not found in repo: ${SUBDIR}`);
const slugs = readdirSync(root, { withFileTypes: true })
.filter(
(d) => d.isDirectory() && existsSync(join(root, d.name, "data-source-ui.md")),
)
.map((d) => d.name)
.sort();
if (slugs.length === 0)
throw new Error(`no <slug>/data-source-ui.md found under ${SUBDIR}`);
rmSync(destDir, { recursive: true, force: true });
mkdirSync(destDir, { recursive: true });
// Logo image formats co-located in a slug folder that we bundle alongside the
// card (e.g. logo.svg / logo-dark.png). The UI resolves the frontmatter/manifest
// `logo` filename to these bundled assets.
const LOGO_EXT = /\.(svg|png|webp|jpe?g)$/i;
for (const slug of slugs) {
mkdirSync(join(destDir, slug), { recursive: true });
cpSync(
join(root, slug, "data-source-ui.md"),
join(destDir, slug, "data-source-ui.md"),
);
// Copy any logo image files present in the slug folder.
for (const f of readdirSync(join(root, slug))) {
if (LOGO_EXT.test(f)) {
cpSync(join(root, slug, f), join(destDir, slug, f));
}
}
}
// Placement manifest (tab/category + order) comes from the content repo and
// is copied verbatim for the UI to consume.
const manifestSrc = join(root, "manifest.json");
if (existsSync(manifestSrc)) {
cpSync(manifestSrc, join(destDir, "manifest.json"));
} else if (STRICT) {
throw new Error(`manifest.json not found under ${SUBDIR}`);
} else {
log("WARN no manifest.json in content repo; Popular tab will be empty");
}
// Our own fetch metadata (separate from the placement manifest).
writeFileSync(
join(destDir, ".fetch.json"),
JSON.stringify(
{
repo: REPO,
ref: REF,
sha,
fetchedAt: new Date().toISOString(),
count: slugs.length,
slugs,
},
null,
2,
) + "\n",
);
return slugs;
}
function main() {
// Fast local dev: reuse cache unless explicitly forced.
if (hasContent(GENERATED) && !FORCE) {
log("generated/ present and DS_CONTENT_FORCE!=1 — using cache (skip fetch)");
return;
}
try {
const { tmp, sha } = cloneRepo();
const slugs = writeCards(tmp, sha, GENERATED);
rmSync(tmp, { recursive: true, force: true });
log(`fetched ${slugs.length} cards @ ${sha.slice(0, 7)} from ${REF}`);
} catch (e) {
if (STRICT) {
// Builds / CI: fail loudly rather than ship stale or missing content.
log(`ERROR failed to fetch latest content from ${REPO}@${REF}: ${e.message}`);
process.exit(1);
}
if (hasContent(GENERATED)) {
log(`WARN fetch failed (${e.message}); using existing cached generated/`);
return;
}
log(
`WARN fetch failed (${e.message}) and no cached content; AI cards will show the basic snippet`,
);
}
}
main();