/** * ctx.entity — context that arrives complete. * * Exactly what `np service workflow build-context` resolves for YAML * workflows, offered to handlers as lazy, memoized fetchers: scope, * application, namespace, account, deployment, release, assets, parameters, * providers. Nothing is fetched until asked for, and nothing is fetched * twice — a handler that never touches the hierarchy pays nothing. * * Identity comes from the notification itself: entity IDs are parsed out of * `entity_nrn` (`organization=…:account=…:namespace=…:application=…:scope=…`), * the scope and deployment ids from the action parameters. Asking for a rung * the NRN does not carry fails with a message that says so, not a 404. */ import * as platformApi from "../api"; import type { Notification } from "./index"; /** The api functions entity fetchers use — injectable for tests. */ export type EntityApi = Pick< typeof platformApi, | "scope" | "application" | "namespace" | "account" | "deployment" | "release" | "assets" | "parameters" | "providers" >; export interface NrnIds { organization?: string; account?: string; namespace?: string; application?: string; scope?: string; } /** `organization=4:account=17:…` → its segments. Unknown keys are ignored. */ export function parseNrn(nrn: string): NrnIds { const out: NrnIds = {}; for (const segment of nrn.split(":")) { const [key, value] = segment.split("="); if ( value && (key === "organization" || key === "account" || key === "namespace" || key === "application" || key === "scope") ) { out[key] = value; } } return out; } export interface EntityContext { /** The action's entity_nrn, verbatim (undefined when the wire omits it). */ nrn?: string; /** The NRN's parsed segments — ids without a fetch. */ ids: NrnIds; scope(): Promise; application(): Promise; namespace(): Promise; account(): Promise; /** The acting deployment — undefined on non-deployment actions. */ deployment(): Promise; /** The acting deployment's release. */ release(): Promise; /** Every asset on the release's build — callers pick and can report ambiguity. */ assets(): Promise; /** Parameters resolved at the entity's NRN, secrets included. */ parameters(): Promise>; /** * Provider attributes by category, the way `np service workflow build-context` * picks them: among the providers at the NRN, the one whose dimensions best * match the scope's (the notification's service dimensions when the action * has no scope). `dimensions` overrides that choice. */ providers( categories: string[], opts?: { dimensions?: Record } ): Promise; } export function createEntityContext( notification: Notification, scopeId: string, deploymentId: string | undefined, api: EntityApi = platformApi ): EntityContext { const nrn = (notification as { entity_nrn?: string }).entity_nrn; const ids = nrn ? parseNrn(nrn) : {}; // One in-flight/settled promise per rung: concurrent callers share the // fetch, later callers get the memo. const memo = new Map>(); const once = (key: string, run: () => Promise): Promise => { let hit = memo.get(key) as Promise | undefined; if (!hit) { // Normalize sync throws into rejections, and drop failed fetches from // the memo — a transient API error should not poison the whole action. hit = Promise.resolve().then(run); hit.catch(() => memo.delete(key)); memo.set(key, hit); } return hit; }; const need = (what: string, id: string | undefined): string => { if (!id) { throw new Error( `the action's entity_nrn does not carry ${what} — nothing to resolve (nrn: ${ nrn ?? "absent" })` ); } return id; }; const deployment = () => once("deployment", async () => deploymentId ? api.deployment(deploymentId) : undefined ); const release = () => once("release", async () => { const dep = await deployment(); if (!dep) throw new Error( "no deployment on this action — there is no release to resolve" ); if (!dep.release_id) throw new Error(`deployment ${deploymentId} has no release_id`); return api.release(String(dep.release_id)); }); // Scope dimensions win; an action with no scope falls back to the service's. const scopeDimensions = async (): Promise< Record | undefined > => { const sid = scopeId || ids.scope; if (sid) { try { const scope = await once("scope", () => api.scope(sid)); if (scope.dimensions && Object.keys(scope.dimensions).length) return scope.dimensions; } catch { // an unreadable scope must not fail the lookup; the service's dimensions still apply } } const service = ( notification as { service?: { dimensions?: Record } } ).service; return service?.dimensions && Object.keys(service.dimensions).length ? service.dimensions : undefined; }; return { nrn, ids, scope: () => once("scope", () => api.scope(need("a scope", scopeId || ids.scope))), application: () => once("application", () => api.application(need("an application", ids.application)) ), namespace: () => once("namespace", () => api.namespace(need("a namespace", ids.namespace)) ), account: () => once("account", () => api.account(need("an account", ids.account))), deployment, release, assets: () => once("assets", async () => { const rel = await release(); if (!rel.build_id) throw new Error("the release has no build_id — no assets to list"); return api.assets(String(rel.build_id)); }), parameters: () => once("parameters", () => api.parameters(need("an NRN", nrn), { includeSecrets: true }) ), providers: ( categories: string[], opts?: { dimensions?: Record } ) => once( `providers:${categories.join(",")}:${JSON.stringify( opts?.dimensions ?? null )}`, async () => api.providers( need("an NRN", nrn), categories, opts?.dimensions ?? (await scopeDimensions()) ) ), }; }