/** * `np service workflow build-context`, as a function. * * The CLI resolves the hierarchy an action runs against (scope, application, * namespace, account), the acting deployment with its release and asset, the * scope's parameters, and the providers of the requested categories, and * hands YAML workflows one map with those keys. This builds the same map from * a notification using the SDK's fetchers, so a package handler can ship a * workflow the context it is used to. * * const build = createBuildContext(notification, { api }); * const ctx = await build({ resources: ["scope", "release"], secrets: true }); * // → { scope: {…}, release: {…} } * * Every resource resolves once per notification: the first build fetches it, * later builds (and dependencies — a release needs its deployment, an asset * its release and the scope's asset_name) reuse the memo. Only the resources * asked for appear in the result; what was fetched merely to reach them does * not, exactly as the CLI drops a release it only needed for the asset. * * Where this differs from the Go command, because the SDK fetchers reshape: * - `parameters.results[]` entries carry `variable` and `values[0].value` * only; the CLI also forwards id, version_id, type, name, destination_path * and values[0].id (`api.parameters` flattens to variable → value). * - `providers[category]` is the winning provider's attributes; the CLI * additionally nests each specification's attributes under its slug inside * the category, and ranks by dimension match score → NRN length → id * (`api.providers` ranks by dimension count → NRN length). * - `asset` is matched by name client-side (the API ignores a name filter), * falling back to the build's only asset when there is exactly one. * - Parameters need only a scope NRN, not a deployment_id; the CLI resolves * them inside its deployment step. A missing scope is fetched for them, * where the CLI silently skips. */ import * as platformApi from "../api"; import { parseNrn } from "../scope/entity"; import type { Notification } from "../scope"; export const BUILD_CONTEXT_RESOURCES = [ "scope", "application", "namespace", "account", "deployment", "release", "parameters", "asset", "providers", ] as const; export type BuildContextResource = (typeof BUILD_CONTEXT_RESOURCES)[number]; export interface BuildContextOptions { /** Resources to fetch (`--include`). Default: all. Unknown names are ignored, as the CLI does. */ resources?: string[]; /** Resources to leave out (`--exclude`). Cannot be combined with `resources`. */ exclude?: string[]; /** Resolve secret parameter values too (`--include-secrets`). Default: false. */ secrets?: boolean; /** Provider categories to resolve (`--provider-categories`). Providers are fetched only when given. */ providerCategories?: string[]; /** Dimensions the provider match is made for. Default: the scope's, else the service's. */ dimensions?: Record; } /** The api functions the builder uses — `typeof platformApi` fits; tests pass a fake. */ export type BuildContextApi = Pick< typeof platformApi, | "scope" | "application" | "namespace" | "account" | "deployment" | "release" | "assets" | "parameters" | "providers" >; export type BuildContext = Record; // ─── Identity ──────────────────────────────────────────────────────── /** The CLI's numeric parameter rule: a number, or a string holding an integer. */ function numericId(value: unknown): string | undefined { if (typeof value === "number" && Number.isFinite(value)) return String(Math.trunc(value)); if (typeof value === "string" && /^-?\d+$/.test(value.trim())) return value.trim(); return undefined; } function nonEmpty(value: unknown): string | undefined { return typeof value === "string" && value !== "" ? value : undefined; } /** entity_nrn first; then the action's scope_id parameter and the *_id tags. */ function resolveEntityIds(notification: Notification) { const nrn = nonEmpty(notification.entity_nrn); const ids = nrn ? parseNrn(nrn) : {}; const params = (notification.parameters ?? {}) as Record; const tags = (notification.tags ?? {}) as Record; return { scope: ids.scope ?? numericId(params.scope_id), application: ids.application ?? nonEmpty(tags.application_id), namespace: ids.namespace ?? nonEmpty(tags.namespace_id), account: ids.account ?? nonEmpty(tags.account_id), deployment: numericId(params.deployment_id), }; } function resolveResourceSet( opts: BuildContextOptions ): Set { if (opts.resources && opts.exclude) { throw new Error( "build-context: `resources` and `exclude` cannot be used together" ); } const valid = new Set(BUILD_CONTEXT_RESOURCES); const active = new Set( opts.resources ? [] : BUILD_CONTEXT_RESOURCES ); for (const name of opts.resources ?? []) { if (valid.has(name)) active.add(name as BuildContextResource); } for (const name of opts.exclude ?? []) active.delete(name as BuildContextResource); return active; } // ─── Providers ─────────────────────────────────────────────────────── /** The most specific NRN among the resolved hierarchy — the CLI's getProviderNRN. */ function providerNrn(ctx: BuildContext): string | undefined { let best: string | undefined; for (const name of ["scope", "application", "namespace", "account"]) { const nrn = (ctx[name] as { nrn?: unknown } | undefined)?.nrn; if (typeof nrn === "string" && nrn.length > (best?.length ?? 0)) best = nrn; } return best; } function stringDimensions(raw: unknown): Record | undefined { if (!raw || typeof raw !== "object") return undefined; const out: Record = {}; for (const [key, value] of Object.entries(raw as Record)) out[key] = String(value); return Object.keys(out).length ? out : undefined; } /** Sorted `key:value,…` — the memo key, and what the CLI sends as its filter. */ function formatDimensions( dimensions: Record | undefined ): string { return Object.keys(dimensions ?? {}) .sort() .map((key) => `${key}:${dimensions![key]}`) .join(","); } // ─── Builder ───────────────────────────────────────────────────────── export function createBuildContext( notification: Notification, deps: { api: BuildContextApi } ): (opts?: BuildContextOptions) => Promise { const { api } = deps; const ids = resolveEntityIds(notification); // One in-flight/settled promise per resource: concurrent callers share the // fetch, later builds get the memo. A failed fetch leaves the memo so a // transient error does not poison every later build. const memo = new Map>(); const once = (key: string, run: () => Promise): Promise => { let hit = memo.get(key) as Promise | undefined; if (!hit) { hit = Promise.resolve().then(run); hit.catch(() => memo.delete(key)); memo.set(key, hit); } return hit; }; const none = Promise.resolve(undefined); // Each rung is undefined when the notification carries no identity for it. const scope = () => ids.scope ? once("scope", () => api.scope(ids.scope!)) : none; const application = () => ids.application ? once("application", () => api.application(ids.application!)) : none; const namespace = () => ids.namespace ? once("namespace", () => api.namespace(ids.namespace!)) : none; const account = () => ids.account ? once("account", () => api.account(ids.account!)) : none; const deployment = () => ids.deployment ? once("deployment", () => api.deployment(ids.deployment!)) : none; const release = () => once("release", async () => { const releaseId = (await deployment())?.release_id; return releaseId == null || releaseId === "" ? undefined : api.release(String(releaseId)); }); const asset = () => once("asset", async () => { const [rel, sc] = await Promise.all([release(), scope()]); const buildId = rel?.build_id; const assetName = sc?.asset_name; if (buildId == null || buildId === "" || typeof assetName !== "string") return undefined; const all = await api.assets(String(buildId)); const named = all.filter( (a) => (a as { name?: unknown }).name === assetName ); if (named.length) return named[0]; if (all.length === 1) return all[0]; throw new Error( `no asset found for build_id: ${buildId} and name: ${assetName}` ); }); const parameters = (secrets: boolean) => once(`parameters:${secrets}`, async () => { const nrn = (await scope())?.nrn; if (typeof nrn !== "string" || !nrn) return undefined; const values = await api.parameters(nrn, { includeSecrets: secrets }); return { results: Object.entries(values).map(([variable, value]) => ({ variable, values: [{ value }], })), }; }); const providers = ( nrn: string, categories: string[], dimensions?: Record ) => once( `providers:${nrn}:${categories.join(",")}:${formatDimensions( dimensions )}`, () => api.providers(nrn, categories, dimensions) ); return async (opts: BuildContextOptions = {}): Promise => { const active = resolveResourceSet(opts); const ctx: BuildContext = {}; const put = async (key: BuildContextResource, value: Promise) => { const resolved = await value; if (resolved !== undefined) ctx[key] = resolved; }; // Everything the caller asked for, concurrently; dependencies chain // through the memo, so a release and its asset share one deployment fetch. const jobs: Promise[] = []; if (active.has("scope")) jobs.push(put("scope", scope())); if (active.has("application")) jobs.push(put("application", application())); if (active.has("namespace")) jobs.push(put("namespace", namespace())); if (active.has("account")) jobs.push(put("account", account())); if (active.has("deployment")) jobs.push(put("deployment", deployment())); if (active.has("release")) jobs.push(put("release", release())); if (active.has("asset")) jobs.push(put("asset", asset())); if (active.has("parameters")) jobs.push(put("parameters", parameters(opts.secrets ?? false))); await Promise.all(jobs); // Providers come last: they are resolved at the most specific NRN in hand // and matched against the scope's dimensions (a service action has no // scope, so its service's). Nothing is fetched without categories. const categories = opts.providerCategories ?? []; if (active.has("providers") && categories.length) { const nrn = providerNrn(ctx) ?? nonEmpty(notification.entity_nrn); if (nrn) { const dimensions = stringDimensions(opts.dimensions) ?? stringDimensions( (ctx.scope as { dimensions?: unknown } | undefined)?.dimensions ) ?? stringDimensions(notification.service?.dimensions); ctx.providers = await providers(nrn, categories, dimensions); } } return ctx; }; }