From 15244795aec0e9ad9d5ef2a08a922ca6ba1c723e Mon Sep 17 00:00:00 2001 From: Colin Ozanne Date: Mon, 13 Jul 2026 08:22:25 +0000 Subject: [PATCH] fix: detect changes in external status providers (#2355) * fix: detect changes in external status providers * fixes * more fixes --- .../src/cron/external-status-detect.test.ts | 128 +++++++++ .../src/cron/external-status-detect.ts | 77 ++++++ apps/workflows/src/cron/external-status.ts | 254 +++++++++++++++++- apps/workflows/src/lib/sentry.ts | 72 +++++ .../__tests__/update-provider.test.ts | 165 ++++++++++++ .../services/src/external-service/index.ts | 4 + .../src/external-service/update-provider.ts | 66 +++++ .../status-fetcher/__tests__/detect.test.ts | 233 ++++++++++++++++ .../status-fetcher/__tests__/fetch.test.ts | 72 ++++- packages/status-fetcher/src/detect.ts | 252 +++++++++++++++++ packages/status-fetcher/src/fetch.ts | 63 ++++- .../status-fetcher/src/fetchers/atlassian.ts | 2 +- .../src/fetchers/betterstack.ts | 2 +- .../status-fetcher/src/fetchers/instatus.ts | 2 +- packages/status-fetcher/src/index.ts | 2 + 15 files changed, 1373 insertions(+), 21 deletions(-) create mode 100644 apps/workflows/src/cron/external-status-detect.test.ts create mode 100644 apps/workflows/src/cron/external-status-detect.ts create mode 100644 packages/services/src/external-service/__tests__/update-provider.test.ts create mode 100644 packages/services/src/external-service/update-provider.ts create mode 100644 packages/status-fetcher/__tests__/detect.test.ts create mode 100644 packages/status-fetcher/src/detect.ts diff --git a/apps/workflows/src/cron/external-status-detect.test.ts b/apps/workflows/src/cron/external-status-detect.test.ts new file mode 100644 index 00000000..70e30447 --- /dev/null +++ b/apps/workflows/src/cron/external-status-detect.test.ts @@ -0,0 +1,128 @@ +import { FetchError } from "@openstatus/status-fetcher"; +import type { DetectionResult } from "@openstatus/status-fetcher"; +import { describe, expect, test } from "@openstatus/test-utils"; + +import { + PROBE_TTL_MS, + clearProbeStamp, + decideDetectionAction, + isSuspicious, + shouldProbe, +} from "./external-status-detect"; + +const URL = "https://status.example.com"; + +const fetchError = (init: { + kind?: "http" | "parse" | "network" | "timeout"; + httpStatus?: number; +}) => new FetchError({ url: URL, ...init }); + +const result = (partial: Partial): DetectionResult => ({ + currentProviderValidated: false, + matches: [], + hostnameSuggestions: [], + evidence: ["e"], + ...partial, +}); + +describe("shouldProbe", () => { + test("probes once per TTL per slug and stamps on true", () => { + const map = new Map(); + expect(shouldProbe("a", 1000, map)).toBe(true); + expect(shouldProbe("a", 1000 + PROBE_TTL_MS - 1, map)).toBe(false); + expect(shouldProbe("a", 1000 + PROBE_TTL_MS, map)).toBe(true); + expect(shouldProbe("b", 1000, map)).toBe(true); + }); + + test("clearProbeStamp refunds the TTL", () => { + const map = new Map(); + expect(shouldProbe("a", 1000, map)).toBe(true); + clearProbeStamp("a", map); + expect(shouldProbe("a", 1001, map)).toBe(true); + }); +}); + +describe("isSuspicious", () => { + test("parse and 4xx are suspicious", () => { + expect(isSuspicious(fetchError({ kind: "parse" }))).toBe(true); + expect(isSuspicious(fetchError({ kind: "http", httpStatus: 404 }))).toBe( + true, + ); + }); + + test("5xx, network, timeout and untyped errors are not", () => { + expect(isSuspicious(fetchError({ kind: "http", httpStatus: 503 }))).toBe( + false, + ); + expect(isSuspicious(fetchError({ kind: "network" }))).toBe(false); + expect(isSuspicious(fetchError({ kind: "timeout" }))).toBe(false); + expect(isSuspicious(fetchError({}))).toBe(false); + }); +}); + +describe("decideDetectionAction", () => { + const row = (apiConfig: { type: "atlassian"; endpoint?: string } | null) => + ({ provider: "atlassian-statuspage", apiConfig }) as const; + + test("clears config when current validates and a custom endpoint is set", () => { + const action = decideDetectionAction( + result({ currentProviderValidated: true }), + row({ type: "atlassian", endpoint: "https://stale.example.com" }), + ); + expect(action.kind).toBe("clear-config"); + }); + + test("noop transient when current validates with default endpoint", () => { + const action = decideDetectionAction( + result({ currentProviderValidated: true }), + row(null), + ); + expect(action).toMatchObject({ kind: "noop", reason: "transient" }); + }); + + test("applies a single match", () => { + const action = decideDetectionAction( + result({ + matches: [{ type: "instatus", provider: "instatus", endpoint: URL }], + }), + row(null), + ); + expect(action).toMatchObject({ kind: "apply", provider: "instatus" }); + }); + + test("suggests when multiple matches", () => { + const action = decideDetectionAction( + result({ + matches: [ + { type: "incidentio", provider: "incidentio", endpoint: URL }, + { + type: "atlassian", + provider: "atlassian-statuspage", + endpoint: URL, + }, + ], + }), + row(null), + ); + expect(action).toMatchObject({ + kind: "suggest", + suggestion: "atlassian-statuspage|incidentio", + }); + }); + + test("suggests hostname evidence", () => { + const action = decideDetectionAction( + result({ hostnameSuggestions: ["uptime-robot"] }), + row(null), + ); + expect(action).toMatchObject({ + kind: "suggest", + suggestion: "uptime-robot", + }); + }); + + test("noop no-evidence when nothing found", () => { + const action = decideDetectionAction(result({}), row(null)); + expect(action).toMatchObject({ kind: "noop", reason: "no-evidence" }); + }); +}); diff --git a/apps/workflows/src/cron/external-status-detect.ts b/apps/workflows/src/cron/external-status-detect.ts new file mode 100644 index 00000000..5b35da3b --- /dev/null +++ b/apps/workflows/src/cron/external-status-detect.ts @@ -0,0 +1,77 @@ +import type { ExternalServiceRow } from "@openstatus/services/external-service"; +import type { + DetectionResult, + FetchError, + StatusPageProvider, +} from "@openstatus/status-fetcher"; + +export const PROBE_TTL_MS = 24 * 60 * 60 * 1000; + +const lastProbeAt = new Map(); + +// check-and-set: stamps on `true` so a no-result probe still waits out the TTL +export function shouldProbe( + slug: string, + now: number, + map: Map = lastProbeAt, +): boolean { + const last = map.get(slug); + if (last !== undefined && now - last < PROBE_TTL_MS) return false; + map.set(slug, now); + return true; +} + +// refunds the TTL after a failed write so the next tick retries +export function clearProbeStamp( + slug: string, + map: Map = lastProbeAt, +): void { + map.delete(slug); +} + +export function isSuspicious(err: FetchError): boolean { + if (err.kind === "parse") return true; + return ( + err.kind === "http" && err.httpStatus !== undefined && err.httpStatus < 500 + ); +} + +export type DetectionAction = + | { kind: "apply"; provider: StatusPageProvider; evidence: string[] } + | { kind: "clear-config"; evidence: string[] } + | { kind: "suggest"; suggestion: string; evidence: string[] } + | { kind: "noop"; reason: "transient" | "no-evidence"; evidence: string[] }; + +export function decideDetectionAction( + result: DetectionResult, + row: Pick, +): DetectionAction { + const { evidence } = result; + if (result.currentProviderValidated) { + return row.apiConfig?.endpoint + ? { kind: "clear-config", evidence } + : { kind: "noop", reason: "transient", evidence }; + } + const single = result.matches.length === 1 ? result.matches[0] : undefined; + if (single) { + return { kind: "apply", provider: single.provider, evidence }; + } + if (result.matches.length >= 2) { + return { + kind: "suggest", + suggestion: result.matches + .map((m) => m.provider) + .sort() + .join("|"), + evidence, + }; + } + if (result.hostnameSuggestions.length > 0) { + return { + kind: "suggest", + suggestion: [...result.hostnameSuggestions].sort().join("|"), + evidence, + }; + } + return { kind: "noop", reason: "no-evidence", evidence }; +} diff --git a/apps/workflows/src/cron/external-status.ts b/apps/workflows/src/cron/external-status.ts index fa312241..ab0c6491 100644 --- a/apps/workflows/src/cron/external-status.ts +++ b/apps/workflows/src/cron/external-status.ts @@ -2,6 +2,7 @@ import { getLogger } from "@logtape/logtape"; import { db } from "@openstatus/db"; import { listExternalServices } from "@openstatus/services/external-service"; import type { ExternalServiceRow } from "@openstatus/services/external-service"; +import { applyDetectedProvider } from "@openstatus/services/external-service"; import { type UpsertExternalComponentInput, upsertExternalComponentsForService, @@ -10,7 +11,11 @@ import { type UpsertExternalIncidentInput, upsertExternalIncidentsForService, } from "@openstatus/services/external-service-incident"; -import { FetchError, fetchers } from "@openstatus/status-fetcher"; +import { + FetchError, + detectProvider, + fetchers, +} from "@openstatus/status-fetcher"; import type { NormalizedComponent, NormalizedIncident, @@ -25,9 +30,17 @@ import type { Context } from "hono"; import { env } from "../env"; import { reportBackgroundError, + reportDetectionStory, + reportDetectionWriteFailure, reportFetchFailure, runSentryCron, } from "../lib/sentry"; +import { + clearProbeStamp, + decideDetectionAction, + isSuspicious, + shouldProbe, +} from "./external-status-detect"; const logger = getLogger(["workflow", "external-status"]); @@ -126,7 +139,7 @@ type PhaseCounts = { type StatusPhaseOutcome = | { kind: "ok"; snapshot: Snapshot } | { kind: "no-fetcher"; slug: string } - | { kind: "fail"; slug: string; reason: string }; + | { kind: "fail"; slug: string; reason: string; error: FetchError }; type IncidentPhaseOutcome = | { kind: "ok"; slug: string; count: number } @@ -164,10 +177,14 @@ function runStatusPhase( snapshot: buildSnapshot({ entry, result, fetchedAt }), }), ), + // Failure reporting is deferred: the detect step after this phase + // either merges it into a detection story or reports it plain. Effect.catchAll((err: FetchError) => - Effect.sync(() => { - reportFetchFailure({ phase: "status", slug: entry.id, error: err }); - return { kind: "fail", slug: entry.id, reason: err.message }; + Effect.succeed({ + kind: "fail", + slug: entry.id, + reason: err.message, + error: err, }), ), ); @@ -413,10 +430,214 @@ function buildTriplets(services: ExternalServiceRow[]): Triplet[] { }); } +const DETECT_CONCURRENCY = 3; + +type DetectItem = { triplet: Triplet; error?: FetchError }; + +type DetectOutcome = { + kind: + | "applied" + | "config-fixed" + | "suggested" + | "transient" + | "none" + | "skipped" + | "error"; + slug: string; +}; + +type DetectCounts = { + probed: number; + applied: number; + configFixed: number; + suggested: number; + failed: number; +}; + +function collectDetectItems( + triplets: Triplet[], + statusOutcomes: StatusPhaseOutcome[], + now: number, +): DetectItem[] { + const items: DetectItem[] = []; + statusOutcomes.forEach((outcome, i) => { + const triplet = triplets[i]; + if (!triplet) return; + if (outcome.kind === "no-fetcher") { + if (shouldProbe(triplet.entry.id, now)) items.push({ triplet }); + return; + } + if (outcome.kind !== "fail") return; + if (isSuspicious(outcome.error) && shouldProbe(triplet.entry.id, now)) { + items.push({ triplet, error: outcome.error }); + } else { + reportFetchFailure({ + phase: "status", + slug: outcome.slug, + error: outcome.error, + }); + } + }); + return items; +} + +function applyDetection(args: { + triplet: Triplet; + error?: FetchError; + provider: ExternalServiceRow["provider"]; + outcome: "applied" | "config-fixed"; + evidence: string[]; + tickStartedAt: Date; +}): Effect.Effect { + const { triplet, error, provider, outcome, evidence, tickStartedAt } = args; + const { row, entry } = triplet; + return Effect.tryPromise({ + try: () => + applyDetectedProvider({ + ctx: { db }, + input: { + serviceId: row.id, + expected: { + provider: row.provider, + apiConfig: row.apiConfig ?? null, + }, + set: { provider, apiConfig: null }, + }, + now: tickStartedAt, + }), + catch: (e) => (e instanceof Error ? e : new Error(String(e))), + }).pipe( + Effect.map(({ updated }): DetectOutcome => { + if (!updated) { + logger.warn( + "external-status detect: concurrent edit, write skipped for slug={slug}", + { slug: entry.id }, + ); + return { kind: "skipped", slug: entry.id }; + } + reportDetectionStory({ + slug: entry.id, + currentProvider: row.provider, + fetchError: error, + outcome: + outcome === "applied" + ? { kind: "applied", provider } + : { kind: "config-cleared" }, + evidence, + }); + return { kind: outcome, slug: entry.id }; + }), + Effect.catchAll((e) => + Effect.sync((): DetectOutcome => { + logger.warn( + "external-status detect: write failed for slug={slug}: {message}", + { slug: entry.id, message: e.message }, + ); + reportDetectionWriteFailure({ slug: entry.id, error: e }); + clearProbeStamp(entry.id); + // The merged story never fired; keep the triggering failure visible. + if (error) { + reportFetchFailure({ phase: "status", slug: entry.id, error }); + } + return { kind: "error", slug: entry.id }; + }), + ), + ); +} + +function detectAndAct( + item: DetectItem, + tickStartedAt: Date, +): Effect.Effect { + const { triplet, error } = item; + const { row, entry } = triplet; + return detectProvider({ + statusPageUrl: entry.status_page_url, + currentProvider: row.provider, + entryId: entry.id, + }).pipe( + Effect.flatMap((result) => { + const action = decideDetectionAction(result, row); + switch (action.kind) { + case "apply": + return applyDetection({ + triplet, + error, + provider: action.provider, + outcome: "applied", + evidence: action.evidence, + tickStartedAt, + }); + case "clear-config": + return applyDetection({ + triplet, + error, + provider: row.provider, + outcome: "config-fixed", + evidence: action.evidence, + tickStartedAt, + }); + case "suggest": + return Effect.sync((): DetectOutcome => { + reportDetectionStory({ + slug: entry.id, + currentProvider: row.provider, + fetchError: error, + outcome: { kind: "suggest", suggestion: action.suggestion }, + evidence: action.evidence, + }); + return { kind: "suggested", slug: entry.id }; + }); + case "noop": + return Effect.sync((): DetectOutcome => { + if (action.reason === "no-evidence") { + reportDetectionStory({ + slug: entry.id, + currentProvider: row.provider, + fetchError: error, + outcome: { kind: "none" }, + evidence: action.evidence, + }); + return { kind: "none", slug: entry.id }; + } + if (error) { + reportFetchFailure({ phase: "status", slug: entry.id, error }); + } + return { kind: "transient", slug: entry.id }; + }); + } + }), + ); +} + +function runDetectPhase( + items: DetectItem[], + tickStartedAt: Date, +): Effect.Effect { + return Effect.forEach(items, (item) => detectAndAct(item, tickStartedAt), { + concurrency: DETECT_CONCURRENCY, + }); +} + +function summarizeDetect(outcomes: DetectOutcome[]): DetectCounts { + let applied = 0; + let configFixed = 0; + let suggested = 0; + let failed = 0; + for (const o of outcomes) { + if (o.kind === "applied") applied++; + else if (o.kind === "config-fixed") configFixed++; + else if (o.kind === "suggested") suggested++; + else if (o.kind === "error") failed++; + } + return { probed: outcomes.length, applied, configFixed, suggested, failed }; +} + export async function runExternalStatusTick(): Promise<{ status: PhaseCounts; incidents: PhaseCounts; components: PhaseCounts; + detect: DetectCounts; }> { const services = await listExternalServices({ ctx: { db } }); @@ -450,7 +671,21 @@ export async function runExternalStatusTick(): Promise<{ await tb.publishExternalStatusComponent(components.snapshots); } - return { status: status.counts, incidents, components: components.counts }; + // After the publishes: a slow probe fleet must not delay or drop the + // snapshots the tick already fetched. + const detectItems = collectDetectItems(triplets, statusOutcomes, Date.now()); + const detectOutcomes = + detectItems.length > 0 + ? await Effect.runPromise(runDetectPhase(detectItems, tickStartedAt)) + : []; + const detect = summarizeDetect(detectOutcomes); + + return { + status: status.counts, + incidents, + components: components.counts, + detect, + }; } export async function handleExternalStatusCron(c: Context) { @@ -471,8 +706,13 @@ export async function handleExternalStatusCron(c: Context) { Effect.tap((res) => Effect.sync(() => { logger.info( - "external-status tick complete: status={statusOk}/{statusTotal} ({statusFail} failures, {statusSkip} skipped), incidents={incOk}/{incTotal} ({incFail} failures, {incSkip} skipped), components={compOk}/{compTotal} ({compFail} failures, {compSkip} skipped)", + "external-status tick complete: status={statusOk}/{statusTotal} ({statusFail} failures, {statusSkip} skipped), incidents={incOk}/{incTotal} ({incFail} failures, {incSkip} skipped), components={compOk}/{compTotal} ({compFail} failures, {compSkip} skipped), detect={detProbed} probed ({detApplied} applied, {detCfg} config-fixed, {detSuggested} suggested, {detFailed} failed)", { + detProbed: res.detect.probed, + detApplied: res.detect.applied, + detCfg: res.detect.configFixed, + detSuggested: res.detect.suggested, + detFailed: res.detect.failed, statusOk: res.status.successCount, statusTotal: res.status.total, statusFail: res.status.failureCount, diff --git a/apps/workflows/src/lib/sentry.ts b/apps/workflows/src/lib/sentry.ts index 45e70c55..84fac772 100644 --- a/apps/workflows/src/lib/sentry.ts +++ b/apps/workflows/src/lib/sentry.ts @@ -34,6 +34,78 @@ export async function reportBackgroundError(message: string): Promise { await Sentry.flush(); } +export type DetectionOutcome = + | { kind: "applied"; provider: string } + | { kind: "config-cleared" } + | { kind: "suggest"; suggestion: string } + | { kind: "none" }; + +// One event tells the whole story of a probed tick: the fetch failure plus +// what detection concluded. Fingerprinted per (slug, outcome) so repeats +// collapse into a single issue. +export function reportDetectionStory(args: { + slug: string; + currentProvider: string; + fetchError?: FetchError; + outcome: DetectionOutcome; + evidence: string[]; +}): void { + const { slug, currentProvider, fetchError, outcome, evidence } = args; + const story = (() => { + switch (outcome.kind) { + case "applied": + return { + key: `applied:${outcome.provider}`, + level: "info" as const, + message: `provider auto-updated ${currentProvider} → ${outcome.provider}`, + }; + case "config-cleared": + return { + key: "config-cleared", + level: "info" as const, + message: `stale api_config cleared (provider ${currentProvider})`, + }; + case "suggest": + return { + key: outcome.suggestion, + level: "warning" as const, + message: `provider suggestion: ${outcome.suggestion} (currently ${currentProvider})`, + }; + case "none": + return { + key: "none", + level: "error" as const, + message: `failing, no provider detected (currently ${currentProvider})`, + }; + } + })(); + Sentry.captureMessage(`external-status: ${slug} ${story.message}`, { + level: story.level, + fingerprint: ["external-status-detect", slug, story.key], + tags: { + cron: "external-status", + phase: "detect", + slug, + current_provider: currentProvider, + outcome: outcome.kind, + }, + extra: { + evidence, + fetchError: fetchError?.message, + url: fetchError?.url, + }, + }); +} + +export function reportDetectionWriteFailure(args: { + slug: string; + error: Error; +}): void { + Sentry.captureException(args.error, { + tags: { cron: "external-status", phase: "detect", slug: args.slug }, + }); +} + // Fires inside the per-service fetch loop, so no flush here — the tick's // cronCompleted/cronFailed path flushes once the tick settles. export function reportFetchFailure(args: { diff --git a/packages/services/src/external-service/__tests__/update-provider.test.ts b/packages/services/src/external-service/__tests__/update-provider.test.ts new file mode 100644 index 00000000..5eb9cec5 --- /dev/null +++ b/packages/services/src/external-service/__tests__/update-provider.test.ts @@ -0,0 +1,165 @@ +import { db, eq, like } from "@openstatus/db"; +import { externalService } from "@openstatus/db/src/schema"; +import { expect } from "@std/expect"; +import { afterEach, describe, test } from "@std/testing/bdd"; + +import { applyDetectedProvider } from "../update-provider"; + +const TEST_PREFIX = "svc-updprov-test"; + +afterEach(async () => { + await db + .delete(externalService) + .where(like(externalService.slug, `${TEST_PREFIX}-%`)) + .run(); +}); + +async function insertService(args: { + slug: string; + provider?: "atlassian-statuspage" | "instatus"; + apiConfig?: { type: "atlassian"; endpoint?: string } | null; + deletedAt?: Date; +}) { + const rows = await db + .insert(externalService) + .values({ + slug: args.slug, + aliases: [], + name: "Svc", + url: "https://example.com", + statusPageUrl: "https://status.example.com", + provider: args.provider ?? "atlassian-statuspage", + industry: ["saas"], + apiConfig: args.apiConfig ?? undefined, + deletedAt: args.deletedAt, + }) + .returning({ id: externalService.id }); + const row = rows[0]; + if (!row) throw new Error("insert failed"); + return row.id; +} + +async function fetchService(id: number) { + const rows = await db + .select() + .from(externalService) + .where(eq(externalService.id, id)) + .all(); + const row = rows[0]; + if (!row) throw new Error("row missing"); + return row; +} + +describe("applyDetectedProvider", () => { + test("updates provider, clears apiConfig, bumps updatedAt", async () => { + const id = await insertService({ + slug: `${TEST_PREFIX}-apply`, + apiConfig: { type: "atlassian", endpoint: "https://old.example.com" }, + }); + const now = new Date(Date.now() + 60_000); + + const result = await applyDetectedProvider({ + input: { + serviceId: id, + expected: { + provider: "atlassian-statuspage", + apiConfig: { type: "atlassian", endpoint: "https://old.example.com" }, + }, + set: { provider: "instatus", apiConfig: null }, + }, + now, + }); + + expect(result.updated).toBe(true); + const row = await fetchService(id); + expect(row.provider).toBe("instatus"); + expect(row.apiConfig).toBeNull(); + expect(row.updatedAt?.getTime()).toBe( + Math.floor(now.getTime() / 1000) * 1000, + ); + }); + + test("skips when provider changed concurrently", async () => { + const id = await insertService({ + slug: `${TEST_PREFIX}-race`, + provider: "instatus", + }); + + const result = await applyDetectedProvider({ + input: { + serviceId: id, + expected: { provider: "atlassian-statuspage", apiConfig: null }, + set: { provider: "instatus", apiConfig: null }, + }, + }); + + expect(result.updated).toBe(false); + const row = await fetchService(id); + expect(row.provider).toBe("instatus"); + }); + + test("skips when apiConfig changed concurrently", async () => { + const id = await insertService({ + slug: `${TEST_PREFIX}-cfgrace`, + apiConfig: { type: "atlassian", endpoint: "https://new.example.com" }, + }); + + const result = await applyDetectedProvider({ + input: { + serviceId: id, + expected: { provider: "atlassian-statuspage", apiConfig: null }, + set: { provider: "instatus", apiConfig: null }, + }, + }); + + expect(result.updated).toBe(false); + const row = await fetchService(id); + expect(row.apiConfig).toEqual({ + type: "atlassian", + endpoint: "https://new.example.com", + }); + }); + + test("clears a stale apiConfig keeping the same provider", async () => { + const id = await insertService({ + slug: `${TEST_PREFIX}-cfgfix`, + apiConfig: { type: "atlassian", endpoint: "https://stale.example.com" }, + }); + + const result = await applyDetectedProvider({ + input: { + serviceId: id, + expected: { + provider: "atlassian-statuspage", + apiConfig: { + type: "atlassian", + endpoint: "https://stale.example.com", + }, + }, + set: { provider: "atlassian-statuspage", apiConfig: null }, + }, + }); + + expect(result.updated).toBe(true); + const row = await fetchService(id); + expect(row.provider).toBe("atlassian-statuspage"); + expect(row.apiConfig).toBeNull(); + }); + + test("skips soft-deleted rows", async () => { + const id = await insertService({ + slug: `${TEST_PREFIX}-deleted`, + deletedAt: new Date(), + }); + + const result = await applyDetectedProvider({ + input: { + serviceId: id, + expected: { provider: "atlassian-statuspage", apiConfig: null }, + set: { provider: "instatus", apiConfig: null }, + }, + }); + + expect(result.updated).toBe(false); + }); +}); diff --git a/packages/services/src/external-service/index.ts b/packages/services/src/external-service/index.ts index 26a025a2..0f3148f6 100644 --- a/packages/services/src/external-service/index.ts +++ b/packages/services/src/external-service/index.ts @@ -6,3 +6,7 @@ export { } from "./list"; export { type SlugMap, listExternalServiceSlugs } from "./list-slugs"; export { assertSlugAvailable } from "./internal"; +export { + type ApplyDetectedProviderInput, + applyDetectedProvider, +} from "./update-provider"; diff --git a/packages/services/src/external-service/update-provider.ts b/packages/services/src/external-service/update-provider.ts new file mode 100644 index 00000000..57f51452 --- /dev/null +++ b/packages/services/src/external-service/update-provider.ts @@ -0,0 +1,66 @@ +import { and, db as defaultDb, eq } from "@openstatus/db"; +import { + type ApiConfig, + type StatusPageProvider, + externalService, +} from "@openstatus/db/src/schema"; + +import { withBusyRetry } from "../retry"; +import { type GlobalReadContext, liveOnlyClause } from "./internal"; + +export type ApplyDetectedProviderInput = { + serviceId: number; + expected: { provider: StatusPageProvider; apiConfig: ApiConfig | null }; + set: { provider: StatusPageProvider; apiConfig: ApiConfig | null }; +}; + +function apiConfigEquals(a: ApiConfig | null, b: ApiConfig | null): boolean { + return JSON.stringify(a ?? null) === JSON.stringify(b ?? null); +} + +// No `withTransaction`/`emitAudit`: external services are a global, public, +// cron-driven catalogue with no workspace scope or audit log (ADR-0006/0007); +// the deduped Sentry event emitted by the caller is the change trail. +export async function applyDetectedProvider(args: { + ctx?: GlobalReadContext; + input: ApplyDetectedProviderInput; + now?: Date; +}): Promise<{ updated: boolean }> { + const { ctx, input } = args; + const db = ctx?.db ?? defaultDb; + const now = args.now ?? new Date(); + + return withBusyRetry(() => + db.transaction(async (tx) => { + const rows = await tx + .select({ + provider: externalService.provider, + apiConfig: externalService.apiConfig, + }) + .from(externalService) + .where(and(eq(externalService.id, input.serviceId), liveOnlyClause())) + .all(); + + const row = rows[0]; + if ( + !row || + row.provider !== input.expected.provider || + !apiConfigEquals(row.apiConfig ?? null, input.expected.apiConfig) + ) { + return { updated: false }; + } + + await tx + .update(externalService) + .set({ + provider: input.set.provider, + apiConfig: input.set.apiConfig, + updatedAt: now, + }) + .where(eq(externalService.id, input.serviceId)) + .run(); + + return { updated: true }; + }), + ); +} diff --git a/packages/status-fetcher/__tests__/detect.test.ts b/packages/status-fetcher/__tests__/detect.test.ts new file mode 100644 index 00000000..7598825f --- /dev/null +++ b/packages/status-fetcher/__tests__/detect.test.ts @@ -0,0 +1,233 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { Effect } from "effect"; + +import { detectProvider } from "../src/detect"; +import { installMockFetch } from "./helpers"; + +const PAGE_URL = "https://status.example.com"; + +const json = (body: object): Response => + ({ + ok: true, + status: 200, + statusText: "OK", + url: "", + json: async () => body, + text: async () => JSON.stringify(body), + }) as Response; + +const notFound = (): Response => + ({ + ok: false, + status: 404, + statusText: "Not Found", + json: async () => ({}), + text: async () => "", + }) as Response; + +const html = (body: string, finalUrl = PAGE_URL): Response => + ({ + ok: true, + status: 200, + statusText: "OK", + url: finalUrl, + text: async () => body, + json: async () => ({}), + }) as Response; + +const atlassianBody = { + page: { + id: "abc", + name: "Example", + url: "https://status.example.com", + updated_at: "2024-01-01T00:00:00Z", + }, + status: { indicator: "none", description: "All Systems Operational" }, +}; + +const instatusBody = { + activeIncidents: [], + activeMaintenances: [], + status: { text: "All systems operational", type: "UP" }, + page: { + name: "Example", + url: "https://status.example.com", + updated: "2024-01-01T00:00:00Z", + }, +}; + +const route = (routes: Record Response>) => + installMockFetch((url) => { + const { pathname } = new URL(url); + const handler = routes[pathname]; + return Promise.resolve(handler ? handler() : notFound()); + }); + +describe("detectProvider", () => { + it("detects a migration to instatus without an html fetch", async () => { + const fetchMock = route({ "/summary.json": () => json(instatusBody) }); + const result = await Effect.runPromise( + detectProvider({ + statusPageUrl: PAGE_URL, + currentProvider: "atlassian-statuspage", + entryId: "test", + }), + ); + expect(result.currentProviderValidated).toBe(false); + expect(result.matches.map((m) => m.provider)).toEqual(["instatus"]); + expect(result.hostnameSuggestions).toEqual([]); + expect(fetchMock.calls.length).toBe(3); + }); + + it("derives probe endpoints from urls with query or trailing slash", async () => { + route({ "/summary.json": () => json(instatusBody) }); + const result = await Effect.runPromise( + detectProvider({ + statusPageUrl: "https://status.example.com/?utm=1", + currentProvider: "atlassian-statuspage", + }), + ); + expect(result.matches.map((m) => m.provider)).toEqual(["instatus"]); + }); + + it("short-circuits when the current provider still validates", async () => { + const fetchMock = route({ + "/api/v2/summary.json": () => json(atlassianBody), + }); + const result = await Effect.runPromise( + detectProvider({ + statusPageUrl: PAGE_URL, + currentProvider: "atlassian-statuspage", + }), + ); + expect(result.currentProviderValidated).toBe(true); + expect(result.matches).toEqual([]); + expect(fetchMock.calls.length).toBe(3); + }); + + it("accepts pair label drift: short-circuits even when the page is served by the api-identical peer", async () => { + // Migrated atlassian → incident.io (or vice versa): the pair probe still + // validates the current label, so detection ends without consulting the + // html markers that would reveal the drift. Locked-in trade-off. + const fetchMock = route({ + "/api/v2/summary.json": () => json(atlassianBody), + "/": () => html('