feat(worker): EOC push-agent for ops summary
- Add EOC_URL + EOC_PUSH_TOKEN config - Add pushOpsSummaryToEoc processor - Schedule PUSH_OPS_SUMMARY_TO_EOC every 2 minutes - Add unit tests
This commit is contained in:
@@ -55,6 +55,11 @@ const configSchema = z.object({
|
||||
/** Strong, rotated ops token for /ops/v1/summary. Set via SSM in production. */
|
||||
OPS_TOKEN: z.string().min(1).default("dev-ops-token-change-me"),
|
||||
|
||||
/** EOC base URL for outbound ops-summary push. Empty = push disabled. */
|
||||
EOC_URL: z.string().url().optional(),
|
||||
/** EOC ingest bearer token for this app. Set via SSM in production. */
|
||||
EOC_PUSH_TOKEN: z.string().min(1).optional(),
|
||||
|
||||
APP_STORE_MODE: z.enum(["production", "sandbox"]).default("sandbox"),
|
||||
EMAIL_MODE: z.enum(["log", "smtp"]).default("log"),
|
||||
SMTP_HOST: z.string().default(""),
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "./processors/maintenance.js";
|
||||
import { processSafetyCanary } from "./processors/safety-canary.js";
|
||||
import { refreshOpsSummaryCache } from "./processors/ops-summary.js";
|
||||
import { pushOpsSummaryToEoc } from "./processors/eoc-push.js";
|
||||
|
||||
import {
|
||||
DEAD_LETTER_QUEUE_NAME,
|
||||
@@ -145,6 +146,12 @@ const worker = new Worker(
|
||||
return;
|
||||
}
|
||||
|
||||
case "PUSH_OPS_SUMMARY_TO_EOC": {
|
||||
const result = await pushOpsSummaryToEoc(ctx.redis!);
|
||||
log(`EOC push: ${result.ok ? "OK" : "FEL"}${result.error ? ` (${result.error})` : ""}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Deterministiska/planerade jobb som inte kräver egen processor ännu
|
||||
case "NORMALIZE_PRODUCTS":
|
||||
case "DEDUPLICATE_INVENTORY":
|
||||
@@ -293,6 +300,15 @@ async function registerRepeatableJobs() {
|
||||
opts: baseOpts,
|
||||
},
|
||||
);
|
||||
await queue.upsertJobScheduler(
|
||||
"scheduler-eoc-push",
|
||||
{ every: 2 * 60 * 1000 }, // var 2:e minut
|
||||
{
|
||||
name: "PUSH_OPS_SUMMARY_TO_EOC",
|
||||
data: { jobType: "PUSH_OPS_SUMMARY_TO_EOC" },
|
||||
opts: baseOpts,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// --- Minimal healthcheck-server så att flera instanser kan övervakas ---
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Redis } from "ioredis";
|
||||
import { CACHE_KEY } from "./ops-summary.js";
|
||||
|
||||
export interface EocPushResult {
|
||||
ok: boolean;
|
||||
status: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const EOC_PUSH_TIMEOUT_MS = 15_000;
|
||||
|
||||
function ingestUrl(base: string): string {
|
||||
const trimmed = base.trim().replace(/\/+$/, "");
|
||||
return `${trimmed}/api/v1/apps/cibello/ingest`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the cached ops summary to EOC. Never throws; failures are returned
|
||||
* as `ok: false` and must be logged by the caller.
|
||||
*/
|
||||
export async function pushOpsSummaryToEoc(redis: Redis): Promise<EocPushResult> {
|
||||
const eocUrl = process.env.EOC_URL?.trim();
|
||||
const token = process.env.EOC_PUSH_TOKEN?.trim();
|
||||
|
||||
if (!eocUrl || !token) {
|
||||
return { ok: false, status: null, error: "EOC_URL or EOC_PUSH_TOKEN not configured" };
|
||||
}
|
||||
|
||||
const json = await redis.get(CACHE_KEY);
|
||||
if (!json) {
|
||||
return { ok: false, status: null, error: "ops summary cache miss" };
|
||||
}
|
||||
|
||||
// Guard against accidentally sending non-object payloads.
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return { ok: false, status: null, error: "ops summary cache contains invalid JSON" };
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return { ok: false, status: null, error: "ops summary cache is not a JSON object" };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), EOC_PUSH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(ingestUrl(eocUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: json,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.status === 204) {
|
||||
return { ok: true, status: 204, error: null };
|
||||
}
|
||||
|
||||
const text = await res.text().catch(() => "<unreadable body>");
|
||||
return { ok: false, status: res.status, error: text.slice(0, 500) };
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, status: null, error: message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
||||
import IORedis from "ioredis";
|
||||
import { pushOpsSummaryToEoc } from "../src/processors/eoc-push.js";
|
||||
|
||||
const redis = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379", {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
|
||||
describe("pushOpsSummaryToEoc", () => {
|
||||
beforeAll(async () => {
|
||||
await redis.set(
|
||||
"ops:summary:cache",
|
||||
JSON.stringify({ app: { app: "cibello", generated_at: new Date().toISOString() }, as_of: new Date().toISOString() }),
|
||||
"EX",
|
||||
60,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await redis.del("ops:summary:cache");
|
||||
await redis.quit();
|
||||
});
|
||||
|
||||
it("returns disabled result when EOC_URL is missing", async () => {
|
||||
delete process.env.EOC_URL;
|
||||
process.env.EOC_PUSH_TOKEN = "tok";
|
||||
const result = await pushOpsSummaryToEoc(redis);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatch(/EOC_URL or EOC_PUSH_TOKEN not configured/);
|
||||
});
|
||||
|
||||
it("returns disabled result when EOC_PUSH_TOKEN is missing", async () => {
|
||||
process.env.EOC_URL = "https://eoc.example.com";
|
||||
delete process.env.EOC_PUSH_TOKEN;
|
||||
const result = await pushOpsSummaryToEoc(redis);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatch(/EOC_URL or EOC_PUSH_TOKEN not configured/);
|
||||
});
|
||||
|
||||
it("returns cache-miss when cache is empty", async () => {
|
||||
process.env.EOC_URL = "https://eoc.example.com";
|
||||
process.env.EOC_PUSH_TOKEN = "tok";
|
||||
await redis.del("ops:summary:cache");
|
||||
const result = await pushOpsSummaryToEoc(redis);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatch(/cache miss/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user