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. */
|
/** 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"),
|
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"),
|
APP_STORE_MODE: z.enum(["production", "sandbox"]).default("sandbox"),
|
||||||
EMAIL_MODE: z.enum(["log", "smtp"]).default("log"),
|
EMAIL_MODE: z.enum(["log", "smtp"]).default("log"),
|
||||||
SMTP_HOST: z.string().default(""),
|
SMTP_HOST: z.string().default(""),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from "./processors/maintenance.js";
|
} from "./processors/maintenance.js";
|
||||||
import { processSafetyCanary } from "./processors/safety-canary.js";
|
import { processSafetyCanary } from "./processors/safety-canary.js";
|
||||||
import { refreshOpsSummaryCache } from "./processors/ops-summary.js";
|
import { refreshOpsSummaryCache } from "./processors/ops-summary.js";
|
||||||
|
import { pushOpsSummaryToEoc } from "./processors/eoc-push.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DEAD_LETTER_QUEUE_NAME,
|
DEAD_LETTER_QUEUE_NAME,
|
||||||
@@ -145,6 +146,12 @@ const worker = new Worker(
|
|||||||
return;
|
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
|
// Deterministiska/planerade jobb som inte kräver egen processor ännu
|
||||||
case "NORMALIZE_PRODUCTS":
|
case "NORMALIZE_PRODUCTS":
|
||||||
case "DEDUPLICATE_INVENTORY":
|
case "DEDUPLICATE_INVENTORY":
|
||||||
@@ -293,6 +300,15 @@ async function registerRepeatableJobs() {
|
|||||||
opts: baseOpts,
|
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 ---
|
// --- 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