Spaces:
Configuration error
Configuration error
File size: 7,760 Bytes
9f21d0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 | #!/usr/bin/env node
// Off-Worker downloader for the GP pipeline. Runs the SAME download logic as the
// cron (importing fetchSources directly via node's built-in TS type stripping,
// node >= 24) from THIS machine's IP, then POSTs the raw payloads to the
// Worker's /api/ingest, which runs the normal evaluate/enrich/store pass on them.
//
// Exists because CelesTrak firewalls Cloudflare's shared Worker egress: the
// cron's own fetches come back as HTTP 522 on every source while the same URLs
// answer fine from anywhere else. Only the download moves — the Worker still
// owns the config, the evaluation and KV.
//
// Run it from a host CelesTrak is not blocking (a CI runner, a VPS, a laptop).
// One run pulls ~7 MB of GP data, so keep the cadence at or above the cron's 6 h
// — CelesTrak asks for one download per update.
//
// It also pulls the SATCAT (~6.7 MB) for satellite-table enrichment, but
// conditionally: the Worker's stored ETag comes back from /api/groups.json and
// goes out as If-None-Match, so the usual run costs a 304 with no body. SATCAT
// only updates once or twice a day, well under this cadence.
//
// Usage:
// SATVIS_REFRESH_TOKEN=<token> pnpm --filter satvis-worker push-gp
// SATVIS_INGEST_URL=http://localhost:8080/api/ingest SATVIS_REFRESH_TOKEN=... node scripts/push-gp.mjs
//
// Exits non-zero when the POST fails or when the run wrote no group at all.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fetchSources } from "../src/gp/evaluate.ts";
import { SATCAT_URL } from "../src/gp/satcat.ts";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const workerDir = path.resolve(scriptDir, "..");
const configPath = path.join(workerDir, "src", "config", "satvis.generated.json");
const ingestUrl = process.env.SATVIS_INGEST_URL ?? "https://satvis.space/api/ingest";
const token = process.env.SATVIS_REFRESH_TOKEN;
// Same origin as the ingest endpoint: the pre-flight reads the SATCAT validator
// the Worker stored, so this download can be conditional (see downloadSatcat).
const groupsUrl = new URL("/api/groups.json", ingestUrl).toString();
function fail(message) {
process.stderr.write(`push-gp: ${message}\n`);
process.exit(1);
}
// The ETag of the SATCAT body the Worker currently holds, or undefined when it
// holds none (or is unreachable — in which case we simply download in full).
async function storedSatcatValidator() {
try {
const res = await fetch(groupsUrl);
if (!res.ok) {
return undefined;
}
const index = await res.json();
return index?.satcat?.validator ?? undefined;
} catch {
return undefined;
}
}
// Download the SATCAT conditionally and shape it as an ingest source.
//
// CelesTrak asks for one download per update and SATCAT updates once or twice a
// day, against our 6 h cadence — so the usual outcome here is a 304 with no body,
// which the Worker reads as "keep the stored snapshot". The full body only
// travels when the catalog actually changed.
//
// Unlike a group source this is posted as the raw upstream bytes rather than
// re-serialized records: the Worker re-parses and re-validates them, so a body
// mangled in transit still fails closed there — and it keeps one parser for one
// format instead of a second shape that only exists in the bundle.
async function downloadSatcat() {
const validator = await storedSatcatValidator();
const headers = { "User-Agent": "satvis.space (https://github.com/Flowm/satvis)" };
if (validator !== undefined) {
headers["If-None-Match"] = validator;
}
const started = Date.now();
try {
const res = await fetch(SATCAT_URL, { headers });
const ms = Date.now() - started;
if (res.status === 304) {
process.stdout.write(`push-gp: satcat 304 not modified (${ms}ms) — worker keeps its stored snapshot\n`);
return { key: "satcat", url: SATCAT_URL, status: 304 };
}
if (!res.ok) {
process.stdout.write(`push-gp: satcat HTTP ${res.status} (${ms}ms)\n`);
return { key: "satcat", url: SATCAT_URL, status: res.status, error: `HTTP ${res.status}` };
}
const body = await res.text();
process.stdout.write(`push-gp: satcat HTTP 200 — ${body.length} bytes in ${ms}ms\n`);
return { key: "satcat", url: SATCAT_URL, status: 200, body, validator: res.headers.get("etag") ?? undefined };
} catch (err) {
return { key: "satcat", url: SATCAT_URL, error: err instanceof Error ? err.message : String(err) };
}
}
async function main() {
if (!token) {
fail("SATVIS_REFRESH_TOKEN is not set (same value as the Worker's REFRESH_TOKEN secret)");
}
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
const fetched = await fetchSources(config.groups, (url, init) => fetch(url, init));
const satcat = await downloadSatcat();
// Successful sources travel as their re-serialized record array rather than the
// original bytes: fetchSources already parsed and validated them, and it does not
// keep the raw body. The Worker re-parses and re-validates what it receives, so a
// payload that got mangled in transit still fails closed there.
//
// Failures travel as their message so the Worker records the same lastError it
// would have recorded itself, and that group keeps its last-known-good value.
const bundle = {
fetchedAt: new Date().toISOString(),
sources: [
...fetched.map((source) =>
source.records === undefined
? { key: source.key, url: source.url, status: source.status, error: source.error ?? "fetch failed" }
: { key: source.key, url: source.url, status: source.status, body: JSON.stringify(source.records) },
),
satcat,
],
};
// SATCAT is not counted: a 304 is a success that carries no body, and a SATCAT
// failure must not read as a group failure — it costs enrichment freshness and
// nothing else.
const ok = fetched.filter((source) => source.records !== undefined).length;
const body = JSON.stringify(bundle);
process.stdout.write(`push-gp: POST ${ingestUrl} — ${ok}/${fetched.length} sources downloaded (+satcat), ${body.length} bytes\n`);
let res;
try {
res = await fetch(ingestUrl, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body,
});
} catch (err) {
fail(`POST failed: ${err instanceof Error ? err.message : String(err)}`);
}
const text = await res.text();
if (!res.ok) {
fail(`ingest returned HTTP ${res.status}: ${text.slice(0, 400)}`);
}
const report = JSON.parse(text);
for (const status of report.groups) {
if (status.lastError) {
process.stdout.write(` ${status.name}: FAILED (${status.lastError}) — keeping last-known-good\n`);
} else {
process.stdout.write(` ${status.name}: ${status.count} records\n`);
}
for (const warning of status.warnings ?? []) {
process.stdout.write(` WARNING: ${warning}\n`);
}
}
if (report.satcat) {
const { count, updated, lastError } = report.satcat;
process.stdout.write(lastError ? ` satcat: FAILED (${lastError}) — keeping ${count} stored rows\n` : ` satcat: ${count} rows (fetched ${updated})\n`);
}
process.stdout.write(`push-gp: ingested in ${report.durationMs}ms — ${report.written} groups written, ${report.skipped} skipped/failed\n`);
// A run that wrote nothing accomplished nothing — surface it to CI. Partial
// failures stay green on purpose: those groups keep serving last-known-good,
// and /api/groups.json already carries their lastError for monitoring.
if (report.written === 0) {
fail("no group was written — every source failed");
}
}
await main();
|