diff --git a/docs/superpowers/plans/2026-09-10-finops-phase0-runner.md b/docs/superpowers/plans/2026-09-10-finops-phase0-runner.md new file mode 100644 index 0000000..1200029 --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-finops-phase0-runner.md @@ -0,0 +1,1495 @@ +# FinOps Phase 0: async package runner (engine plugin + callback) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A workflow can call the `cloud-query` package on an agent asynchronously, receive the result through the engine's per-execution callback, and read it as step outputs. + +**Architecture:** Two repos. In `workflow-system-demo` (engine) a new two-phase MODULE plugin `np-package-call` dispatches `package-exec` through `POST /controlplane/agent_command` with `execution_config.async` and returns `IStepResult.wait`; phase 2 validates a per-dispatch token and exposes the runner response as outputs. In `nullplatform/workflows` (this worktree) the `cloud-query` package POSTs its response to the callback URL it receives, and a reusable child workflow `finops/tool-cloud-query.yaml` wraps the plugin. + +**Tech Stack:** TypeScript (engine: Node 22 ESM, Vitest; package: bun 1.4, `@nullplatform/plugin@0.0.4`, AWS SDK v3), workflow YAML (`@nullplatform/workflow-kit`), Docker (local controlplane agent). + +**Spec:** `docs/superpowers/specs/2026-09-10-finops-cost-allocation-design.md` (sections 2, 4.1, 4.2, 4.3, 7 phase 0). + +## Global Constraints + +- Engine repo: `/Users/geisbruch/workspace/null/workflow-system-demo`, branch `fix/parallel-ready-steps` has unrelated uncommitted work. Create and use branch `feat/np-package-call` from `main` in a NEW worktree at `/Users/geisbruch/workspace/null/workflow-system-demo/.worktrees/np-package-call` (verify `.worktrees` is git-ignored first with `git check-ignore -q .worktrees`; if not, add it to `.gitignore` and commit that alone). +- Workflows repo worktree: `/Users/geisbruch/workspace/null/workflows/.worktrees/cost-v2`, branch `feat/cost-v2`. +- Engine plugin rules (CLAUDE.md): credentials via config `apikey: "${{ secrets.NP_API_KEY }}"`, never `ctx.secrets` as primary; composite waits use the two-phase protocol (`IStepResult.wait` + `ctx.resume`), never `ctx.helpers.waitForSignal()`; no `Date.now()`/`Math.random()` (use `ctx.helpers.uuid()`); `validateConfig()` runs before `execute()`; descriptors declare `outputPorts` statically; only the `default` port is wired in YAML. +- Runner output contract (spec 4.1): progress on stderr, exactly one JSON document on stdout, per-call cap `maxResultBytes` default 300 KB. +- `execution_config.retry.max_attempts` sent by the plugin defaults to 1 (spec 4.2). +- Async dispatch URL: `POST {baseUrl}/controlplane/agent_command`; callback URL: `{callbackBaseUrl}/workflows/webhooks/callback/{executionId}/{signalName}`; signal name `package-call.{stepId}`; correlation key `executionId`. +- Before every engine commit: `pnpm -w run lint`, `pnpm --filter @nullplatform/workflow-core build`, `pnpm --filter @nullplatform/workflow-core test`. +- Before every workflows commit that touches YAML: `npx np-workflow validate ` and `npx vitest finops`. +- The shell prints harmless `setValueForKeyFakeAssocArray ... _encode` noise on every command; ignore it. +- Commit messages end with `Co-Authored-By: Claude Fable 5.1 `. + +--- + +## File structure + +Engine (`workflow-system-demo/packages/core/src/plugins/built-in/np-package-call/`): + +| File | Responsibility | +|---|---| +| `descriptor.ts` | `NP_PACKAGE_CALL_ERROR_CODES`, config JSON schema, UI schema, descriptor object | +| `plugin.ts` | `NpPackageCallPlugin`: config merge, validation, sync execute, phase 1 dispatch, phase 2 resume | +| `index.ts` | re-exports | +| `__tests__/np-package-call.test.ts` | descriptor, validation, sync, phase 1, phase 2, error mapping | + +Engine touch points: `packages/core/src/plugins/built-in/index.ts` (add export line), `packages/core/src/plugins/bootstrap.ts` (import + register). + +Workflows (`workflows/.worktrees/cost-v2/finops/`): + +| File | Responsibility | +|---|---| +| `packages/cloud-query/src/callback.ts` | `postCallback(url, body, fetchImpl)` with retries; pure and testable | +| `packages/cloud-query/src/index.ts` | wire `callback` + `token` echo into the response | +| `packages/cloud-query/test/callback.test.ts` | retry/backoff/failure behavior | +| `packages/cloud-query/mise.toml` | `run` task on `:latest` with `NP_WORKER_PATCHES` | +| `packages/cloud-query/README.md` | request/response contract, local run | +| `tool-cloud-query.yaml` | reusable child workflow | +| `__tests__/tool-cloud-query.e2e.test.ts` | `runWorkflowE2E` with `np-package-call` mocked | +| `README.md` | suite overview (phase 0 scope only) | + +--- + +### Task 1: Runner callback POST (package) + +**Files:** +- Create: `finops/packages/cloud-query/src/callback.ts` +- Create: `finops/packages/cloud-query/test/callback.test.ts` +- Modify: `finops/packages/cloud-query/src/runner.ts` (add `callback` + `token` to `CloudQueryRequest`/`CloudQueryResponse`) +- Modify: `finops/packages/cloud-query/src/index.ts` + +**Interfaces:** +- Produces: `postCallback(url: string, body: unknown, opts?: { fetchImpl?: typeof fetch; attempts?: number; timeoutMs?: number; sleep?: (ms: number) => Promise }): Promise<{ ok: true; status: number } | { ok: false; error: string }>` +- Produces: request fields `callback?: { url: string; token?: string }`; response field `token?: string`. + +- [ ] **Step 1: Write the failing tests** + +```ts +// finops/packages/cloud-query/test/callback.test.ts +import { describe, expect, test } from "bun:test"; +import { postCallback } from "../src/callback"; + +function fakeFetch(responses: Array) { + const calls: Array<{ url: string; init: RequestInit }> = []; + const fetchImpl = (async (url: string, init: RequestInit) => { + calls.push({ url, init }); + const next = responses.shift(); + if (next instanceof Error) throw next; + return new Response("ok", { status: next ?? 200 }); + }) as unknown as typeof fetch; + return { fetchImpl, calls }; +} +const noSleep = async () => {}; + +describe("postCallback", () => { + test("POSTs JSON once on 2xx", async () => { + const { fetchImpl, calls } = fakeFetch([200]); + const r = await postCallback("http://cb/x", { a: 1 }, { fetchImpl, sleep: noSleep }); + expect(r).toEqual({ ok: true, status: 200 }); + expect(calls.length).toBe(1); + expect(calls[0]!.init.method).toBe("POST"); + expect((calls[0]!.init.headers as Record)["Content-Type"]).toBe("application/json"); + expect(calls[0]!.init.body).toBe(JSON.stringify({ a: 1 })); + }); + test("retries on 5xx and network errors, then succeeds", async () => { + const { fetchImpl, calls } = fakeFetch([503, new Error("ECONNRESET"), 200]); + const r = await postCallback("http://cb/x", {}, { fetchImpl, sleep: noSleep, attempts: 3 }); + expect(r.ok).toBe(true); + expect(calls.length).toBe(3); + }); + test("does not retry on 4xx", async () => { + const { fetchImpl, calls } = fakeFetch([404]); + const r = await postCallback("http://cb/x", {}, { fetchImpl, sleep: noSleep }); + expect(r).toEqual({ ok: false, error: "callback returned HTTP 404" }); + expect(calls.length).toBe(1); + }); + test("gives up after attempts", async () => { + const { fetchImpl, calls } = fakeFetch([500, 500, 500]); + const r = await postCallback("http://cb/x", {}, { fetchImpl, sleep: noSleep, attempts: 3 }); + expect(r.ok).toBe(false); + expect(calls.length).toBe(3); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd finops/packages/cloud-query && PATH="$HOME/.local/share/mise/shims:$PATH" bun test test/callback.test.ts` +Expected: FAIL, `Cannot find module "../src/callback"`. + +- [ ] **Step 3: Implement `callback.ts`** + +```ts +// finops/packages/cloud-query/src/callback.ts +/** + * Push the runner response to the workflow engine's per-execution callback. + * The URL is an unguessable capability minted by the engine; the body carries + * the `token` the plugin issued so a forged POST cannot be mistaken for ours. + */ +export interface PostCallbackOptions { + fetchImpl?: typeof fetch; + attempts?: number; + timeoutMs?: number; + sleep?: (ms: number) => Promise; +} + +export type PostCallbackResult = { ok: true; status: number } | { ok: false; error: string }; + +export async function postCallback(url: string, body: unknown, opts: PostCallbackOptions = {}): Promise { + const fetchImpl = opts.fetchImpl ?? fetch; + const attempts = opts.attempts ?? 3; + const timeoutMs = opts.timeoutMs ?? 10_000; + const sleep = opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const payload = JSON.stringify(body); + let lastError = ""; + for (let i = 0; i < attempts; i++) { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + const res = await fetchImpl(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: payload, signal: ac.signal }); + if (res.ok) return { ok: true, status: res.status }; + lastError = `callback returned HTTP ${res.status}`; + if (res.status < 500) return { ok: false, error: lastError }; + } catch (err) { + lastError = `callback request failed: ${(err as Error).message}`; + } finally { + clearTimeout(timer); + } + if (i < attempts - 1) await sleep(1000 * (i + 1)); + } + return { ok: false, error: lastError }; +} +``` + +- [ ] **Step 4: Extend the request/response types in `runner.ts`** + +Add to `CloudQueryRequest`: + +```ts + /** Where to POST the response (engine per-execution callback). Optional. */ + callback?: { url: string; token?: string }; +``` + +Add to `CloudQueryResponse`: + +```ts + /** `callback.token` echoed back so the caller can verify provenance. */ + token?: string; + callbackDelivered?: boolean; +``` + +In `validateRequest`, after the calls loop: + +```ts + if (r.callback !== undefined) { + if (typeof r.callback !== "object" || typeof (r.callback as { url?: unknown }).url !== "string" || !/^https?:\/\//.test((r.callback as { url: string }).url)) { + return "callback.url must be an http(s) URL"; + } + } +``` + +- [ ] **Step 5: Wire the callback in `index.ts`** + +Replace the block that starts at `if ("arn" in identity) response.identity = identity;` with: + +```ts + if ("arn" in identity) response.identity = identity; + if (cq.callback?.token) response.token = cq.callback.token; + if (cq.callback) { + const cb = await postCallback(cq.callback.url, response); + response.callbackDelivered = cb.ok; + log(`[cloud-query] callback ${cb.ok ? `delivered (HTTP ${cb.status})` : `FAILED: ${cb.error}`}`); + if (!cb.ok) { + req.emit({ stdout: JSON.stringify(response) }); + return { success: false, errorCode: "CALLBACK_FAILED", error: cb.error, data: response }; + } + } + req.emit({ stdout: JSON.stringify(response) }); + const failed = response.calls.filter((c) => !c.ok); +``` + +and add `import { postCallback } from "./callback";` at the top. + +- [ ] **Step 6: Run all package tests** + +Run: `PATH="$HOME/.local/share/mise/shims:$PATH" bun test` +Expected: 11 pass (7 existing + 4 new), 0 fail. + +- [ ] **Step 7: Add a runner test for callback validation** + +Append to `test/runner.test.ts` inside `describe("validateRequest")`: + +```ts + test("validates callback url", () => { + const calls = [{ id: "a", service: "ce", operation: "GetCostAndUsage" }]; + expect(validateRequest({ calls, callback: { url: "ftp://x" } })).toContain("callback.url"); + expect(validateRequest({ calls, callback: { url: "http://host.docker.internal:3000/cb", token: "t" } })).toBeUndefined(); + }); +``` + +Run: `bun test` → 12 pass. + +- [ ] **Step 8: Commit** + +```bash +cd /Users/geisbruch/workspace/null/workflows/.worktrees/cost-v2 +git add finops/packages/cloud-query/src finops/packages/cloud-query/test +git commit -m "finops(cloud-query): POST the response to the engine callback with retries + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 2: Runner local-run task and README (package) + +**Files:** +- Modify: `finops/packages/cloud-query/mise.toml` (task `run`) +- Modify: `finops/packages/cloud-query/README.md` (replace template text) + +**Interfaces:** +- Consumes: nothing. +- Produces: `mise run run` starts the agent on `:latest` with `NP_WORKER_PATCHES` from `$NP_WORKER_AWS_ACCESS_KEY_ID` / `$NP_WORKER_AWS_SECRET_ACCESS_KEY` / `$NP_WORKER_AWS_REGION`. + +- [ ] **Step 1: Replace the `run` task in `mise.toml`** + +```toml +[tasks.run] +description = "Run locally: an agent (docker backend, :latest) that spawns the lean worker" +depends = ["build:image"] +run = """ +set -eu +: "${NP_API_KEY:?set NP_API_KEY}" +: "${NP_WORKER_AWS_ACCESS_KEY_ID:?set NP_WORKER_AWS_ACCESS_KEY_ID (dev only; in a cluster the pod's IAM role is used)}" +: "${NP_WORKER_AWS_SECRET_ACCESS_KEY:?set NP_WORKER_AWS_SECRET_ACCESS_KEY}" +region="${NP_WORKER_AWS_REGION:-us-east-1}" +patches=$(printf '[{"target":{"package":"cloud-query"},"merge":{"spec":{"containers":[{"name":"worker","env":[{"name":"AWS_ACCESS_KEY_ID","value":"%s"},{"name":"AWS_SECRET_ACCESS_KEY","value":"%s"},{"name":"AWS_REGION","value":"%s"}]}]}}}]' "$NP_WORKER_AWS_ACCESS_KEY_ID" "$NP_WORKER_AWS_SECRET_ACCESS_KEY" "$region") +docker rm -f np-cloud-query-agent >/dev/null 2>&1 || true +docker rm -f "np-worker-$(docker info --format '{{.Name}}')-cloud-query" >/dev/null 2>&1 || true +docker run -d --name np-cloud-query-agent --network host \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e NP_API_KEY -e NP_LOG_LEVEL="${NP_LOG_LEVEL:-INFO}" \ + -e NP_WORKER_BACKEND=docker \ + -e NP_WORKER_IMAGE=cloud-query-worker:dev \ + -e NP_WORKER_PATCHES="$patches" \ + "${NP_AGENT_IMAGE:-public.ecr.aws/nullplatform/controlplane-agent:latest}" \ + -runtime=host -tags=package:cloud-query,local:"${NP_LOCAL_USER:-$USER}",env:local >/dev/null +echo "agent started; follow with: docker logs -f np-cloud-query-agent" >&2 +""" +``` + +- [ ] **Step 2: Write `README.md`** + +```markdown +# cloud-query + +Generic cloud SDK call runner, shipped as a nullplatform **package** (`simple` +type). A workflow sends a list of SDK calls; the worker runs them with the +credentials of the pod/container it runs in (IAM role in a cluster) and returns +the raw responses. It carries no cost semantics. + +## Request (`NP_ACTION_CONTEXT.cloud_query`) + +| Field | Type | Notes | +|---|---|---| +| `provider` | `"aws"` | only AWS for now | +| `region` | string | default `AWS_REGION` of the worker, else `us-east-1` | +| `assumeRole` | `{ roleArn, sessionName?, externalId? }` | optional STS AssumeRole before the calls | +| `calls[]` | `{ id, service, operation, params?, paginate?, maxPages? }` | `service` in `ce`, `cost-explorer`, `cloudwatch`, `ec2`, `sts`; `operation` PascalCase SDK command | +| `maxResultBytes` | number | per-call cap, default 307200 | +| `callback` | `{ url, token? }` | POST the response here (engine callback); `token` is echoed | + +## Response + +`{ provider, region, identity?, token?, callbackDelivered?, calls: [{ id, ok, pages?, durationMs, result?, errorCode?, error? }] }` + +Output contract: progress on **stderr**, the response JSON alone on **stdout** +(the control plane exposes a command's stdout, not the gRPC `data`). Keep +results under the cap: Cost Explorer daily grouped by SERVICE+USAGE_TYPE for +14 days is ~360 KB; larger windows must be split by the caller. + +## Local run + +```bash +export NP_API_KEY=... # org API key (the agent registers with it) +export NP_WORKER_AWS_ACCESS_KEY_ID=... NP_WORKER_AWS_SECRET_ACCESS_KEY=... +mise run run # builds the image, starts the agent with tags package:cloud-query,local:$USER +``` + +Dispatch by hand (sync, small): + +```bash +curl -s -X POST https://api.nullplatform.com/controlplane/agent_command \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"selector":{"package":"cloud-query","local":"'$USER'"},"execution_config":{"retry":{"max_attempts":1}}, + "command":{"type":"package-exec","data":{"package":"cloud-query","environment":{"NP_ACTION_CONTEXT":"{\"cloud_query\":{\"calls\":[{\"id\":\"who\",\"service\":\"sts\",\"operation\":\"GetCallerIdentity\"}]}}"}}}}' +``` + +## Tests + +`mise run test` (bun). No live AWS in tests. +``` + +- [ ] **Step 3: Verify the task starts the agent** + +Run (with the kwik key and the `kwik_admin` AWS keys exported as the three `NP_WORKER_*` vars): `PATH="$HOME/.local/share/mise/shims:$PATH" mise run run` then `docker logs np-cloud-query-agent | grep -m1 'Successfully connected'`. +Expected: the line appears within 10 s. + +- [ ] **Step 4: Commit** + +```bash +git add finops/packages/cloud-query/mise.toml finops/packages/cloud-query/README.md +git commit -m "finops(cloud-query): local run on agent :latest with worker patches; README + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 3: Engine worktree + `np-package-call` descriptor and config validation + +**Files:** +- Create: `packages/core/src/plugins/built-in/np-package-call/descriptor.ts` +- Create: `packages/core/src/plugins/built-in/np-package-call/plugin.ts` (validation only in this task) +- Create: `packages/core/src/plugins/built-in/np-package-call/index.ts` +- Create: `packages/core/src/plugins/built-in/np-package-call/__tests__/np-package-call.test.ts` + +**Interfaces:** +- Produces: `NpPackageCallConfig`, `NP_PACKAGE_CALL_ERROR_CODES`, `npPackageCallDescriptor`, `NpPackageCallPlugin` (`configure`, `validateConfig`, `execute`, `destroy`). + +- [ ] **Step 1: Create the engine worktree** + +```bash +cd /Users/geisbruch/workspace/null/workflow-system-demo +git check-ignore -q .worktrees || (echo ".worktrees/" >> .gitignore && git add .gitignore && git commit -m "infra: ignore .worktrees" ) +git fetch -q origin +git worktree add .worktrees/np-package-call -b feat/np-package-call origin/main +cd .worktrees/np-package-call && pnpm install --frozen-lockfile 2>&1 | tail -2 +pnpm --filter @nullplatform/workflow-core test -- np-agent-command 2>&1 | tail -5 +``` +Expected: install ok, np-agent-command tests pass (baseline). + +- [ ] **Step 2: Write the failing descriptor/validation tests** + +```ts +// packages/core/src/plugins/built-in/np-package-call/__tests__/np-package-call.test.ts +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IStepExecutionContext, IStepResumeContext } from '@nullplatform/workflow-sdk'; +import { makeStepContext } from '../../__tests__/context-fixture.js'; +import { __clearTokenCacheForTests } from '../../np-agent-command/auth.js'; +import { NP_PACKAGE_CALL_ERROR_CODES } from '../descriptor.js'; +import { NpPackageCallPlugin } from '../plugin.js'; + +const fetchMock = vi.fn(); +beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); + __clearTokenCacheForTests(); +}); +afterEach(() => vi.unstubAllGlobals()); + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} +const TOKEN_RESPONSE = { access_token: 'jwt', token_expires_at: 4102444800000 }; + +function baseConfig(overrides: Record = {}): Record { + return { + apikey: 'apikey-1', + agent_selector: { tags: { package: 'cloud-query', local: 'gabriel' } }, + package: 'cloud-query', + action_context: { cloud_query: { calls: [{ id: 'who', service: 'sts', operation: 'GetCallerIdentity' }] } }, + ...overrides, + }; +} +function phase1Ctx(): IStepExecutionContext { + const { ctx } = makeStepContext({ executionId: 'ex_1', stepId: 'query' }); + return { ...ctx, helpers: { ...ctx.helpers, uuid: () => 'tok_1' } }; +} +function phase2Ctx(resume: IStepResumeContext): IStepExecutionContext { + const { ctx } = makeStepContext({ executionId: 'ex_1', stepId: 'query' }); + return { ...ctx, resume }; +} + +describe('np-package-call — descriptor & config', () => { + it('declares a single default port and the awaits-signal capability', () => { + const p = new NpPackageCallPlugin(); + expect(p.descriptor.name).toBe('np-package-call'); + expect(p.descriptor.outputPorts?.map((x) => x.name)).toEqual(['default']); + expect(p.descriptor.capabilities).toContain('awaits-signal'); + expect(p.descriptor.executeMode).toBe('all'); + }); + it('accepts a good config and rejects missing package / selector / bad mode', () => { + const ok = new NpPackageCallPlugin(); + ok.configure(baseConfig({ mode: 'sync', timeout: '10m' })); + expect(ok.validateConfig().valid).toBe(true); + + const noPkg = new NpPackageCallPlugin(); + noPkg.configure(baseConfig({ package: '' })); + expect(noPkg.validateConfig().valid).toBe(false); + + const noSel = new NpPackageCallPlugin(); + noSel.configure(baseConfig({ agent_selector: { tags: {} } })); + expect(noSel.validateConfig().valid).toBe(false); + + const badMode = new NpPackageCallPlugin(); + badMode.configure(baseConfig({ mode: 'later' })); + expect(badMode.validateConfig().valid).toBe(false); + }); +}); +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `pnpm --filter @nullplatform/workflow-core test -- np-package-call` +Expected: FAIL, cannot resolve `../descriptor.js`. + +- [ ] **Step 4: Write `descriptor.ts`** + +```ts +// packages/core/src/plugins/built-in/np-package-call/descriptor.ts +/** + * `np-package-call` — call a nullplatform PACKAGE (a worker image the agent + * spawns) through `POST /controlplane/agent_command` `package-exec`. + * + * Async mode (default) is the two-phase wait protocol: phase 1 dispatches with + * `execution_config.async: true` and returns `IStepResult.wait`; the worker + * POSTs its result to the engine's per-execution callback URL, which resumes + * phase 2. Sync mode is a one-shot call for small, fast requests (the platform + * cuts sync calls at 60 s and drops completions above ~400 KB). + */ +import type { IPluginDescriptor } from '@nullplatform/workflow-sdk'; + +export const NP_PACKAGE_CALL_ERROR_CODES = { + NOT_CONFIGURED: 'NP_PACKAGE_CALL_NOT_CONFIGURED', + TOKEN_EXCHANGE_FAILED: 'NP_PACKAGE_CALL_TOKEN_EXCHANGE_FAILED', + DISPATCH_FAILED: 'NP_PACKAGE_CALL_DISPATCH_FAILED', + AGENT_NOT_FOUND: 'NP_PACKAGE_CALL_AGENT_NOT_FOUND', + BAD_RUNNER_OUTPUT: 'NP_PACKAGE_CALL_BAD_RUNNER_OUTPUT', + CALLBACK_TOKEN_MISMATCH: 'NP_PACKAGE_CALL_CALLBACK_TOKEN_MISMATCH', + CALLS_FAILED: 'NP_PACKAGE_CALL_CALLS_FAILED', + TIMEOUT: 'NP_PACKAGE_CALL_TIMEOUT', +} as const; +export type NpPackageCallErrorCode = (typeof NP_PACKAGE_CALL_ERROR_CODES)[keyof typeof NP_PACKAGE_CALL_ERROR_CODES]; + +export const NP_PACKAGE_CALL_MODES = ['async', 'sync'] as const; +export type NpPackageCallMode = (typeof NP_PACKAGE_CALL_MODES)[number]; + +export const DEFAULT_CALLBACK_BASE_URL = 'https://api.nullplatform.com'; +export const DEFAULT_CALLBACK_KEY = 'cloud_query.callback'; +export const DEFAULT_WAIT_TIMEOUT = '30m'; + +const configSchema = { + type: 'object', + required: ['agent_selector', 'package', 'action_context'], + properties: { + apikey: { type: 'string', title: 'API key', description: 'Org API key, from a config entry: "${{ secrets.NP_API_KEY }}".' }, + base_url: { type: 'string', title: 'Platform base URL', default: 'https://api.nullplatform.com' }, + agent_selector: { + type: 'object', + title: 'Agent selector', + required: ['tags'], + properties: { + nrn: { type: 'string', description: 'Restrict matching agents to this NRN subtree.' }, + tags: { type: 'object', additionalProperties: { type: 'string' }, description: 'Subset match on agent tags, e.g. {"package":"cloud-query"}. Never pin an agent id: ids change on restart.' }, + }, + }, + package: { type: 'string', title: 'Package slug', description: 'e.g. cloud-query' }, + version: { type: 'string', title: 'Package version (semver, optional)' }, + action_context: { type: 'object', title: 'Action context', description: 'JSON handed to the worker as NP_ACTION_CONTEXT.' }, + mode: { type: 'string', enum: [...NP_PACKAGE_CALL_MODES], default: 'async', title: 'Mode' }, + callback_base_url: { type: 'string', default: DEFAULT_CALLBACK_BASE_URL, title: 'Engine base URL for the callback', description: 'The worker must reach this URL. Locally: http://host.docker.internal:3000.' }, + callback_key: { type: 'string', default: DEFAULT_CALLBACK_KEY, title: 'Dotted path inside action_context where {url, token} is written' }, + timeout: { type: 'string', default: DEFAULT_WAIT_TIMEOUT, title: 'Wait timeout (async)', description: 'Duration string, e.g. "30m". On timeout the step FAILS; route with error_handling.fallback_step.' }, + retry_max_attempts: { type: 'integer', minimum: 1, default: 1, title: 'Platform re-delivery attempts', description: 'execution_config.retry.max_attempts. Keep 1: a re-delivered package-exec re-runs the calls.' }, + timeout_seconds: { type: 'integer', minimum: 1, title: 'HTTP timeout for the dispatch call', description: 'Default 30 (async) / 120 (sync).' }, + }, +} as const; + +const configUiSchema = { + type: 'VerticalLayout', + elements: [ + { type: 'Control', scope: '#/properties/package' }, + { type: 'Control', scope: '#/properties/version' }, + { type: 'Control', scope: '#/properties/agent_selector' }, + { type: 'Control', scope: '#/properties/mode', options: { format: 'select' } }, + { type: 'Control', scope: '#/properties/action_context', options: { format: 'json' } }, + { type: 'Control', scope: '#/properties/apikey', options: { format: 'password' } }, + { + type: 'Group', label: 'Advanced', + elements: [ + { type: 'Control', scope: '#/properties/callback_base_url' }, + { type: 'Control', scope: '#/properties/callback_key' }, + { type: 'Control', scope: '#/properties/timeout' }, + { type: 'Control', scope: '#/properties/retry_max_attempts' }, + { type: 'Control', scope: '#/properties/timeout_seconds' }, + { type: 'Control', scope: '#/properties/base_url' }, + ], + }, + ], +} as const; + +export const npPackageCallDescriptor: IPluginDescriptor = { + name: 'np-package-call', + version: '1.0.0', + displayName: 'NP Package Call', + description: 'Run a nullplatform package (agent-spawned worker) with an arbitrary action context; async with callback, or sync for small calls.', + semanticDescription: + 'Dispatches package-exec to an agent selected by tags. Async mode returns a wait and resumes when the worker POSTs its result to the engine callback URL injected into the action context. Use for cloud billing/SDK queries (cloud-query) and any custom command handler package.', + category: 'nullplatform', + icon: 'package', + tags: ['nullplatform', 'agent', 'package', 'finops'], + executeMode: 'all', + configSchema, + configUiSchema, + inputPorts: [{ name: 'default', displayName: 'Input' }], + outputPorts: [{ name: 'default', displayName: 'Result' }], + capabilities: ['awaits-signal'], + previewTemplate: '{{ config.package }} ({{ config.mode | default: "async" }})', + outputSchema: { + type: 'object', + properties: { + commandId: { type: 'string' }, + agentId: { type: 'string' }, + response: { description: 'The worker response (cloud-query: {calls[], identity}).' }, + calls: { type: 'array' }, + failed: { type: 'array', items: { type: 'string' }, description: 'Ids of calls with ok=false.' }, + }, + }, + examples: [ + { + name: 'Cost Explorer by service (async)', + description: 'One day of AWS cost grouped by service through the cloud-query package.', + config: { + apikey: '${{ secrets.NP_API_KEY }}', + agent_selector: { tags: { package: 'cloud-query' } }, + package: 'cloud-query', + action_context: { + cloud_query: { + calls: [{ id: 'by_service', service: 'ce', operation: 'GetCostAndUsage', params: { TimePeriod: { Start: '2026-09-01', End: '2026-09-02' }, Granularity: 'DAILY', Metrics: ['UnblendedCost'], GroupBy: [{ Type: 'DIMENSION', Key: 'SERVICE' }] } }], + }, + }, + }, + }, + { + name: 'Who am I (sync)', + config: { apikey: '${{ secrets.NP_API_KEY }}', agent_selector: { tags: { package: 'cloud-query' } }, package: 'cloud-query', mode: 'sync', action_context: { cloud_query: { calls: [{ id: 'who', service: 'sts', operation: 'GetCallerIdentity' }] } } }, + }, + ], + documentation: `## np-package-call + +Runs a package on an agent. **Async** (default): the step waits until the worker POSTs to +\`{callback_base_url}/workflows/webhooks/callback/{executionId}/package-call.{stepId}\`. The plugin injects +\`{url, token}\` at \`callback_key\` inside \`action_context\`; the worker must echo \`token\` in its response body. +**Sync**: one-shot; the response is parsed from the command's stdout. Keep sync calls small and fast. + +Select agents by **tags**, never by id. Timeouts fail the step: wire \`error_handling.fallback_step\`.`, +}; +``` + +- [ ] **Step 5: Write `plugin.ts` (config + validation only)** + +```ts +// packages/core/src/plugins/built-in/np-package-call/plugin.ts +import { ExtensionMetadata } from '@nullplatform/workflow-sdk'; +import type { + IModulePlugin, + IPluginDescriptor, + IStepExecutionContext, + IStepResult, + IValidationResult, +} from '@nullplatform/workflow-sdk'; +import { DEFAULT_NP_API_BASE_URL, TokenExchangeError, getAccessToken } from '../np-agent-command/auth.js'; +import { + DEFAULT_CALLBACK_BASE_URL, + DEFAULT_CALLBACK_KEY, + DEFAULT_WAIT_TIMEOUT, + NP_PACKAGE_CALL_ERROR_CODES, + NP_PACKAGE_CALL_MODES, + type NpPackageCallMode, + npPackageCallDescriptor, +} from './descriptor.js'; + +export interface NpPackageCallConfig { + apikey?: string; + base_url?: string; + agent_selector?: { nrn?: string; tags?: Record }; + package?: string; + version?: string; + action_context?: Record; + mode?: NpPackageCallMode; + callback_base_url?: string; + callback_key?: string; + timeout?: string; + retry_max_attempts?: number; + timeout_seconds?: number; +} + +export class NpPackageCallPlugin implements IModulePlugin { + readonly descriptor: IPluginDescriptor = npPackageCallDescriptor; + readonly extensions = new ExtensionMetadata(); + #config: NpPackageCallConfig | undefined; + + configure(config: Record): void { + this.#config = config as NpPackageCallConfig; + } + + validateConfig(): IValidationResult { + const cfg = this.#config ?? {}; + const errors: IValidationResult['errors'] = []; + if (typeof cfg.package !== 'string' || cfg.package.length === 0) { + errors.push({ path: 'package', message: 'package is required', code: NP_PACKAGE_CALL_ERROR_CODES.NOT_CONFIGURED }); + } + const tags = cfg.agent_selector?.tags; + if (!tags || typeof tags !== 'object' || Object.keys(tags).length === 0) { + errors.push({ path: 'agent_selector.tags', message: 'agent_selector.tags must have at least one tag', code: NP_PACKAGE_CALL_ERROR_CODES.NOT_CONFIGURED }); + } + if (cfg.action_context !== undefined && (typeof cfg.action_context !== 'object' || cfg.action_context === null)) { + errors.push({ path: 'action_context', message: 'action_context must be an object', code: NP_PACKAGE_CALL_ERROR_CODES.NOT_CONFIGURED }); + } + if (cfg.mode !== undefined && !(NP_PACKAGE_CALL_MODES as readonly string[]).includes(cfg.mode)) { + errors.push({ path: 'mode', message: `mode must be one of ${NP_PACKAGE_CALL_MODES.join(', ')}`, code: NP_PACKAGE_CALL_ERROR_CODES.NOT_CONFIGURED }); + } + if (cfg.retry_max_attempts !== undefined && (!Number.isInteger(cfg.retry_max_attempts) || cfg.retry_max_attempts < 1)) { + errors.push({ path: 'retry_max_attempts', message: 'retry_max_attempts must be an integer >= 1', code: NP_PACKAGE_CALL_ERROR_CODES.NOT_CONFIGURED }); + } + return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [] }; + } + + destroy(): void { + this.#config = undefined; + } + + async execute(ctx: IStepExecutionContext): Promise { + return failure('not implemented', NP_PACKAGE_CALL_ERROR_CODES.NOT_CONFIGURED, false); + } +} + +export function failure(message: string, code: string, retryable: boolean, details?: Record): IStepResult { + return { status: 'failure', error: { message, code, retryable, ...(details ? { details } : {}) } }; +} + +// re-exported for the tests and the next task +export { DEFAULT_NP_API_BASE_URL, TokenExchangeError, getAccessToken, DEFAULT_CALLBACK_BASE_URL, DEFAULT_CALLBACK_KEY, DEFAULT_WAIT_TIMEOUT }; +``` + +Note: `IValidationResult['errors']` entries in this codebase are `{ path, message, code }` (see `np-action-item-wait/plugin.ts`); if the type differs, match `slack-ask/plugin.ts`. + +- [ ] **Step 6: Write `index.ts`** + +```ts +export { NpPackageCallPlugin, type NpPackageCallConfig } from './plugin.js'; +export { + npPackageCallDescriptor, + NP_PACKAGE_CALL_ERROR_CODES, + NP_PACKAGE_CALL_MODES, + type NpPackageCallErrorCode, + type NpPackageCallMode, +} from './descriptor.js'; +``` + +- [ ] **Step 7: Run the tests** + +Run: `pnpm --filter @nullplatform/workflow-core test -- np-package-call` +Expected: 2 pass. + +- [ ] **Step 8: Commit** + +```bash +git add packages/core/src/plugins/built-in/np-package-call +git commit -m "core: np-package-call descriptor + config validation + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 4: `np-package-call` sync mode + +**Files:** +- Modify: `packages/core/src/plugins/built-in/np-package-call/plugin.ts` +- Modify: `__tests__/np-package-call.test.ts` + +**Interfaces:** +- Consumes: `getAccessToken({ apikey, baseUrl })` → `{ token }` (from `np-agent-command/auth.ts`; check the `ExchangedToken` field name with `grep -n "interface ExchangedToken" -A6 auth.ts`, it is `token`). +- Produces: `dispatch(ctx, cfg, body)` internal; outputs `{ commandId, agentId, response, calls, failed, mode: 'sync' }`. + +- [ ] **Step 1: Write the failing sync tests** + +Append to the test file: + +```ts +describe('np-package-call — sync', () => { + it('dispatches package-exec and parses the runner JSON from stdOut', async () => { + fetchMock + .mockResolvedValueOnce(json(TOKEN_RESPONSE)) + .mockResolvedValueOnce(json({ type: 'completed', executions: [{ commandId: 'c1', agentId: 'a1', status: 'success', results: { stdOut: JSON.stringify({ provider: 'aws', calls: [{ id: 'who', ok: true, result: { Account: '1' } }] }), stdErr: 'progress', exitCode: 0 }}] })); + const p = new NpPackageCallPlugin(); + p.configure(baseConfig({ mode: 'sync' })); + const r = await p.execute(phase1Ctx()); + expect(r.status).toBe('success'); + expect(r.wait).toBeUndefined(); + expect(r.outputs).toMatchObject({ commandId: 'c1', agentId: 'a1', failed: [], mode: 'sync' }); + expect((r.outputs?.response as { calls: unknown[] }).calls).toHaveLength(1); + + const [, dispatch] = fetchMock.mock.calls as Array<[string, RequestInit]>; + expect(dispatch![0]).toBe('https://api.nullplatform.com/controlplane/agent_command'); + const body = JSON.parse(String(dispatch![1].body)); + expect(body.selector).toEqual({ package: 'cloud-query', local: 'gabriel' }); + expect(body.execution_config).toEqual({ retry: { max_attempts: 1 } }); + expect(body.command.type).toBe('package-exec'); + expect(body.command.data.package).toBe('cloud-query'); + expect(JSON.parse(body.command.data.environment.NP_ACTION_CONTEXT)).toEqual(baseConfig().action_context); + }); + it('fails with CALLS_FAILED when a call is not ok, keeping the response in outputs', async () => { + fetchMock + .mockResolvedValueOnce(json(TOKEN_RESPONSE)) + .mockResolvedValueOnce(json({ executions: [{ commandId: 'c1', agentId: 'a1', status: 'success', results: { stdOut: JSON.stringify({ calls: [{ id: 'x', ok: false, errorCode: 'RESULT_TOO_LARGE', error: 'big' }] }), stdErr: '', exitCode: 1 }}] })); + const p = new NpPackageCallPlugin(); + p.configure(baseConfig({ mode: 'sync' })); + const r = await p.execute(phase1Ctx()); + expect(r.status).toBe('failure'); + expect(r.error?.code).toBe(NP_PACKAGE_CALL_ERROR_CODES.CALLS_FAILED); + expect(r.error?.retryable).toBe(false); + expect(r.outputs).toMatchObject({ failed: ['x'] }); + }); + it('maps a non-JSON stdOut to BAD_RUNNER_OUTPUT and a platform error to DISPATCH_FAILED', async () => { + fetchMock + .mockResolvedValueOnce(json(TOKEN_RESPONSE)) + .mockResolvedValueOnce(json({ executions: [{ commandId: 'c1', agentId: 'a1', status: 'success', results: { stdOut: 'not json', stdErr: '', exitCode: 0 }}] })); + const p = new NpPackageCallPlugin(); + p.configure(baseConfig({ mode: 'sync' })); + expect((await p.execute(phase1Ctx())).error?.code).toBe(NP_PACKAGE_CALL_ERROR_CODES.BAD_RUNNER_OUTPUT); + + fetchMock + .mockResolvedValueOnce(json({ type: 'error', executions: [{ commandId: 'unknown', agentId: 'unknown', status: 'error', data: { error: 'No agents found for selector' } }] }, 500)); + const q = new NpPackageCallPlugin(); + q.configure(baseConfig({ mode: 'sync' })); + const r = await q.execute(phase1Ctx()); + expect(r.error?.code).toBe(NP_PACKAGE_CALL_ERROR_CODES.AGENT_NOT_FOUND); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm --filter @nullplatform/workflow-core test -- np-package-call` +Expected: 3 new tests FAIL ("not implemented"). + +- [ ] **Step 3: Implement the shared dispatch + sync path** + +Replace `execute` and add helpers in `plugin.ts`: + +```ts +interface AgentExecution { + commandId?: string; + agentId?: string; + status?: string; + error?: string; + data?: { error?: string }; + results?: { stdOut?: string; stdErr?: string; exitCode?: number }; +} +interface AgentCommandResponse { + type?: string; + error?: string; + executions?: AgentExecution[]; +} + +export interface RunnerResponse { + calls?: Array<{ id: string; ok: boolean; [k: string]: unknown }>; + token?: string; + [k: string]: unknown; +} + +function mergeConfig(cfg: NpPackageCallConfig, inputs: Record): NpPackageCallConfig { + // Whitelisted runtime overrides (so YAML can pass action_context/package via inputs). + const out: NpPackageCallConfig = { ...cfg }; + if (inputs.action_context && typeof inputs.action_context === 'object') out.action_context = inputs.action_context as Record; + if (typeof inputs.package === 'string') out.package = inputs.package; + if (typeof inputs.version === 'string') out.version = inputs.version; + if (inputs.agent_selector && typeof inputs.agent_selector === 'object') out.agent_selector = inputs.agent_selector as NpPackageCallConfig['agent_selector']; + if (typeof inputs.mode === 'string') out.mode = inputs.mode as NpPackageCallMode; + return out; +} + +async function resolveApikey(cfg: NpPackageCallConfig, ctx: IStepExecutionContext): Promise { + if (typeof cfg.apikey === 'string' && cfg.apikey.length > 0) return cfg.apikey; + const fromSecrets = await ctx.secrets.get('NP_API_KEY'); + if (typeof fromSecrets === 'string' && fromSecrets.length > 0) return fromSecrets; + throw new Error("apikey not configured: set config.apikey to '${{ secrets.NP_API_KEY }}'"); +} + +/** Set a dotted path inside a (cloned) object: setPath({a:{}}, 'a.b', 1) → {a:{b:1}}. */ +export function setPath(obj: Record, path: string, value: unknown): Record { + const clone = JSON.parse(JSON.stringify(obj)) as Record; + const parts = path.split('.'); + let cur: Record = clone; + for (let i = 0; i < parts.length - 1; i++) { + const k = parts[i]!; + if (typeof cur[k] !== 'object' || cur[k] === null) cur[k] = {}; + cur = cur[k] as Record; + } + cur[parts[parts.length - 1]!] = value; + return clone; +} + +function classifyPlatformError(message: string): string { + return /no agents?|agent not found|not connected/i.test(message) + ? NP_PACKAGE_CALL_ERROR_CODES.AGENT_NOT_FOUND + : NP_PACKAGE_CALL_ERROR_CODES.DISPATCH_FAILED; +} + +async function dispatch( + ctx: IStepExecutionContext, + cfg: NpPackageCallConfig, + actionContext: Record, + async: boolean, +): Promise<{ ok: true; exec: AgentExecution } | { ok: false; result: IStepResult }> { + const baseUrl = (cfg.base_url ?? DEFAULT_NP_API_BASE_URL).replace(/\/+$/, ''); + let token: string; + try { + const apikey = await resolveApikey(cfg, ctx); + token = (await getAccessToken({ apikey, baseUrl })).token; + } catch (err) { + const retryable = err instanceof TokenExchangeError ? err.retryable : false; + return { ok: false, result: failure((err as Error).message, NP_PACKAGE_CALL_ERROR_CODES.TOKEN_EXCHANGE_FAILED, retryable) }; + } + const body: Record = { + selector: cfg.agent_selector?.tags ?? {}, + ...(cfg.agent_selector?.nrn ? { nrn: cfg.agent_selector.nrn } : {}), + execution_config: { ...(async ? { async: true } : {}), retry: { max_attempts: cfg.retry_max_attempts ?? 1 } }, + command: { + type: 'package-exec', + data: { + package: cfg.package, + ...(cfg.version ? { version: cfg.version } : {}), + environment: { NP_ACTION_CONTEXT: JSON.stringify(actionContext) }, + }, + }, + }; + const timeoutMs = (cfg.timeout_seconds ?? (async ? 30 : 120)) * 1000; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const onAbort = () => controller.abort(); + ctx.signal.addEventListener('abort', onAbort, { once: true }); + let status = 0; + let text = ''; + try { + const resp = await fetch(`${baseUrl}/controlplane/agent_command`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: controller.signal, + }); + status = resp.status; + text = await resp.text(); + } catch (err) { + return { ok: false, result: failure(`agent_command request failed: ${(err as Error).message}`, NP_PACKAGE_CALL_ERROR_CODES.DISPATCH_FAILED, true) }; + } finally { + clearTimeout(timer); + ctx.signal.removeEventListener('abort', onAbort); + } + let parsed: AgentCommandResponse; + try { + parsed = JSON.parse(text) as AgentCommandResponse; + } catch { + return { ok: false, result: failure(`agent_command returned non-JSON (HTTP ${status}): ${text.slice(0, 200)}`, NP_PACKAGE_CALL_ERROR_CODES.DISPATCH_FAILED, status >= 500) }; + } + const exec = parsed.executions?.[0]; + if (status >= 400 || !exec || exec.status !== 'success') { + const message = exec?.data?.error ?? exec?.error ?? parsed.error ?? `agent_command HTTP ${status}`; + return { ok: false, result: failure(message, classifyPlatformError(message), false, { httpStatus: status, commandId: exec?.commandId ?? null }) }; + } + ctx.log.info('np-package-call.dispatched', { commandId: exec.commandId, agentId: exec.agentId, async }); + return { ok: true, exec }; +} + +function outputsFrom(exec: { commandId?: string; agentId?: string }, response: RunnerResponse, mode: NpPackageCallMode): { result: IStepResult } { + const calls = Array.isArray(response.calls) ? response.calls : []; + const failed = calls.filter((c) => c.ok === false).map((c) => c.id); + const outputs = { commandId: exec.commandId ?? null, agentId: exec.agentId ?? null, response, calls, failed, mode }; + if (failed.length > 0) { + return { + result: { + status: 'failure', + outputs, + error: { message: `calls failed: ${failed.join(', ')}`, code: NP_PACKAGE_CALL_ERROR_CODES.CALLS_FAILED, retryable: false, details: { failed } }, + }, + }; + } + return { result: { status: 'success', outputs, activePorts: ['default'] } }; +} +``` + +and the `execute` body: + +```ts + async execute(ctx: IStepExecutionContext): Promise { + const cfg = mergeConfig(this.#config ?? {}, ctx.inputs ?? {}); + const mode: NpPackageCallMode = cfg.mode ?? 'async'; + if (mode === 'sync') return this.#sync(ctx, cfg); + return failure('async not implemented', NP_PACKAGE_CALL_ERROR_CODES.NOT_CONFIGURED, false); + } + + async #sync(ctx: IStepExecutionContext, cfg: NpPackageCallConfig): Promise { + const d = await dispatch(ctx, cfg, cfg.action_context ?? {}, false); + if (!d.ok) return d.result; + const stdOut = d.exec.results?.stdOut ?? ''; + let response: RunnerResponse; + try { + response = JSON.parse(stdOut) as RunnerResponse; + } catch { + return failure(`runner stdout is not JSON: ${stdOut.slice(0, 200)}`, NP_PACKAGE_CALL_ERROR_CODES.BAD_RUNNER_OUTPUT, false, { stdErr: (d.exec.results?.stdErr ?? '').slice(0, 2000) }); + } + return outputsFrom(d.exec, response, 'sync').result; + } +``` + +- [ ] **Step 4: Run the tests** + +Run: `pnpm --filter @nullplatform/workflow-core test -- np-package-call` +Expected: 5 pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/plugins/built-in/np-package-call +git commit -m "core: np-package-call sync mode (package-exec one-shot, stdout JSON) + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 5: `np-package-call` async two-phase mode + +**Files:** +- Modify: `packages/core/src/plugins/built-in/np-package-call/plugin.ts` +- Modify: `__tests__/np-package-call.test.ts` + +**Interfaces:** +- Consumes: `setPath`, `dispatch`, `outputsFrom` from Task 4. +- Produces: phase 1 returns `wait: { signalName: 'package-call.', correlationKey: , timeout, onTimeout: 'continue', resumeState: { token, commandId, agentId } }`; phase 2 verifies `payload.token`. + +- [ ] **Step 1: Write the failing async tests** + +```ts +describe('np-package-call — async phase 1', () => { + it('injects the callback, dispatches with execution_config.async and returns a wait', async () => { + fetchMock + .mockResolvedValueOnce(json(TOKEN_RESPONSE)) + .mockResolvedValueOnce(json({ executions: [{ commandId: 'c1', agentId: 'a1', status: 'success' }] })); + const p = new NpPackageCallPlugin(); + p.configure(baseConfig({ callback_base_url: 'http://host.docker.internal:3000', timeout: '10m' })); + const r = await p.execute(phase1Ctx()); + expect(r.status).toBe('success'); + expect(r.wait).toEqual({ + signalName: 'package-call.query', + correlationKey: 'ex_1', + timeout: '10m', + onTimeout: 'continue', + resumeState: { token: 'tok_1', commandId: 'c1', agentId: 'a1' }, + }); + expect(r.outputs).toMatchObject({ commandId: 'c1', agentId: 'a1', dispatched: true }); + const body = JSON.parse(String((fetchMock.mock.calls[1] as [string, RequestInit])[1].body)); + expect(body.execution_config).toEqual({ async: true, retry: { max_attempts: 1 } }); + const actionCtx = JSON.parse(body.command.data.environment.NP_ACTION_CONTEXT); + expect(actionCtx.cloud_query.callback).toEqual({ + url: 'http://host.docker.internal:3000/workflows/webhooks/callback/ex_1/package-call.query', + token: 'tok_1', + }); + expect(actionCtx.cloud_query.calls).toHaveLength(1); + }); + it('fails without waiting when the platform rejects the dispatch', async () => { + fetchMock + .mockResolvedValueOnce(json(TOKEN_RESPONSE)) + .mockResolvedValueOnce(json({ type: 'error', executions: [{ commandId: 'unknown', agentId: 'unknown', status: 'error', data: { error: 'Command failed to start after all retry attempts' } }] }, 500)); + const p = new NpPackageCallPlugin(); + p.configure(baseConfig()); + const r = await p.execute(phase1Ctx()); + expect(r.status).toBe('failure'); + expect(r.wait).toBeUndefined(); + expect(r.error?.code).toBe(NP_PACKAGE_CALL_ERROR_CODES.DISPATCH_FAILED); + }); +}); + +describe('np-package-call — async phase 2', () => { + const RS = { token: 'tok_1', commandId: 'c1', agentId: 'a1' }; + it('accepts the callback payload with the right token and emits outputs', async () => { + const p = new NpPackageCallPlugin(); + p.configure(baseConfig()); + const payload = { token: 'tok_1', provider: 'aws', calls: [{ id: 'who', ok: true, result: { Account: '1' } }] }; + const r = await p.execute(phase2Ctx({ signal: { name: 'package-call.query', payload }, resumeState: RS, phase: 2 })); + expect(r.status).toBe('success'); + expect(r.wait).toBeUndefined(); + expect(r.outputs).toMatchObject({ commandId: 'c1', agentId: 'a1', failed: [], mode: 'async' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('rejects a payload with a wrong or missing token', async () => { + const p = new NpPackageCallPlugin(); + p.configure(baseConfig()); + const r = await p.execute(phase2Ctx({ signal: { name: 'package-call.query', payload: { token: 'forged', calls: [] } }, resumeState: RS, phase: 2 })); + expect(r.status).toBe('failure'); + expect(r.error?.code).toBe(NP_PACKAGE_CALL_ERROR_CODES.CALLBACK_TOKEN_MISMATCH); + expect(r.error?.retryable).toBe(false); + }); + it('fails with TIMEOUT on the __timeout__ envelope', async () => { + const p = new NpPackageCallPlugin(); + p.configure(baseConfig()); + const r = await p.execute(phase2Ctx({ signal: { name: '__timeout__' }, resumeState: RS, phase: 2 })); + expect(r.status).toBe('failure'); + expect(r.error?.code).toBe(NP_PACKAGE_CALL_ERROR_CODES.TIMEOUT); + }); + it('propagates CALLS_FAILED from the callback payload', async () => { + const p = new NpPackageCallPlugin(); + p.configure(baseConfig()); + const payload = { token: 'tok_1', calls: [{ id: 'big', ok: false, errorCode: 'RESULT_TOO_LARGE' }] }; + const r = await p.execute(phase2Ctx({ signal: { name: 'package-call.query', payload }, resumeState: RS, phase: 2 })); + expect(r.error?.code).toBe(NP_PACKAGE_CALL_ERROR_CODES.CALLS_FAILED); + expect(r.outputs).toMatchObject({ failed: ['big'] }); + }); +}); +``` + +Check the `IStepResumeContext` fields with `sed -n 300,330p packages/sdk/src/types/execution.ts`; if `phase` is not a field, drop it from the test objects. + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm --filter @nullplatform/workflow-core test -- np-package-call` +Expected: 6 new tests FAIL. + +- [ ] **Step 3: Implement both phases** + +Replace the `execute` method body and add the two phase methods: + +```ts + async execute(ctx: IStepExecutionContext): Promise { + const cfg = mergeConfig(this.#config ?? {}, ctx.inputs ?? {}); + const mode: NpPackageCallMode = cfg.mode ?? 'async'; + if (mode === 'sync') return this.#sync(ctx, cfg); + return ctx.resume === undefined ? this.#phase1(ctx, cfg) : this.#phase2(ctx); + } + + async #phase1(ctx: IStepExecutionContext, cfg: NpPackageCallConfig): Promise { + const signalName = `package-call.${ctx.stepId}`; + const token = ctx.helpers.uuid(); + const base = (cfg.callback_base_url ?? DEFAULT_CALLBACK_BASE_URL).replace(/\/+$/, ''); + const url = `${base}/workflows/webhooks/callback/${encodeURIComponent(ctx.executionId)}/${signalName}`; + const actionContext = setPath(cfg.action_context ?? {}, cfg.callback_key ?? DEFAULT_CALLBACK_KEY, { url, token }); + const d = await dispatch(ctx, cfg, actionContext, true); + if (!d.ok) return d.result; + return { + status: 'success', + outputs: { commandId: d.exec.commandId ?? null, agentId: d.exec.agentId ?? null, dispatched: true, mode: 'async' }, + wait: { + signalName, + correlationKey: ctx.executionId, + timeout: cfg.timeout ?? DEFAULT_WAIT_TIMEOUT, + onTimeout: 'continue', + resumeState: { token, commandId: d.exec.commandId ?? null, agentId: d.exec.agentId ?? null }, + }, + }; + } + + async #phase2(ctx: IStepExecutionContext): Promise { + const resume = ctx.resume!; + const rs = (resume.resumeState ?? {}) as { token?: string; commandId?: string | null; agentId?: string | null }; + if (resume.signal.name === '__timeout__') { + return failure('timed out waiting for the package callback', NP_PACKAGE_CALL_ERROR_CODES.TIMEOUT, false, { commandId: rs.commandId ?? null, agentId: rs.agentId ?? null }); + } + const payload = resume.signal.payload; + if (!payload || typeof payload !== 'object') { + return failure('callback payload is not an object', NP_PACKAGE_CALL_ERROR_CODES.BAD_RUNNER_OUTPUT, false); + } + const response = payload as RunnerResponse; + if (typeof rs.token !== 'string' || response.token !== rs.token) { + ctx.log.warn('np-package-call.callback-token-mismatch', { commandId: rs.commandId ?? null }); + return failure('callback token mismatch: ignoring payload', NP_PACKAGE_CALL_ERROR_CODES.CALLBACK_TOKEN_MISMATCH, false); + } + return outputsFrom({ commandId: rs.commandId ?? undefined, agentId: rs.agentId ?? undefined }, response, 'async').result; + } +``` + +`outputsFrom` (Task 4) accepts `commandId?: string`; passing `undefined` for `null` keeps the type happy. + +- [ ] **Step 4: Run the tests** + +Run: `pnpm --filter @nullplatform/workflow-core test -- np-package-call` +Expected: 11 pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/plugins/built-in/np-package-call +git commit -m "core: np-package-call async mode (two-phase wait, engine callback, per-dispatch token) + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 6: Register the plugin, lint, build, full core tests + +**Files:** +- Modify: `packages/core/src/plugins/built-in/index.ts` (add `export * from './np-package-call/index.js';` next to the `np-agent-command` line) +- Modify: `packages/core/src/plugins/bootstrap.ts` (import `NpPackageCallPlugin` in the alphabetical import block after `NpAgentCommandPlugin`; add `NpPackageCallPlugin,` to the registration array after `NpAgentCommandPlugin,`) + +- [ ] **Step 1: Write the failing registration test** + +Append to the test file: + +```ts +import { registerBuiltInPlugins } from '../../../bootstrap.js'; +import { PluginRegistry } from '../../../registry.js'; + +describe('np-package-call — registration', () => { + it('is registered by bootstrap', () => { + const registry = new PluginRegistry(); + registerBuiltInPlugins(registry); + expect(registry.get('np-package-call')).toBeDefined(); + }); +}); +``` + +Check the actual import paths and API with `grep -n "export function registerBuiltInPlugins" -A6 packages/core/src/plugins/bootstrap.ts` and `grep -rn "class PluginRegistry" packages/core/src/plugins/`; adapt the two imports and the lookup method name (`get`/`getDescriptor`/`has`) to what exists. If bootstrap needs more arguments, mirror an existing bootstrap test under `packages/core/src/plugins/__tests__/`. + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm --filter @nullplatform/workflow-core test -- np-package-call` +Expected: the registration test FAILS (plugin undefined). + +- [ ] **Step 3: Register** + +Apply the two edits listed under Files. + +- [ ] **Step 4: Lint, build, full test** + +```bash +pnpm -w run lint +pnpm --filter @nullplatform/workflow-core build +pnpm --filter @nullplatform/workflow-core test +pnpm tsx scripts/check-boundaries.ts +``` +Expected: 0 lint errors, build ok, all core tests pass, boundaries ok. Fix Biome formatting with `pnpm -w run format` if lint complains about formatting. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/plugins/built-in/index.ts packages/core/src/plugins/bootstrap.ts packages/core/src/plugins/built-in/np-package-call +git commit -m "core: register np-package-call built-in plugin + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 7: `finops/tool-cloud-query.yaml` + E2E test (workflows repo) + +**Files:** +- Create: `finops/tool-cloud-query.yaml` +- Create: `finops/__tests__/tool-cloud-query.e2e.test.ts` +- Create: `finops/README.md` + +**Interfaces:** +- Consumes: plugin `np-package-call` (inputs `action_context`, `agent_selector`, `mode`; outputs `response`, `calls`, `failed`). +- Produces: workflow id `finops_cloud_query`; inputs `agent_tags`, `calls`, `region?`, `assume_role_arn?`, `mode?`; outputs `results` (map by call id), `identity`, `failed`. + +- [ ] **Step 1: Write the workflow** + +```yaml +# finops/tool-cloud-query.yaml +# +# Reusable child: run a list of cloud SDK calls through the `cloud-query` +# package on an agent selected by tags, and return results keyed by call id. +# Async by default (np-package-call two-phase wait + engine callback); sync +# only for small, fast calls (<60 s, <300 KB). +id: finops_cloud_query +name: "FinOps — Cloud Query (tool)" +description: > + Runs cloud SDK calls (Cost Explorer, CloudWatch, EC2, STS) on a customer + agent through the cloud-query package and returns the raw responses keyed + by call id. Called by the FinOps collectors and usable as an agent tool. +path: "/finops" +semantic_version: 0.1.0 + +inputs: + agent_tags: + type: object + required: true + description: "Agent selector tags, e.g. {\"package\":\"cloud-query\"}" + calls: + type: array + required: true + description: "cloud-query calls: [{id, service, operation, params?, paginate?, maxPages?}]" + region: + type: string + required: false + description: "AWS region for regional services (Cost Explorer is global)" + assume_role_arn: + type: string + required: false + description: "Optional role the worker assumes before the calls" + mode: + type: string + required: false + description: "async (default) or sync" + +variables: + # The engine base URL the WORKER must reach to deliver the callback. + # Locally (docker worker → dev-server on the laptop): http://host.docker.internal:3000 + callback_base_url: + initialValue: "https://api.nullplatform.com" + +steps: + - id: start + type: trigger + plugin_type: manual + name: "Query" + config: + description: "Run cloud SDK calls through the cloud-query package." + inputs: + agent_tags: { type: object, required: true, description: "Agent selector tags" } + calls: { type: array, required: true, description: "cloud-query calls" } + region: { type: string, required: false, description: "AWS region" } + assume_role_arn: { type: string, required: false, description: "Role ARN to assume" } + mode: { type: string, required: false, description: "async | sync" } + + - id: build_request + type: module + plugin_type: code-exec + name: "Build cloud-query request" + inputs: + calls: "${{ workflow.inputs.calls }}" + region: "${{ workflow.inputs.region }}" + assume_role_arn: "${{ workflow.inputs.assume_role_arn }}" + mode: "${{ workflow.inputs.mode }}" + config: + language: javascript + code: | + var calls = Array.isArray(inputs.calls) ? inputs.calls : []; + if (calls.length === 0) throw new Error("calls must be a non-empty array"); + var cq = { provider: "aws", calls: calls }; + if (inputs.region) cq.region = String(inputs.region); + if (inputs.assume_role_arn) cq.assumeRole = { roleArn: String(inputs.assume_role_arn), sessionName: "np-finops" }; + return { action_context: { cloud_query: cq }, mode: inputs.mode === "sync" ? "sync" : "async" }; + + - id: call_package + type: module + plugin_type: np-package-call + name: "cloud-query (package-exec)" + inputs: + action_context: "${{ steps.build_request.outputs.action_context }}" + agent_selector: "${{ workflow.inputs.agent_tags }}" + mode: "${{ steps.build_request.outputs.mode }}" + config: + apikey: "${{ secrets.NP_API_KEY }}" + package: cloud-query + # Placeholder so validateConfig() passes; inputs.agent_selector replaces it at run time. + agent_selector: + tags: + package: cloud-query + action_context: {} + callback_base_url: "${{ variables.callback_base_url }}" + timeout: "30m" + retry_max_attempts: 1 + + - id: shape_results + type: module + plugin_type: code-exec + name: "Results by call id" + inputs: + response: "${{ steps.call_package.outputs.response }}" + failed: "${{ steps.call_package.outputs.failed }}" + config: + language: javascript + code: | + var r = inputs.response || {}; + var out = {}; + (r.calls || []).forEach(function (c) { out[c.id] = c.ok ? c.result : { error: c.error, errorCode: c.errorCode }; }); + return { results: out, identity: r.identity || null, failed: inputs.failed || [] }; + +connections: + - { from: start, to: build_request } + - { from: build_request, to: call_package } + - { from: call_package, to: shape_results } + +outputs: + results: "${{ steps.shape_results.outputs.results }}" + identity: "${{ steps.shape_results.outputs.identity }}" + failed: "${{ steps.shape_results.outputs.failed }}" +``` + +Check the `agent_selector` input override in Task 4's `mergeConfig`: the YAML passes `workflow.inputs.agent_tags` (a tags map) as `agent_selector`. Adjust `mergeConfig` so that when `inputs.agent_selector` has no `tags` key it is treated as the tags map: `out.agent_selector = 'tags' in sel ? sel : { tags: sel }`. Add a unit test for that in the engine test file (`it('accepts a bare tags map as agent_selector input')`). + +- [ ] **Step 2: Validate** + +Run: `cd /Users/geisbruch/workspace/null/workflows/.worktrees/cost-v2 && npx np-workflow validate finops/tool-cloud-query.yaml` +Expected: valid. If `np-workflow` reports `np-package-call` as an unknown plugin, its plugin catalog comes from the published engine; note it and continue (the E2E test in Step 3 runs against the local engine sources). + +Check how existing suites resolve plugins in tests: `grep -n "runWorkflowE2E\|pluginMocks\|mockPlugin" cost/__tests__/cost.e2e.test.ts | head` and mirror the mocking API exactly. + +- [ ] **Step 3: Write the E2E test** + +```ts +// finops/__tests__/tool-cloud-query.e2e.test.ts +import { describe, expect, it } from 'vitest'; +import { runWorkflowE2E } from '@nullplatform/workflow-test'; + +const RESPONSE = { + provider: 'aws', + identity: { account: '688720756067' }, + calls: [ + { id: 'who', ok: true, pages: 1, durationMs: 5, result: { Account: '688720756067' } }, + { id: 'cost', ok: true, pages: 1, durationMs: 9, result: { ResultsByTime: [{ Total: { UnblendedCost: { Amount: '1.5' } } }] } }, + ], +}; + +describe('finops/tool-cloud-query', () => { + it('returns results keyed by call id', async () => { + const run = await runWorkflowE2E({ + workflow: 'finops/tool-cloud-query.yaml', + inputs: { agent_tags: { package: 'cloud-query' }, calls: [{ id: 'who', service: 'sts', operation: 'GetCallerIdentity' }, { id: 'cost', service: 'ce', operation: 'GetCostAndUsage' }] }, + pluginMocks: { + 'np-package-call': async (ctx) => { + const ac = ctx.inputs.action_context as { cloud_query: { calls: unknown[] } }; + expect(ac.cloud_query.calls).toHaveLength(2); + expect(ctx.inputs.agent_selector).toEqual({ package: 'cloud-query' }); + return { status: 'success', outputs: { commandId: 'c1', agentId: 'a1', response: RESPONSE, calls: RESPONSE.calls, failed: [], mode: 'async' } }; + }, + }, + }); + expect(run.status).toBe('completed'); + expect(run.outputs.results).toEqual({ who: { Account: '688720756067' }, cost: { ResultsByTime: [{ Total: { UnblendedCost: { Amount: '1.5' } } }] } }); + expect(run.outputs.identity).toEqual({ account: '688720756067' }); + expect(run.outputs.failed).toEqual([]); + }); + it('fails the run when calls is empty', async () => { + const run = await runWorkflowE2E({ workflow: 'finops/tool-cloud-query.yaml', inputs: { agent_tags: { package: 'cloud-query' }, calls: [] }, pluginMocks: {} }); + expect(run.status).toBe('failed'); + }); +}); +``` + +Adapt `runWorkflowE2E` options to the real signature found in `cost/__tests__/cost.e2e.test.ts` (path base, mocks key, result field names). Keep the assertions. + +- [ ] **Step 4: Run the tests** + +Run: `npx vitest finops` +Expected: 2 pass. If the harness resolves plugins from the published `@nullplatform/workflow-kit` and `np-package-call` is unknown there, link the local engine per `AUTHORING.md` (search it for "link" / "local engine") and re-run; document the exact command in `finops/README.md`. + +- [ ] **Step 5: Write `finops/README.md`** + +```markdown +# FinOps suite (phase 0) + +Cloud billing through an agent-run package, daily cost facts, allocation to +applications. Design: `docs/superpowers/specs/2026-09-10-finops-cost-allocation-design.md`. + +| Piece | What it is | +|---|---| +| `packages/cloud-query/` | The runner package (generic AWS SDK call executor). See its README. | +| `tool-cloud-query.yaml` | Reusable child workflow: agent tags + calls → results by call id (async with callback by default) | +| `__tests__/` | E2E tests on the local executor with `np-package-call` mocked at the plugin level | + +Engine dependency: plugin `np-package-call` (workflow-system-demo, branch `feat/np-package-call`). + +## Local loop + +1. Engine: `cd workflow-system-demo/.worktrees/np-package-call && WORKFLOW_SECRET_GLOBAL_NP_API_KEY=$NP_API_KEY WORKFLOW_INTER_SERVICE_SECRET=<32+ chars> pnpm tsx scripts/dev-server.ts` +2. Agent: `cd finops/packages/cloud-query && mise run run` (see its README for the env vars) +3. Publish the tool to the local engine with `callback_base_url` = `http://host.docker.internal:3000` and execute it (Task 8 of the phase-0 plan has the exact curl). +``` + +- [ ] **Step 6: Commit** + +```bash +git add finops/tool-cloud-query.yaml finops/__tests__ finops/README.md +git commit -m "finops: tool-cloud-query child workflow + E2E test + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 8: Live run against the local dev-server and the local agent + +**Files:** +- Modify (if needed): `finops/README.md` with the verified commands. + +- [ ] **Step 1: Start the engine from the plugin worktree** + +```bash +cd /Users/geisbruch/workspace/null/workflow-system-demo/.worktrees/np-package-call +lsof -i :3000 -t | xargs kill 2>/dev/null +export NP_API_KEY=$(grep -E '^NP_API_KEY=' ../../.env | cut -d= -f2- | tr -d '"') +WORKFLOW_SECRET_GLOBAL_NP_API_KEY="$NP_API_KEY" WORKFLOW_INTER_SERVICE_SECRET=local-dev-inter-service-secret-0123456789 \ + nohup pnpm tsx scripts/dev-server.ts > /tmp/dev-server.log 2>&1 & +timeout 60 tail -f /tmp/dev-server.log | grep -m1 -i "listening" +curl -s http://localhost:3000/workflows/plugins | python3 -c "import json,sys; d=json.load(sys.stdin); print([p['name'] for p in (d.get('data') or d) if 'package' in p['name']])" +``` +Expected: `['np-package-call']`. If the plugins route needs auth or a different path, read `packages/core/src/api/routes/` for the plugins route and the local auth mode (`WORKFLOW_ORGANIZATION_MODE`), and record the working call in `finops/README.md`. + +- [ ] **Step 2: Start the local agent** + +```bash +cd /Users/geisbruch/workspace/null/workflows/.worktrees/cost-v2/finops/packages/cloud-query +export NP_WORKER_AWS_ACCESS_KEY_ID=$(aws configure get aws_access_key_id --profile kwik_admin) +export NP_WORKER_AWS_SECRET_ACCESS_KEY=$(aws configure get aws_secret_access_key --profile kwik_admin) +PATH="$HOME/.local/share/mise/shims:$PATH" mise run run +timeout 40 docker logs -f np-cloud-query-agent 2>&1 | grep -m1 'Successfully connected' +``` + +- [ ] **Step 3: Publish the tool workflow to the local engine** + +```bash +cd /Users/geisbruch/workspace/null/workflows/.worktrees/cost-v2 +# Normalize YAML → IWorkflowDefinition with the DSL, then create + alias + activate on the LOCAL engine. +# Use the repo's own publisher pointed at localhost (AUTHORING.md / np-workflow skill: NP_WORKFLOW_URL): +NP_WORKFLOW_URL=http://localhost:3000 npx np-workflow publish finops/tool-cloud-query.yaml --alias live +``` +Expected: a `wf_…` id printed. Record it as `$WF`. If the publisher cannot target localhost, fall back to `POST http://localhost:3000/workflows/definitions` with the JSON produced by `normalizeWorkflowDocument` (`packages/dsl/src/yaml`) from the engine worktree: `pnpm tsx -e "..."`; then `POST /workflows/definitions/$WF/aliases {name:'live', revision}` and `POST /workflows/definitions/$WF/aliases/live/activate`. + +- [ ] **Step 4: Patch `callback_base_url` for the local worker and execute (async)** + +Execute with an override of the variable (the child accepts variables via the execute body; if not, republish with `initialValue: http://host.docker.internal:3000` for the local run): + +```bash +curl -s -X POST "http://localhost:3000/workflows/definitions/$WF/execute" -H 'Content-Type: application/json' -d '{ + "inputs": { "agent_tags": {"package":"cloud-query","local":"geisbruch"}, + "calls": [ {"id":"who","service":"sts","operation":"GetCallerIdentity"}, + {"id":"by_service","service":"ce","operation":"GetCostAndUsage","params":{"TimePeriod":{"Start":"2026-09-08","End":"2026-09-09"},"Granularity":"DAILY","Metrics":["UnblendedCost"],"GroupBy":[{"Type":"DIMENSION","Key":"SERVICE"}]}} ] }, + "variables": { "callback_base_url": "http://host.docker.internal:3000" } }' +``` +Then poll `GET /workflows/executions/:id` until terminal (≤ 60 s) and print `outputs.results.by_service.ResultsByTime[0].Groups | length` and `outputs.identity`. +Expected: status `completed`, identity account `688720756067`, ≥ 8 groups. Also verify on the agent: `docker logs np-cloud-query-agent | grep -c 'Starting command'` increments by exactly 1 (no re-delivery), and `docker logs | tail -3` shows `callback delivered (HTTP 200)`. + +- [ ] **Step 5: Sync mode and oversized call** + +Run the same execute with `"mode": "sync"` and only the `who` call → expect `completed` within 10 s. +Run async with a call that exceeds the cap (60 days, DAILY, `SERVICE`+`USAGE_TYPE`, `maxPages: 50`) → expect the execution to end `failed` with `NP_PACKAGE_CALL_CALLS_FAILED` and `failed: ["big"]`, delivered via the callback (the worker logs `RESULT_TOO_LARGE`), and `Starting command` on the agent incremented by exactly 1. + +- [ ] **Step 6: Record and commit** + +Update `finops/README.md` "Local loop" with the exact commands that worked (publish command, execute body, variable override). Commit: + +```bash +git add finops/README.md +git commit -m "finops: local loop verified (dev-server + local agent, async callback) + +Co-Authored-By: Claude Fable 5.1 " +``` + +- [ ] **Step 7: Engine PR readiness** + +In the engine worktree: `pnpm -w run lint && pnpm -w run build && pnpm -w run test` all green; `git log --oneline main..` shows the four core commits. Do NOT push or open the PR until Gabriel says so. + +--- + +## Self-review + +- Spec coverage: 4.1 callback + token echo (Task 1), local run task (Task 2, spec §9), 4.2 plugin config/sync/async/token/timeout/retry=1 (Tasks 3-6), 4.3 child workflow with inputs/outputs (Task 7), phase 0 acceptance (Task 8: async callback, sync small call, oversized fails with no re-delivery). Registration and boundaries (Task 6). Phases 1-4 of the spec are out of this plan by design. +- Placeholders: none; every code step has the code. Two steps ask the executor to verify a real signature (`runWorkflowE2E` options, `IStepResumeContext.phase`, registry lookup) and give the grep to do it. +- Type consistency: `NpPackageCallConfig` fields match the descriptor schema keys; `dispatch` returns `{ok, exec}`/`{ok:false, result}` in both call sites; `outputsFrom(exec, response, mode)` used by sync and phase 2; `setPath` used by phase 1; runner `callback`/`token` names match between package (`callback.ts`, `runner.ts`, `index.ts`) and plugin (`callback_key` default `cloud_query.callback`, `response.token`). diff --git a/docs/superpowers/specs/2026-09-10-finops-cost-allocation-design.md b/docs/superpowers/specs/2026-09-10-finops-cost-allocation-design.md new file mode 100644 index 0000000..3522790 --- /dev/null +++ b/docs/superpowers/specs/2026-09-10-finops-cost-allocation-design.md @@ -0,0 +1,339 @@ +# FinOps suite: cloud billing via agent packages, daily cost facts, and app allocation + +Date: 2026-09-10. Status: approved design, pre-implementation. +Owner: Gabriel Eisbruch. Target org: itti. Iteration org: kwik-e-mart (1255165411, AWS 688720756067). + +## 1. Goal and non-goals + +Goal: a total-cost-allocation system on the workflow engine that (1) reads cloud +billing through an agent-run package so customers grant an IAM role instead of +handing us credentials, (2) stores one daily cost fact per subject in the catalog, +and (3) allocates those facts to applications and dimensions incrementally. + +Explicit decisions taken with Gabriel on 2026-09-10: + +- The total does NOT need to reconcile against the invoice. Prorated allocation + (k8s by cpu/mem, shared databases) makes exact reconciliation impossible by + construction. Every fact declares HOW it was allocated so the reader knows + what is direct and what is a share. +- Go incrementally: direct-allocation resources first (tagged EC2, S3, dedicated + databases), k8s prorated as the existing suites do, shared resources in an + explicit bucket until a rule exists. Then look at what is left outside. +- Attribution by app starts with "service to its owning application"; harder + cases (multi-instance databases, shared caches) come later through config. +- Everything is developed and tested LOCALLY (local agent on Gabriel's laptop, + local dev-server). The only thing created in kwik-e-mart is the catalog + entity (specs + instances). Nothing is deployed to itti in this design. + +Non-goals: forecasting, anomaly detection, budgets, right-sizing (the `cost/` +suite owns that), Azure/GCP adapters (the runner contract allows them, no work +here), replacing `cost/` or `cost-finout/`. + +## 2. Verified platform facts this design relies on + +All measured on 2026-09-10 against `api.nullplatform.com` and read from +`nullplatform/agents-api` (`routes/agent_command.js`, +`services/agent_command_executor_service.js`, `schemas/agent_command_schemas.js`) +and `nullplatform/controlplane-agent` main (`supervisor/commandexecutor/worker_orchestrator.go`). + +| Fact | Consequence | +|---|---| +| `POST /controlplane/agent_command` accepts `command.type: package-exec` with `data: {package?, version?, environment: {NP_ACTION_CONTEXT: ""}}`. The worker receives the NP_ACTION_CONTEXT JSON as the gRPC `Execute` payload. | The workflow can call a package directly, with an arbitrary JSON request, no notification/channel needed. | +| The API exposes only `executions[0].results.{stdOut, stdErr, exitCode}`; the worker's gRPC `data` is dropped. | Runner output contract: logs on stderr, the result JSON alone on stdout. | +| Sync mode: HTTP gateway cuts at 60 s (504). Agent heartbeat timeout is 60 s and `execution_config.retry.max_attempts` defaults to 3, so a lost completion is re-delivered to the agent up to 3 times per POST. The agent-to-API completion message carries stdout AND data; ~360 KB stdout round-trips, ~800 KB is lost (never completes, re-delivered). | Sync is only for small, fast calls. Large answers are a poison pill, not an error. | +| Async mode: `execution_config: {async: true}` resolves on the agent's `started` (~1 s) with `executions[0].commandId`. There is NO route to fetch the result later (only `POST /agent_command` and `POST /:commandId/cancel`). `ping` never emits `started`, so async ping fails. | The worker must push its result somewhere. | +| The engine has `POST /workflows/webhooks/callback/:executionId/:signalName` (unauthenticated, executionId is the capability) which signals a `signal-wait` step. | The worker pushes the result to the engine; the workflow waits with `signal-wait`. | +| Agent image `alpha-packages-2.2.0` (the template default) does not inject env into docker workers and never answered. `latest` (0.11.1) supports `package-exec`, `NP_WORKER_PATCHES`, `NP_WORKERS`. | Local dev uses `:latest`. The template's `mise run` task must be overridden. | +| Restarting the agent container registers a NEW agent id; commands to the old id fail with "failed to start after all retry attempts". | Workflows select agents by TAGS, never by pinned id. | +| kwik AWS is a linked account: no CUR, no Data Exports, cost allocation tags are managed by the payer (AccessDenied). Cost Explorer works. ~USD 600/30 d, one EKS cluster `developent`, 3 EC2 scopes tagged `scope_id`, `application_id`, `namespace_id`. No Container Insights, no AMP, no metrics-server addon. | Phase 1 uses Cost Explorer. k8s usage split needs an in-cluster package (phase 3). | + +## 3. Architecture + +``` +workflow (engine) customer side +───────────────── ───────────── +np-package-call ──async package-exec──▶ agent (tags) ──spawns──▶ cloud-query worker + │ (dispatch, then WAIT) │ runs SDK calls with the + │ │ pod's IAM role + ◀──── POST /workflows/webhooks/callback/{executionId}/{signal} ┘ result JSON + │ +collectors ──▶ cost_daily facts (catalog) ──▶ allocator ──▶ cost_daily facts (allocated=true) + └──▶ dashboards (np-report, lake) +``` + +Three layers, each independently testable: + +1. **Runner**: `cloud-query` package + `np-package-call` engine plugin + a reusable + child workflow `finops/tool-cloud-query.yaml`. +2. **Facts**: catalog spec `cost_daily` + one collector workflow per source. +3. **Allocation**: catalog spec `cost_allocation_rule` + allocator workflow that + turns raw facts into per-application facts. + +## 4. Layer 1: the runner + +### 4.1 `cloud-query` package (`finops/packages/cloud-query/`) + +Already scaffolded and spiked (`simple-bun` template, bun 1.4, `@nullplatform/plugin@0.0.4`). +It carries NO cost semantics. It executes a list of SDK calls and returns raw responses. + +Request (the NP_ACTION_CONTEXT JSON, key `cloud_query`): + +```json +{ + "cloud_query": { + "provider": "aws", + "region": "us-east-1", + "assumeRole": { "roleArn": "arn:aws:iam::123:role/np-finops", "externalId": "..." }, + "calls": [ + { "id": "cost_by_service", "service": "ce", "operation": "GetCostAndUsage", + "params": { "TimePeriod": {"Start": "2026-09-01", "End": "2026-09-02"}, + "Granularity": "DAILY", "Metrics": ["UnblendedCost"], + "GroupBy": [{"Type": "DIMENSION", "Key": "SERVICE"}] }, + "paginate": true, "maxPages": 20 } + ], + "maxResultBytes": 307200, + "callback": { "url": "https://api.nullplatform.com/workflows/webhooks/callback//cloud-query.", + "token": "" } + } +} +``` + +Rules: + +- `service` is a fixed allow-list compiled into the binary: `ce`/`cost-explorer`, + `cloudwatch`, `ec2`, `sts`. Adding a service is a package release. `operation` + is the PascalCase SDK command name (`GetCostAndUsage` maps to `GetCostAndUsageCommand`). +- Pagination follows `NextPageToken`, `NextToken`, `NextContinuationToken`, `Marker`; + pages are merged: arrays concatenated, scalars last-page-wins, tokens and + `$metadata` dropped. +- Credentials: default AWS provider chain (IRSA / pod identity in a cluster, + `NP_WORKER_PATCHES` env locally). Optional `assumeRole` via STS before the calls. +- Per-call result cap `maxResultBytes` (default 300 KB). Over the cap: the call + fails with `RESULT_TOO_LARGE`; the caller narrows the window or the grouping. + The runner never truncates data silently. +- Output contract: progress lines on stderr; on stdout exactly one JSON document + (the response below). If `callback.url` is present the runner ALSO POSTs the + response there (JSON body, `Content-Type: application/json`, 3 attempts with + backoff, 10 s timeout each). The gRPC result `success` is false if any call + failed or the callback POST failed after retries. + +Response: + +```json +{ + "provider": "aws", "region": "us-east-1", + "identity": { "account": "688720756067", "arn": "arn:aws:iam::...:role/np-finops" }, + "token": "", + "calls": [ + { "id": "cost_by_service", "ok": true, "pages": 1, "durationMs": 980, "result": { "ResultsByTime": [...] } }, + { "id": "big", "ok": false, "durationMs": 4191, "errorCode": "RESULT_TOO_LARGE", "error": "result is 1.7 MB, cap is 300 KB; narrow the query" } + ] +} +``` + +Package identity: manifest `name: cloud-query`, selector `{package: cloud-query}`, +`command_types: ["custom"]`. Version from `package.json`. + +Distribution: `np package publish` to a registry allowed by the customer agent +(`worker.allowedRegistries`). For itti the image is pinned by digest in the agent +Helm values (`worker.pins`) with `serviceAccount` bound to the IAM role. This is +operator configuration, outside this repo. + +Tests: `bun test` unit tests on `runner.ts` (pagination, merge, caps, per-call +errors, validation) with a fake client factory. No live AWS in tests. + +### 4.2 Engine plugin `np-package-call` (workflow-system-demo, `packages/core/src/plugins/built-in/np-package-call/`) + +A new MODULE plugin, composite wait, two-phase protocol per CLAUDE.md +("Composite wait plugins MUST use the two-phase protocol"). It does not extend +`np-agent-command` because that plugin is a one-shot sync call and the wait +semantics differ; `np-agent-command` stays untouched. + +Config: + +| Field | Type | Notes | +|---|---|---| +| `apikey` | string | `${{ secrets.NP_API_KEY }}` (config entry, never `ctx.secrets`) | +| `agent_selector` | `{ nrn?, tags }` | passed verbatim to `/controlplane/agent_command` (`selector` + `nrn`). Never `agent_id`. | +| `package` | string | e.g. `cloud-query` | +| `version` | string, optional | semver pin | +| `action_context` | object | becomes `environment.NP_ACTION_CONTEXT` (JSON-stringified by the plugin) | +| `mode` | `async` (default) or `sync` | sync: one-shot, result parsed from `results.stdOut`; async: dispatch + wait | +| `callback_base_url` | string | default `https://api.nullplatform.com`; the plugin builds `${base}/workflows/webhooks/callback/${execution.id}/${signalName}` and injects it as `action_context.` | +| `callback_key` | string | default `cloud_query.callback` (dotted path inside `action_context` where `{url, token}` is written) | +| `timeout` | duration string | wait timeout, default `30m` | +| `retry_max_attempts` | int | `execution_config.retry.max_attempts`, default 1 (NOT the API default 3: re-delivery of a package-exec re-runs billing calls) | +| `timeout_seconds` | int | HTTP timeout for the dispatch call, default 30 (async) / 120 (sync) | + +Behavior (async): + +1. Phase 1: build `signalName = package-call.${ctx.stepId}`, `correlationKey = ${ctx.executionId}`, + a random `token = ctx.helpers.uuid()`; inject `{url, token}` into the action + context; POST `/controlplane/agent_command` with `execution_config: {async: true, retry: {max_attempts}}`. + On HTTP error or `executions[0].status !== 'success'`: return a NON-retryable + failure with the platform error (`AGENT_NOT_FOUND`, `DISPATCH_FAILED`). + Otherwise return `IStepResult.wait` with `{signalName, correlationKey, timeout}` + and interim outputs `{commandId, agentId, dispatchedAt}`. +2. Phase 2 (`ctx.resume`): the signal payload is the runner response. Verify + `payload.token === token` (stored in step state in phase 1); mismatch = failure + `CALLBACK_TOKEN_MISMATCH` (the callback route is unauthenticated; the token + makes a forged POST harmless). Outputs: `{commandId, agentId, response, calls: response.calls, failed: [...ids]}`. + `status: 'failure'` with `CALLS_FAILED` when any call has `ok: false` + (the response is still in outputs so the workflow can branch). +3. Timeout: the wait fails (`onTimeout: error` semantics); route with + `error_handling.fallback_step` + a declared `condition: "false"` edge, never + via a non-default output port (CLAUDE.md port-wiring rule). + +Behavior (sync): as today's `np-agent-command exec` branch but for `package-exec`; +`JSON.parse(results.stdOut)` into `response`; any parse error is `BAD_RUNNER_OUTPUT`. + +Descriptor: `category: nullplatform`, `executeMode: all`, ports `default` only, +`capabilities: ['awaits-signal']`, rich `configUiSchema`, examples for both modes. +Tests with `createPluginTest`: dispatch body shape, wait shape, token check, +sync parse, error mapping. The activity must not use `waitForSignal()` (throws +on Temporal). + +Determinism: the token comes from `ctx.helpers.uuid()`; no `Date.now()`. + +### 4.3 Child workflow `finops/tool-cloud-query.yaml` + +A reusable sub-workflow so collectors (and agents, via `sub-workflow` as a tool) +do not repeat the plumbing: + +- Inputs: `agent_tags` (object), `calls` (array), `region` (string, optional), + `assume_role_arn` (optional), `mode` (`async` default). +- One `np-package-call` step + a `code-exec` that reshapes `outputs.calls` into + `{ : result }` and fails loudly listing failed call ids. +- Outputs: `results` (map by call id), `identity`. + +## 5. Layer 2: daily cost facts + +### 5.1 Catalog spec `cost_daily` (`finops/specs/cost_daily.spec.json`) + +Generalizes `infrastructure_cost_daily` (falabella). One instance per +(subject, day, allocation stage). Flat fields, denormalized names, `additionalProperties: false`, +`schema.authorization` grants as in the falabella spec (without it API keys get 403). + +| Group | Fields | +|---|---| +| Identity | `id` (`:::`), `d` (YYYY-MM-DD), `stage` enum `raw` / `allocated` | +| Subject | `subject_type` enum `cloud_service` / `resource` / `cluster` / `scope` / `service` / `application` / `bucket`; `subject_id`; `subject_name`; `nrn` (nullable) | +| Null dimensions (nullable) | `application_id`, `application_name`, `namespace_id`, `namespace_name`, `account_id`, `account_name`, `environment`, `scope_id`, `scope_name`, `service_id`, `service_name` | +| Cloud dimensions | `cloud` enum `aws` / `azure` / `gcp` / `other`; `cloud_account`; `region`; `cloud_service` (e.g. `Amazon Elastic Compute Cloud - Compute`); `usage_type` (nullable); `resource_id` (nullable); `cluster` (nullable) | +| Money | `cost_usd` (chargeback), `usage_usd` (nullable, when usage is known), `waste_usd` (nullable) | +| Usage vs reservation (nullable, resource-kind specific) | `cpu_req_core_h`, `cpu_used_core_h`, `mem_req_gb_h`, `mem_used_gb_h`, `storage_gb`, `requests_total`, `units` (free-form unit label), `quantity` | +| Provenance | `source` enum `aws_ce` / `aws_cur` / `k8s` / `manual`; `allocation_method` enum `direct_tag` / `direct_resource` / `cluster_split_cpu_mem` / `service_owner` / `rule` / `unallocated`; `rule_id` (nullable); `parent_id` (nullable: the raw fact an allocated fact came from); `share` (0..1, nullable); `collected_at`; `collector` (workflow id + revision) | + +Invariants: + +- `raw` facts are written only by collectors; `allocated` facts only by the + allocator. Each workflow owns its top-level fields; catalog `PATCH ?upsert=true` + merges at top level (falabella lesson). +- Σ `allocated` facts of a day ≤ Σ `raw` facts of the day. The difference is + visible as `allocation_method: unallocated` facts, never hidden. +- A subject's daily fact is idempotent: re-running a collector for a day + overwrites the same `id`. + +Retention: 400 days of daily facts. Weekly/monthly aggregates are a dashboard +concern (lake queries), not stored. + +### 5.2 Collectors (kwik, phase 1) + +All collectors are daily crons with a `date` input (default: yesterday, UTC), +and a `days_back` input for backfill fan-out. Each runs one +`tool-cloud-query` call set sized under the 300 KB cap (one day, one grouping +per call). + +| Workflow | Calls | Facts written (`stage: raw`) | +|---|---|---| +| `wf1-aws-billing-daily.yaml` | CE `GetCostAndUsage` for `date`, DAILY, `UnblendedCost`, grouped (a) by `SERVICE`, (b) by `SERVICE` + `USAGE_TYPE` | one `cloud_service` fact per service (`allocation_method: unallocated`, the org-level truth), one `bucket` fact per (service, usage_type) when `USAGE_TYPE` grouping is available | +| `wf2-aws-tagged-resources-daily.yaml` | CE grouped by `TAG` `scope_id` (needs the tag activated at the payer; kwik: DEFERRED until activated, see §8), fallback: EC2 `DescribeInstances` filtered by tag `scope_id` + CE `RESOURCE_ID` grouping for the last 14 days | one `scope` fact per tagged scope with `allocation_method: direct_tag`, joined to null names via `np-lake-query` (scope, application, namespace) | +| `wf3-eks-cluster-daily.yaml` | CE filtered by tag `eks:cluster-name` = cluster (nodes) + service `Amazon Elastic Container Service for Kubernetes` (control plane) | one `cluster` fact per EKS cluster with `cost_usd` = nodes + control plane, `allocation_method: unallocated` until phase 3 splits it | + +Joins to null entities use `np-lake-query` (scope/application/namespace names by +id), never the NP API in a loop. + +Fact writes: `code-exec` with `PATCH /catalog/instances/cost_daily/{id}?upsert=true`, +3 retries with backoff on 5xx (copied from `cost-finout/wf3`), `mapLimit` 5. +Writes are the only network I/O outside the runner; the step runs +`runtime.kind: microvm` with `executionConfig.timeoutMs` set explicitly. + +## 6. Layer 3: allocation + +### 6.1 Catalog spec `cost_allocation_rule` + +One instance per rule, evaluated in `priority` order by the allocator: + +| Field | Meaning | +|---|---| +| `id`, `name`, `enabled`, `priority` (int) | identity and ordering | +| `match` | object: any of `subject_type`, `cloud_service`, `usage_type` (glob), `resource_id` (glob), `cluster`, `cloud_account`, `region` | +| `method` | enum `service_owner` / `fixed_split` / `by_usage` / `bucket` | +| `targets` | for `fixed_split`: `[{application_id, share}]` (shares sum to 1); for `bucket`: `{bucket_name}`; for `service_owner` / `by_usage`: empty (resolved from null data) | +| `usage_metric` | for `by_usage`: `cpu_req_core_h` / `mem_req_gb_h` / `requests_total` | + +Built-in behavior without rules: `scope` facts with `application_id` allocate +100 % to that application (`direct_tag`); `service` facts allocate to the +service's owning application (`service_owner`, owner read from the lake); +everything else stays `unallocated`. + +### 6.2 Allocator `wf5-allocate-daily.yaml` + +Daily cron after the collectors. For a `date`: read raw facts (lake query on +`cost_daily` with `stage = raw`), read rules, apply in priority order, write +`allocated` facts with `parent_id`, `share`, `rule_id`, `allocation_method`. +Output summary fact per application (`subject_type: application`) and per +bucket. Re-runnable: it first deletes nothing; it overwrites by `id` +(`allocated:application::`, `allocated:bucket::`). + +Sanity check inside the run: Σ allocated ≤ Σ raw, else fail the run. + +## 7. Phases and acceptance + +| Phase | Deliverable | Acceptance | +|---|---|---| +| 0 | `np-package-call` plugin (engine) + `finops/tool-cloud-query.yaml` | Unit tests green; local dev-server executes `tool-cloud-query` against the local agent in async mode and receives the callback; sync mode works for a small call; oversized call fails with `RESULT_TOO_LARGE` and NO re-delivery on the agent | +| 1 | `cloud-query` package hardened (callback POST, tests) + `cost_daily` spec in kwik + wf1/wf3 collectors + 30-day backfill | Lake query shows one `cloud_service` fact per service per day for 30 days; Σ facts of a day equals CE's daily total within 1 % | +| 2 | wf2 tagged resources (EC2 scopes) + allocator with built-in behavior + rules spec + 1 `fixed_split` rule | Per-application allocated facts for the 3 tagged scopes; unallocated bucket visible; Σ allocated ≤ Σ raw enforced | +| 3 | k8s split: in-cluster `k8s-query` package (Kubernetes API: pod requests/usage via metrics.k8s.io when present) + `wf4-eks-split-daily.yaml` splitting `cluster` facts by cpu/mem into `scope` facts | Cluster fact fully split (scopes + `cluster-overhead` bucket) for kwik's `developent` | +| 4 | itti rollout runbook: IAM role + IRSA, agent Helm values (`allowedRegistries`, `pins`, `serviceAccount`), package publish, config entries | Documented in `finops/README.md`; not executed in this design | + +Every phase ends with: `pnpm lint:workflows`, `np-workflow validate` on the +YAMLs, E2E test with `runWorkflowE2E` for each collector (mocking +`np-package-call` at the plugin level), and a live run on the local dev-server. + +## 8. Open items (tracked, not blocking phase 0/1) + +- Cost allocation tag `scope_id` must be activated at kwik's payer account for + `GROUP BY TAG`. Until then wf2 uses the EC2 inventory + resource-level CE. +- itti: confirm billing source (CE vs CUR) and whether it is a payer or linked + account. CUR adds `athena` + `s3` to the runner allow-list. +- `agents-api` retry default of 3 re-delivers package-exec on lost completions; + the plugin sends `max_attempts: 1`. A platform-side "no retry for package-exec" + default is worth proposing separately. +- The callback route is unauthenticated by design; the per-dispatch token + closes the forged-POST gap for this plugin. An engine-level HMAC remains a + future hardening (noted in `webhooks-callback.ts`). + +## 9. Local development setup (recorded from the spike) + +```bash +# CLI with `np package` (preview channel), kept separate from the stable np +curl -fsSL -o ~/.local/bin/np-preview https://cli.nullplatform.com/packages-preview/np-Darwin-arm && chmod +x ~/.local/bin/np-preview +brew install mise + +# worker image +cd finops/packages/cloud-query && docker build -t cloud-query-worker:dev . + +# local agent (docker backend). AWS creds reach the worker through a pod patch. +docker run -d --name np-cloud-query-agent --network host \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e NP_API_KEY -e NP_WORKER_BACKEND=docker -e NP_WORKER_IMAGE=cloud-query-worker:dev \ + -e NP_WORKER_PATCHES='[{"target":{"package":"cloud-query"},"merge":{"spec":{"containers":[{"name":"worker","env":[{"name":"AWS_ACCESS_KEY_ID","value":"..."},{"name":"AWS_SECRET_ACCESS_KEY","value":"..."},{"name":"AWS_REGION","value":"us-east-1"}]}]}}}]' \ + public.ecr.aws/nullplatform/controlplane-agent:latest \ + -runtime=host -tags=package:cloud-query,local:$USER,env:local +# after rebuilding the image: docker rm -f np-worker-docker-desktop-cloud-query +``` + +The `mise run` task from the template must be updated to `:latest` and to pass +`NP_WORKER_PATCHES`; that change ships with phase 1. diff --git a/finops/README.md b/finops/README.md new file mode 100644 index 0000000..0a251b3 --- /dev/null +++ b/finops/README.md @@ -0,0 +1,351 @@ +# FinOps suite + +Cloud billing read through an **agent-run package** (the customer grants an +IAM role, no credentials in workflows), one **cost fact per subject per day** +in the catalog, and allocation to applications by direct attribution and by +Kubernetes consumption. + +Read first: +- [docs/analysis-kwik-e-mart-2026-09-10.md](./docs/analysis-kwik-e-mart-2026-09-10.md) — the reference analysis (numbers, decisions, what maps and what does not) +- [docs/mapping-playbook.md](./docs/mapping-playbook.md) — how to repeat it for another account, service by service, and which ids join cloud resources to null +- Design: `docs/superpowers/specs/2026-09-10-finops-cost-allocation-design.md`; phase-0 plan: `docs/superpowers/plans/2026-09-10-finops-phase0-runner.md` + +| Piece | What it is | +|---|---| +| `packages/cloud-query/` | The runner package (image `public.ecr.aws/nullplatform/agent-plugins/workflows/aws-cost-explorer`): generic AWS SDK call executor — `ce`, `ec2`, `elbv2`, `rds`, `pi` (Performance Insights), `tagging`, `cloudwatch`, `logs`, `sts` — pagination, per-call size cap, callback with host allow-list; in callback mode the command completion is a small receipt. See its README. | +| `tool-cloud-query.yaml` | Reusable child: agent tags (+ `agent_nrn`) + calls → results by call id (async with engine callback by default, sync for small calls). Carries the image as an `oci_image` artifact: no package registration on the platform. | +| `wf0-aws-billing-dispatch.yaml` | **The daily loop** (cron 04:15 UTC): one target per account (agent tags × NRN × AssumeRole × region) → `wf1` per target → `wf2` allocation of the day → `wf-suggest-mappings` for what stayed unallocated. `expected_account` guards the pairing. | +| `wf1-aws-billing-daily.yaml` | Collector: one day of AWS billing → `raw` `cost_daily` facts (cloud services, usage-type buckets, EC2 scopes by null tags, one bucket for instances that terminated before collection, EKS clusters with components per cloud service + blended rates, databases with host/tags). Evidence fields (`tags`, `host`) travel with the facts. | +| `wf3-k8s-consumption-daily.yaml` | **Kubernetes consumption**: per null scope, `max(usage, request)` per hour from the cluster's Prometheus through the agent (the cost tracker's collector script), priced with the day's blended rates → `raw-k8s-scope-*` rows (cost, usage, waste, core-h/GiB-h) and the cluster row's consumption shares (`metric_shares`/`metric_owners`) that split the cluster among applications. Replaces the metadata `cost_tracking` writes of `cost/wf1b`. | +| `wf2-allocate-daily.yaml` | **Allocator**: raw facts of a day + active `cost_mapping_rule`s → `allocated` facts per source fact × owner (categorized) + one rollup per application, per shared bucket and one `unallocated`, and one **`application_cost_daily`** row per application (total, by category, by cloud service, the resources behind it, the Kubernetes part). Σ allocated = Σ cloud services, always. | +| `wf-suggest-mappings.yaml` | **Inference**: unallocated leaves × evidence (null services by host, application parameters) → `cost_mapping_suggestion` rows (`proposed`) with evidence, confidence and the USD they would recover. | +| `wf-cost-fact-upsert.yaml` | Child: `PATCH /catalog/instances//?upsert=true` for one row (facts, rules, suggestions — `catalog_slug` input) | +| `specs/cost_daily.spec.json` | Catalog spec (62 fields): subject, null dimensions, cloud dimensions, amortized `cost_usd` + `unblended_usd`, capacity/rates, evidence (`tags`, `host`), allocation provenance (`rule_id`, `share`, `source_fact_id`, `category`), rollups. Logical id `---`; `day` = filterable copy of `date`. | +| `specs/application_cost_daily.spec.json` | The per-application daily **invoice**: `-` with `date`, `total_usd`, flat `charge_items[]` (each line says how it was charged — `charge_type` `scope` (any scope type, with `scope_id`), `service` (`service_id`) or `application` (by tag/rule, no null object) — plus category, cloud service, USD, share and the rule that attributed it), `totals` by charge type / category / cloud service, and `consumption` (Kubernetes usage vs request per scope). | +| `specs/cost_mapping_rule.spec.json`, `specs/cost_mapping_suggestion.spec.json` | The rules as data (see [docs/mapping-rules-design.md](./docs/mapping-rules-design.md)) and what the inference proposes. | +| `setup/01-catalog-spec.sh` | Creates/updates the three specs (session bearer; the admin grant is rewritten to the token's user) | +| `setup/02-aws-worker-identity.sh` | Worker pod identity for clusters without IaC (Pod Identity role + SA + the agent rule) | +| `setup/03-mapping-rules.sh` | Upserts a rules JSON (e.g. `setup/rules.nullplatform.json`) into `cost_mapping_rule` | +| `setup/publish.ts`, `setup/vars..json` | Publishes the seven workflows to an engine (platform or local) in dependency order with per-org variable values | +| `docs/iam/` | Read-only policy + trust documents for the worker role (IRSA, Pod Identity, cross-account) | +| `__tests__/` | 16 E2E tests on the local executor, plugins stubbed at the plugin level | + +CI note: `npm run validate` skips `finops/tool-cloud-query.yaml` — the published `@nullplatform/workflow-kit` (0.1.0) predates the `np-package-call` plugin and reports it as unregistered; the file is covered by the E2E tests and validated by the engine on publish. Remove the exclusion when the kit is re-released from the current engine. + +State (2026-09-11): LIVE in nullplatform's organization (org 4): daily loop published with alias `live`, rules seeded from `setup/rules.nullplatform.json`, 2026-09-09 collected (384 USD amortized). Engine plugin `np-package-call` is deployed (workflow-system 0.0.125). + +## Scope usage (right-sizing) and its price + +`wf3` writes two things per scope-day. First `scope_usage_daily` (`usage--`): the +consumption itself — per-hour CPU/memory used vs requested, pods, `core_h_*`/`gb_h_*` totals, +`*_chargeable` = Σ max(used, requested), utilization % and waste — from the agent collector +(Prometheus) or from New Relic (`collector_mode: newrelic`, one NRQL per cluster-day). Then the +priced `cost_daily` scope fact (`raw-k8s-scope--`, `usage_id` → the usage row) and the +cluster row's `metric_shares`. The usage entity is the right-sizing source (utilization per scope +over time, straight from the Lake) and what a re-pricing reads; the cost side never needs the +metrics source again. + +## Bringing it to a customer + +Read [`docs/customer-onboarding.md`](docs/customer-onboarding.md): discovery questions, worker +identity + agent rule, catalog specs and their traps, publishing, the first day, the dashboard and +the operational limits — everything learned on nullplatform's own org. + +## Local loop + +1. **Engine** (from the engine worktree that has the plugin): + ```bash + cd ~/workspace/null/workflow-system-demo/.worktrees/np-package-call + WORKFLOW_SECRET_GLOBAL_NP_API_KEY=$NP_API_KEY WORKFLOW_INTER_SERVICE_SECRET=<32+ chars> PORT=3210 \ + pnpm tsx scripts/dev-server.ts + ``` +2. **Agent** (docker backend on the laptop; AWS creds only for dev): + ```bash + cd finops/packages/cloud-query + export NP_API_KEY=... NP_WORKER_AWS_ACCESS_KEY_ID=... NP_WORKER_AWS_SECRET_ACCESS_KEY=... + mise run run # tags package:cloud-query,local:$USER,env:local; callback allow-list includes host.docker.internal + ``` +3. **Publish** the tool on the local engine. `npx np-workflow publish` refuses + (its plugin catalog is the published engine), so normalize with the engine's + own DSL and POST it (verified 2026-09-10; the `publish.ts` snippet lives + in the phase-0 plan, Task 8): `POST /workflows/definitions` with the parsed + YAML, then `POST /workflows/definitions/:id/aliases {name: live, revision}` and + `POST .../aliases/live/activate`. Note: the engine's `PORT` must not collide + with a docker-published port (k3d publishes 3000 on this laptop → use 3210). +4. **Run** with the callback pointed at the laptop — as an INPUT, not a + variable override (`variables` in the execute body are ignored): + ```bash + curl -s -X POST http://127.0.0.1:3210/workflows/definitions/$WF/execute -H 'Content-Type: application/json' -d '{ + "inputs": { "agent_tags": {"package":"cloud-query","local":"'$USER'"}, + "callback_base_url": "http://host.docker.internal:3210", + "calls": [ {"id":"who","service":"sts","operation":"GetCallerIdentity"}, + {"id":"by_service","service":"ce","operation":"GetCostAndUsage", + "params":{"TimePeriod":{"Start":"2026-09-08","End":"2026-09-09"},"Granularity":"DAILY","Metrics":["UnblendedCost"],"GroupBy":[{"Type":"DIMENSION","Key":"SERVICE"}]}} ] } }' + ``` + Verified outcomes (2026-09-10, kwik-e-mart): async run `completed` in 4 s with + the callback received by the engine and exactly one delivery on the agent; + sync `who` in 4 s; an oversized 60-day call `failed` with + `NP_PACKAGE_CALL_CALLS_FAILED` (`RESULT_TOO_LARGE`, 1.5 MB vs 300 KB cap) + delivered through the callback, again with a single delivery — no re-delivery + storm. + +## Configuration: how cost becomes an owner + +Nothing about a customer's resources lives in code. Three layers, per organization: + +1. **Evidence the collector records on every raw fact** — `tags`, `host`, `cluster`, `component`, + `resource_type`, `usage_type`, the null dimensions wf1 could derive (`application_id`, + `scope_id`, `service_id` from null tags and null service hosts). +2. **Rules** (`cost_mapping_rule` rows, edited via API/UI, versioned): evaluated by the allocator + per raw fact, ascending `priority`, first match wins. Shape: + + ```json + { + "id": "rds-approvals-shared-by-consumers", + "enabled": true, "status": "active", "priority": 100, "source": "inferred", "confidence": 0.6, + "scope": { "cloud_service": "Amazon Relational Database Service", "resource_type": "rds:cluster" }, + "match": [{ "field": "host", "equals": "postgres-approvals-api-db.cluster-xyz.us-east-1.rds.amazonaws.com" }], + "method": "split", + "target": { "split": [ + { "weight": 1, "target": { "application_id": "1234", "application_slug": "entities-api" } }, + { "weight": 1, "target": { "application_id": "5678", "application_slug": "users-api" } } + ]}, + "evidence": [{ "kind": "parameter", "detail": "catalog.entities-api parameter DB_HOST references …" }] + } + ``` + + - `scope`: field → value, list, or `{regex}` on the raw fact (cheap pre-filter). + - `match`: predicates, all must hold: `{field, equals | in | regex | exists}`; `field` is a dotted + path (`tags.application_id`, `host`, `subject_name`, `resource_id`, `usage_type`, `component`). + Regex named groups become `$captures`. + - `target`: a literal owner (`application_id`, `scope_id`, `service_id`, `namespace_id`, `cluster`, + `bucket`), or `capture` (take the owner from the fact / a regex group), or `split` (weights), or + `map` (`key` → owner table). `method`: `direct | split | map | by_metric | spread`. + - `by_metric`: the collector attaches a consumption metric to the fact (`metric: pi.db.load`, + `metric_shares: {database → share}` — Performance Insights DB load by database on the cluster + writer, one call per RDS subject per day); the rule's `map.entries` say which application owns + each key; keys without an owner stay visibly unallocated (`metric_key` on the row). Example, the + shared approvals Aurora (15 databases): + + ```json + { "id": "rds-approvals-by-database-load", "priority": 90, "method": "by_metric", + "scope": { "cloud_service": "Amazon Relational Database Service", "resource_type": "rds:cluster" }, + "match": [{ "field": "host", "equals": "postgres-approvals-api-db.cluster-….rds.amazonaws.com" }, { "field": "metric", "equals": "pi.db.load" }], + "target": { "map": { "key": "db.name", "entries": { + "core_entities_api": { "application_id": "1182532716" }, "tracing_api_production": { "application_id": "518811741" }, + "notifications": { "application_id": "1593752815" }, "approvals": { "application_id": "1372325109" }, "…": "…" } } } } + ``` + - CloudWatch Logs: `wf1` lists every log group (`storedBytes`) and asks `IncomingBytes` per group + for the day, and attaches them as `metric_shares` to the CloudWatch usage-type buckets — + ingestion (`DataProcessing-Bytes`, vended logs: `cloudwatch.IncomingBytes`) and storage + (`TimedStorage-ByteHrs`: `cloudwatch.StoredBytes`), keyed by log group name. A `by_metric` rule + with `map.entries` {log group → application} hands the log cost to its application; groups nobody + owns (cluster/system logs) stay visible as `metric_key` unallocated rows. When the group name follows + `.[.suffix]` (the platform's convention) `wf1` also attaches `metric_owners` + {key → application} from the account's applications, so the rule needs no `entries` at all + (`wf2` uses `map.entries[key]` first, then `metric_owners[key]`). EMF custom metrics + (`MetricMonitorUsage`, `MetricStreamUsage`) get shares by the `*_agg` log groups' IncomingBytes per + application (`cloudwatch.EmfBytes`). Alarm, dashboard and request usage types carry no shares. + - `spread`: platform cost shared by EVERY application of the day, weighted by what each one already + carries (`target.spread.weights: attributed`, default) or equally (`equal`). Resolved after the + main pass, so the weights are the day's attribution, never the spread itself; the rollups and the + invoices show it as `allocation_method: spread`, `charge_type: application`, category from the + rule (`platform`). With no application attributed yet the leaf stays unallocated. Example, + AWS Config / GuardDuty / KMS / the CloudWatch remainder: + + ```json + { "id": "platform-spread", "priority": 900, "method": "spread", "category": "platform", + "scope": { "cloud_service": { "regex": "AWS Config|GuardDuty|Key Management|Inspector|Secrets Manager|Macie|CloudWatch|Virtual Private Cloud" } }, + "target": { "spread": { "weights": "attributed" } } } + ``` + Keep an equal-`split` rule with lower priority as the fallback for days without PI data. + - `category` overrides the cost category derived from the cloud service. + + Three defaults ship with the allocator, lowest priority: `default:null-service` (fact carries + `service_id` + `application_id`), `default:null-dims` (fact carries `application_id`/`scope_id` + from null tags), `default:cluster` (cluster components → the cluster, split by consumption in + phase 3). + + More examples: + + ```json + { "id": "security-compliance-platform", "priority": 10, + "scope": { "cloud_service": { "regex": "GuardDuty|Security Hub|Inspector|AWS Config|WAF" } }, + "target": { "bucket": "shared-platform" }, "category": "security" } + ``` + ```json + { "id": "log-groups-by-name", "priority": 30, "scope": { "resource_type": "logs:log-group" }, + "match": [{ "field": "subject_name", "regex": "^(?[a-z0-9-]+)\\.(?[a-z0-9-]+)$" }], + "target": { "capture": { "application_slug": "$app" } } } + ``` + ```json + { "id": "ids-in-tags", "priority": 900, + "match": [{ "field": "tags.application_id", "regex": "^[0-9]+$" }], + "target": { "capture": { "application_id": "tags.application_id", "scope_id": "tags.scope_id", "namespace_id": "tags.namespace_id" } } } + ``` + +3. **Suggestions** (`cost_mapping_suggestion`, `status: proposed`): after each allocation, the + inference workflow looks at what stayed unallocated and proposes rules with evidence — a null + service whose host matches (confidence 0.95), applications whose parameters reference the host + (0.7 for one consumer, 0.6 for several → an equal split to refine with a metric). Accepting one is + copying its `rule` into `cost_mapping_rule` with `status: active` (`setup/03-mapping-rules.sh` + does it from a JSON). + +What the allocator writes (`stage: allocated`, all in `cost_daily`): + +| Row | id | Meaning | +|---|---|---| +| leaf allocation | `alloc--` | one per source fact × owner: `cost_usd`, `share`, `rule_id`, `category`, `application_id`/`scope_id`/`service_id`/`cluster`/`bucket`, `source_fact_id` | +| application rollup | `alloc-app--` | `cost_usd`, `by_category`, `by_cloud_service`, `quantity` = facts | +| shared bucket rollup | `alloc-bucket--` | e.g. `shared-platform` | +| kubernetes | `alloc-bucket----app-` | a cluster component split by the scope's consumption share (`default:cluster-consumption`, category `kubernetes`, `scope_id` on the row) | +| kubernetes overhead | `alloc-bucket----cluster-` | the share no scope covers (system namespaces, idle headroom) | +| unallocated | `alloc-unallocated-` | `by_cloud_service` = the gap to close with rules | + +**The per-application entity** (an invoice): `GET /catalog/instances/application_cost_daily/-` +(or list `?application_id=` / `?day=`): + +```json +{ "id": "2044993572-2026-09-09", "date": "2026-09-09", "application_id": "2044993572", "application_slug": "entities-api", + "total_usd": 9.1, "currency": "USD", + "charge_items": [ + { "charge_type": "service", "service_id": "38879e91-…", "service_name": "oltp-database", "category": "database", "cloud_service": "Amazon Relational Database Service", "subject_id": "oltp-database", "cost_usd": 3.59, "share": 1, "allocation_method": "direct", "rule_id": "default:null-service" }, + { "charge_type": "scope", "scope_id": "596334143", "scope_name": "production", "scope_type": "web_pool_k8s", "category": "kubernetes", "cloud_service": "Amazon Elastic Compute Cloud - Compute", "subject_id": "runtime|nodes", "component": "nodes", "cost_usd": 2.1, "share": 0.013, "allocation_method": "by_metric", "rule_id": "default:cluster-consumption" }, + { "charge_type": "application", "category": "database", "subject_id": "postgres-approvals-api-db", "cost_usd": 1.99, "share": 0.06, "allocation_method": "by_metric", "rule_id": "rds-approvals-by-database-load" } + ], + "totals": { "by_charge_type": { "scope": 3.52, "service": 3.59, "application": 1.99 }, "by_category": { "database": 5.58, "kubernetes": 3.52 }, "by_cloud_service": { "…": "…" } }, + "consumption": { "596334143": { "core_h_chargeable": 96, "core_h_used": 22, "gb_h_chargeable": 380, "gb_h_used": 190, "usage_usd": 1.2, "waste_usd": 2.3 }, "total": { "…": "…", "scopes": 1 } } } +``` + +`charge_type` says through which null object the line was charged: a scope (any type — k8s, lambda, +custom/EC2 — always with its `scope_id`), a service, or the application itself (a queue, a bucket, a +shared database's share attributed by tag or rule without a null object). + +Other queries: `GET /catalog/instances/cost_daily?stage=allocated&application_id=` (allocated +facts of one app), `…&stage=allocated&subject_type=unallocated` (the gap), +`…&stage=raw&subject_type=cluster` (cluster rows with rates, shares and overhead). + +**Kubernetes**: the cluster's cost (nodes, control plane, LBs, networking, storage) is split by each +scope's consumption share = its chargeable cost (`max(usage, request)` per hour × blended rates) +over the cluster cost. What no scope covers stays with the cluster as `kubernetes_overhead`. The +scope rows (`raw-k8s-scope-*`, `source: k8s`) keep usage vs request, so waste per scope is visible. + +### Step chain and the 1 MB step output + +`wf2` runs `prep → read_raw + read_rules + scopes + services → allocate → (gate → write_facts) + +(invoices → invoice_gate → write_invoices) → summary`. The code sandbox caps a step's output at +1 MB, so the allocated facts leave `allocate` exactly once (the gates only decide whether the +fan-outs write, for `dry_run`) and the invoices are built in their own step from those facts. +For nullplatform the outputs are ~850 KB (752 facts) and ~450 KB (48 invoices): a customer with +several hundred scopes will need `allocate` to write in chunks instead of returning the facts. +The summary reports `cluster_pending_usd` (clusters without consumption shares yet) separately +from `kubernetes_overhead_usd` (what no scope covered once the shares exist). + +Allocated ids are `alloc--` plus the metric key for `by_metric` rows +(`…-app--` / `…-app--`), so an application with several scopes on the +same cluster component gets one row per scope (they used to collide and the upsert kept one). +After the writes, a **stale sweep** (`read_allocated → stale → delete_stale`) deletes the day's +`allocated` rows this run did not produce (older allocator revisions, rules that stopped matching), +so re-allocating a day never double counts in the lake; `summary.stale_deleted` says how many. + +`wf1` does the same for its own raw rows (`source: aws_ce`, never wf3's `k8s` rows), so a +resource that disappears or a collector revision that renames ids leaves nothing behind. + +### Writes go in batches, never one child per row + +The upsert child (`wf-cost-fact-upsert.yaml`) takes `facts[]` (plus `dry_run`) and fans out the +PATCHes inside; the collectors and the allocator send batches of 40 (invoices 25). A per-row +sub-workflow fan-out (752 children for one day) pushed the parent's Temporal history to 14 MB and +its workflow tasks past the 10 s timeout, which re-issues child starts in a loop. The read steps +carry `output_projection` for the same reason. Spec grants: every principal may create/write/ +delete `cost_daily`, `application_cost_daily`, `cost_mapping_rule` and `cost_mapping_suggestion` +entities — the workflows write with the organization's API key, which is not the spec's owner. + +## Dashboard + +`setup/report-finops.py --spec-id ` generates the "FinOps — Costos por +aplicación" dynamic report (Lake-backed): filters period / account / application / environment / +charge type / null service / scope type; KPIs (total = applications + shared platform + Kubernetes +overhead + unallocated); daily stacked bars by charge type; top applications; donuts by +environment, cloud service and category; tables per application, per invoice item (with the day) +and shared/unallocated. See `docs/customer-onboarding.md § 6` for the query rules it encodes. + +## Deploying to an organization (done for nullplatform, org 4, 2026-09-11) + +Everything is per organization; nothing is registered on the platform as a package. + +1. **Worker identity** (once per cluster). The controlplane-agent spawns the collector as + a pod in its worker namespace; without an identity of its own the pod runs as the NODE + role. Give it a read-only role + ServiceAccount + an agent rule: + - IaC (nullplatform's own runtime): `iac-null-runtime` `iam/roles.tf` `k8s_np_finops_worker` + (IRSA) + `k8s/np_finops_worker.tf` (SA `np-workers/np-finops-worker`). + - Any other cluster: `setup/02-aws-worker-identity.sh --cluster ` (Pod Identity) + or `docs/iam/` by hand. + - Agent: `NP_ALLOWED_REGISTRIES` must include `public.ecr.aws/nullplatform/*` and + `NP_WORKER_RULES` must map the image to the SA: + `[{"match":{"registry":"public.ecr.aws/nullplatform/agent-plugins/workflows/aws-cost-explorer","package":"cloud-query"},"serviceAccount":"np-finops-worker"}]` + (restart the agent). Verify with a sync `sts GetCallerIdentity` through the tool. +2. **Catalog specs**: `NP_TOKEN= setup/01-catalog-spec.sh` creates/updates + `cost_daily`, `cost_mapping_rule`, `cost_mapping_suggestion` (the admin grant is rewritten to the + token's user). Create the specs BEFORE the first collection: the catalog silently drops + attributes the spec does not declare. +3. **Secret**: `POST /workflows/config {"name":"NP_API_KEY","value":…,"secret":true,"path":"/finops"}` + with a session bearer (an org API key with catalog + agent_command grants). +4. **Per-org values**: copy `setup/vars.nullplatform.json` — agent tags + `agent_nrn` + (REQUIRED when the agent is registered under an account: the control plane does not + find account-level agents from the organization root), `org_nrn`, dispatcher targets. +5. **Publish** (from the engine repo, so the DSL parser resolves): + `NP_TOKEN= pnpm tsx finops/setup/publish.ts finops --base https://api.nullplatform.com --vars finops/setup/vars..json` + and later `--update =,…` for new revisions — **always with all seven ids**: a + partial list would publish the omitted files as NEW definitions (with a live cron on the + dispatcher copy); the script refuses that unless `--allow-create` is passed. The dispatcher + (`wf0`) owns the schedule (04:15 UTC): collect → k8s consumption (`k8s_clusters` in the vars) → + allocate → suggest. `wf1`/`wf2`/`wf3` have no cron of their own. +6. **Rules**: start from `setup/rules..json` (copy the nullplatform one) and load it with + `setup/03-mapping-rules.sh`; everything else comes from the suggestions. +7. **First run**: execute `wf0` with `{"date":"YYYY-MM-DD","dry_run":true}`, read the + child summaries (collection, allocation, suggestions), then run it for real. Rows: `GET /catalog/instances/cost_daily?stage=raw&subject_type=cluster` + (the `date` query filter is ignored by the catalog list API today — filter on other + fields or query the lake). + +### itti / tuti (org 1049493649, account 1041301647 → AWS 985539773184, 2026-09-11) + +Second organization, and the one that shaped most of the model: + +- **Agent**: `np-agent-itti-tuti-sdlc` upgraded to 0.11.1 the same day (0.6.0 has no `package-exec`); + identity = the agent's pod-identity role (`npagents-itti-tuti-sdlc-assumed-pod-identity-npagent`), + enough for Cost Explorer, EC2/RDS describes, tagging, Performance Insights and CloudWatch Logs. + `ListMetricStreams` and `ListCostAllocationTags` (linked account) are denied. +- **Billing shape** (09-10, 89.38 USD, amortized = unblended, no Savings Plans): EKS cluster + `eks-1-tuti-null-use1-dev` 50% (Spot nodes, no Karpenter, 6–28 nodes/hour), CloudWatch 19% (67% of it + a metric stream, 25% EMF custom metrics), AWS Config 12%, Aurora + DocumentDB 10%. +- **No Cost Explorer resource-level data**: `wf1` prices the cluster from the `INSTANCE_TYPE` rows + (running nodes → cluster by type, else the account's sole cluster). +- **Pod metrics from New Relic** (`collector_mode: newrelic`, account 6332316): one NRQL per cluster-day + over `K8sContainerSample` FACET `label.scope_id` × hour; 29 scopes; `scope_usage_daily` per scope. +- **Databases without null services**: the 6 null services of the account are an S3 bucket, an Amplify + scope and placeholders without `host`; `aurora-main` / `elasticache-main` / the DocumentDB service were + deleted months ago. Rules come from the applications' parameters (`DB_HOST` + `DB_NAME` → exact + Aurora database, `DBM_HOST` → DocumentDB `db`, `REDIS_HOST` → ElastiCache consumers) and PI + `db.load` shares; charged as `service` / `service_kind: cloud`. +- **CloudWatch**: logs and EMF metrics to their application (`.` naming), the + metric stream + Config + GuardDuty/KMS/Inspector/Secrets/Macie + VPC as `spread` platform cost. +- **Result**: 94.8% attributed on 09-10 (84.74 of 89.38), 94.6% on 09-09, 86.8% on 09-08 (6.6 USD of + Kubernetes overhead: capacity no scope requested that day). Unattributed ≈ 3.3 USD/day (EC2 Other, + ELB, SQS, cluster/RDS log groups). +- **Vars**: `setup/vars.itti.json` (target `dimensions: {environment: development}` — the whole AWS + account is one dimension value, stamped on application-level charges); rules `setup/rules.itti.json`. +- Dashboard `645e6dbf-b83b-4b4a-97ab-215ab1912d4d` (published), see `docs/customer-onboarding.md` §6. + +### What nullplatform's first day looked like (2026-09-09, account 283477532906) + +275 rows: 38 `cloud_service`, 222 `bucket` (usage types + cluster components + one +`ec2-instances-unattributed`), 9 `scope`, 7 `service` (RDS clusters, 2 mapped to null +services by host), 1 `cluster`, 1 `resource`. Total 383.20 USD amortized. + +Known gap: Karpenter nodes that terminated before collection are not in `DescribeInstances`, +so their cost (539 instance-days, 64.74 USD) lands in `ec2-instances-unattributed` instead +of the cluster's `nodes` component, and the blended rates come out high. Fix: activate +`aws:eks:cluster-name` as a cost allocation tag (Billing → Cost allocation tags; today only +`application`, `namespace`, `scope` are active) and group the EC2 resource query by that +tag — terminated instances keep their tags in Cost Explorer. diff --git a/finops/__tests__/allocate-daily.e2e.test.ts b/finops/__tests__/allocate-daily.e2e.test.ts new file mode 100644 index 0000000..ca75dab --- /dev/null +++ b/finops/__tests__/allocate-daily.e2e.test.ts @@ -0,0 +1,274 @@ +/** + * E2E for finops/wf2-allocate-daily.yaml: raw facts + mapping rules → allocated facts. + * Catalog reads are stubbed at the `np-entity-paginated-fetch` level, the upsert child at + * the `sub-workflow` level. + */ +import { resolve } from 'node:path'; +import { runWorkflowE2E } from '@nullplatform/workflow-kit/test'; +import { describe, expect, it } from 'vitest'; + +const YAML = resolve(__dirname, '..', 'wf2-allocate-daily.yaml'); +const D = '2026-09-09'; + +const passthroughTrigger = { + handler: () => ({ status: 'success' as const, outputs: {}, activePorts: ['default'] }), + registryType: 'trigger' as const, +}; + +const svc = (name: string, id: string, cost: number) => ({ + id: `raw-cloud_service-${id}-${D}`, date: D, day: D, stage: 'raw', subject_type: 'cloud_service', subject_id: id, subject_name: name, + cloud: 'aws', cloud_account: '111122223333', cloud_service: name, cost_usd: cost, +}); +const EC2 = 'Amazon Elastic Compute Cloud - Compute'; +const RDS = 'Amazon Relational Database Service'; +const VPC = 'Amazon Virtual Private Cloud'; +const GD = 'Amazon GuardDuty'; +const CW = 'AmazonCloudWatch'; + +const RAW = [ + svc(EC2, 'ec2', 10), svc(RDS, 'rds', 30), svc(VPC, 'vpc', 3), svc(GD, 'guardduty', 1), svc(CW, 'cloudwatch', 4), + // EC2: a null scope (tags → dims from wf1), a cluster nodes component, the unattributed bucket → remainder 2 + { id: `raw-scope-777-${D}`, date: D, day: D, stage: 'raw', subject_type: 'scope', subject_id: '777', subject_name: 'ns.app.prod', cloud: 'aws', cloud_service: EC2, parent_id: `raw-cloud_service-ec2-${D}`, + cost_usd: 2, scope_id: '777', application_id: '100', namespace_id: '5', account_id: '1', tags: { scope_id: '777', application_id: '100' }, resource_id: 'i-1', resource_type: 'ec2:instance' }, + { id: `raw-bucket-runtime-nodes-${D}`, date: D, day: D, stage: 'raw', subject_type: 'bucket', subject_id: 'runtime|nodes', subject_name: 'runtime / nodes', cloud: 'aws', cloud_service: EC2, cluster: 'runtime', component: 'nodes', parent_id: `raw-cluster-runtime-${D}`, cost_usd: 5 }, + { id: `raw-bucket-ec2-instances-unattributed-${D}`, date: D, day: D, stage: 'raw', subject_type: 'bucket', subject_id: 'ec2-instances-unattributed', subject_name: 'EC2 instances not attributable', cloud: 'aws', cloud_service: EC2, parent_id: `raw-cloud_service-ec2-${D}`, cost_usd: 1, resource_type: 'ec2:instance' }, + { id: `raw-cluster-runtime-${D}`, date: D, day: D, stage: 'raw', subject_type: 'cluster', subject_id: 'runtime', cloud: 'aws', cluster: 'runtime', cost_usd: 8, + metric: 'k8s.chargeable', metric_shares: { 777: 0.5, 999: 0.25 }, metric_owners: { 777: { application_id: '100', namespace_id: '5', scope_id: '777' }, 999: { application_id: '400', scope_id: '999' } } }, + // k8s consumption rows (wf3) — informational for the allocator, summarized into application_cost_daily.kubernetes + { id: `raw-k8s-scope-777-${D}`, date: D, day: D, stage: 'raw', subject_type: 'scope', subject_id: '777', cloud: 'aws', source: 'k8s', cluster: 'runtime', application_id: '100', scope_id: '777', cost_usd: 4, core_h_chargeable: 48, gb_h_chargeable: 96, core_h_used: 10, gb_h_used: 40, usage_usd: 1, waste_usd: 3 }, + { id: `raw-bucket-runtime-networking-${D}`, date: D, day: D, stage: 'raw', subject_type: 'bucket', subject_id: 'runtime|networking', subject_name: 'runtime / networking', cloud: 'aws', cloud_service: VPC, cluster: 'runtime', component: 'networking', parent_id: `raw-cluster-runtime-${D}`, cost_usd: 3 }, + // RDS: one cluster mapped by wf1 (host = null service), one shared cluster with no owner + { id: `raw-service-orders-db-${D}`, date: D, day: D, stage: 'raw', subject_type: 'service', subject_id: 'orders-db', subject_name: 'Orders DB', cloud: 'aws', cloud_service: RDS, parent_id: `raw-cloud_service-rds-${D}`, + cost_usd: 12, service_id: 'svc-1', application_id: '100', namespace_id: '5', account_id: '1', host: 'orders-db.cluster-abc.us-east-1.rds.amazonaws.com', resource_type: 'rds:cluster', tags: { application: 'orders' } }, + { id: `raw-service-shared-db-${D}`, date: D, day: D, stage: 'raw', subject_type: 'service', subject_id: 'shared-db', subject_name: 'shared-db', cloud: 'aws', cloud_service: RDS, parent_id: `raw-cloud_service-rds-${D}`, + cost_usd: 8, host: 'shared-db.cluster-abc.us-east-1.rds.amazonaws.com', resource_type: 'rds:cluster', tags: { application: 'shared' } }, + // a cluster with Performance Insights shares by database (by_metric rule below): 60% orders, 30% ledger, 10% scratch (no owner) + { id: `raw-service-metrics-db-${D}`, date: D, day: D, stage: 'raw', subject_type: 'service', subject_id: 'metrics-db', subject_name: 'metrics-db', cloud: 'aws', cloud_service: RDS, parent_id: `raw-cloud_service-rds-${D}`, + cost_usd: 10, host: 'metrics-db.cluster-abc.us-east-1.rds.amazonaws.com', resource_type: 'rds:cluster', metric: 'pi.db.load', metric_shares: { orders: 0.6, ledger: 0.3, scratch: 0.1 } }, + // usage-type buckets are informational — must not be allocated (they overlap the leaves) + { id: `raw-bucket-rds-aurora-storageio-${D}`, date: D, day: D, stage: 'raw', subject_type: 'bucket', subject_id: 'rds|Aurora:StorageIOUsage', cloud: 'aws', cloud_service: RDS, parent_id: `raw-cloud_service-rds-${D}`, usage_type: 'Aurora:StorageIOUsage', cost_usd: 7 }, + // CloudWatch: a log-group resource named . + { id: `raw-resource-loggroup-catalog-entities-api-${D}`, date: D, day: D, stage: 'raw', subject_type: 'resource', subject_id: 'catalog.entities-api', subject_name: 'catalog.entities-api', cloud: 'aws', cloud_service: CW, parent_id: `raw-cloud_service-cloudwatch-${D}`, resource_type: 'logs:log-group', cost_usd: 1.5 }, + // yesterday's row must be ignored + { id: 'raw-cloud_service-ec2-2026-09-08', date: '2026-09-08', day: '2026-09-08', stage: 'raw', subject_type: 'cloud_service', subject_id: 'ec2', subject_name: EC2, cloud: 'aws', cloud_service: EC2, cost_usd: 99 }, +]; + +const RULES = [ + { id: 'security-is-platform', name: 'security → platform', enabled: true, status: 'active', priority: 10, scope: { cloud_service: { regex: 'GuardDuty|Security Hub' } }, target: { bucket: 'shared-platform' }, category: 'security' }, + { id: 'shared-db-by-database', name: 'shared Aurora split', enabled: true, status: 'active', priority: 20, scope: { resource_type: 'rds:cluster' }, + match: [{ field: 'host', equals: 'shared-db.cluster-abc.us-east-1.rds.amazonaws.com' }], + method: 'split', target: { split: [{ weight: 3, target: { application_id: '100', namespace_id: '5' } }, { weight: 1, target: { application_id: '200', namespace_id: '6' } }] } }, + { id: 'metrics-db-by-load', name: 'metrics-db by database load', enabled: true, status: 'active', priority: 25, scope: { resource_type: 'rds:cluster' }, + match: [{ field: 'host', equals: 'metrics-db.cluster-abc.us-east-1.rds.amazonaws.com' }, { field: 'metric', equals: 'pi.db.load' }], + method: 'by_metric', target: { map: { key: 'db.name', entries: { orders: { application_id: '100', namespace_id: '5' }, ledger: { application_id: '300' } } } } }, + { id: 'log-groups-by-name', name: 'log groups .', enabled: true, status: 'active', priority: 30, scope: { resource_type: 'logs:log-group' }, + match: [{ field: 'subject_name', regex: '^(?[a-z0-9-]+)\\.(?[a-z0-9-]+)$' }], target: { capture: { application_slug: '$app' } } }, + { id: 'disabled-rule', name: 'would steal everything', enabled: false, status: 'active', priority: 1, target: { bucket: 'nope' } }, + { id: 'proposed-rule', name: 'not active yet', enabled: true, status: 'proposed', priority: 1, target: { bucket: 'nope' } }, +]; + +const SCOPES_API = [ + { id: 777, name: 'prod', type: 'web_pool_k8s', dimensions: { environment: 'production' } }, + { id: 999, name: 'stage', type: 'web_pool_k8s', dimensions: { environment: 'stage' } }, +]; +const SERVICES_API = { results: [{ id: 'svc-1', name: 'Orders DB', dimensions: { environment: 'production' } }] }; +const servicesStub = { handler: () => ({ status: 'success' as const, outputs: { status: 200, body: SERVICES_API }, activePorts: ['default'] }), executeMode: 'all' as const }; +function lakeStub(raw = RAW) { + return { handler: (ctx: { inputs: Record }) => { expect(String(ctx.inputs.sql)).toContain("JSONExtractString(data, 'day') = '"); return { status: 'success' as const, outputs: { rows: raw.map((f) => ({ data: JSON.stringify(f) })), rowCount: raw.length }, activePorts: ['default'] }; }, executeMode: 'all' as const }; +} +function fetchStub(raw = RAW, rules = RULES) { + return { + handler: (ctx: { stepId: string; inputs: Record }) => { + const items = ctx.stepId === 'read_rules' ? rules : ctx.stepId === 'scopes' ? SCOPES_API : []; + return { status: 'success' as const, outputs: { items, totalFetched: items.length, pages: 1 }, activePorts: ['default'] }; + }, + executeMode: 'all' as const, + }; +} + +describe('finops/wf2-allocate-daily', () => { + it('allocates every leaf, reconciles with the service totals, and rolls up per application', async () => { + const written: Array> = []; + const result = await runWorkflowE2E({ + yamlPath: YAML, + inputs: { date: D }, + pluginStubs: { + manual: passthroughTrigger, cron: passthroughTrigger, + 'np-entity-paginated-fetch': fetchStub(), 'np-lake-query': lakeStub(), + 'np-api-call': servicesStub, + 'sub-workflow': { + handler: (ctx: { inputs: Record }) => { + // batch child: { facts[], catalog_slug, dry_run } + const facts = ctx.inputs.facts as Array>; + expect(facts.length).toBeLessThanOrEqual(40); + if (!ctx.inputs.dry_run) for (const f of facts) written.push({ ...f, _slug: ctx.inputs.catalog_slug }); + return { status: 'success' as const, outputs: { count: facts.length, written: ctx.inputs.dry_run ? 0 : facts.length, ids: facts.map((f) => f.id) }, activePorts: ['default'] }; + }, + executeMode: 'all' as const, + }, + }, + }); + // a real run returns no batches (they would sit in the parent's history): read what the upsert children got + expect(result.outputs?.batches).toEqual([]); + const facts = written.filter((w) => w._slug === 'cost_daily'); + const summary = result.outputs?.summary as Record; + const byId = Object.fromEntries(facts.map((f) => [f.id, f])); + + // leaves: scope, nodes, unattributed, EC2 remainder 2, orders-db, shared-db, networking, GuardDuty remainder, log group, CloudWatch remainder 2.5 + expect(summary.leaves).toBe(11); + expect(summary.total_usd).toBe(48); + expect(summary.rules_active).toBe(4); + + // default:null-dims — the scope row already carries the owner + expect(byId[`alloc-scope-777-${D}-app-100`]).toMatchObject({ stage: 'allocated', application_id: '100', scope_id: '777', cost_usd: 2, share: 1, rule_id: 'default:null-dims', category: 'compute', source_fact_id: `raw-scope-777-${D}` }); + // default:null-service — host mapped by wf1 + expect(byId[`alloc-service-orders-db-${D}-app-100`]).toMatchObject({ application_id: '100', service_id: 'svc-1', cost_usd: 12, rule_id: 'default:null-service', category: 'database' }); + // split rule 3:1 on the shared cluster + expect(byId[`alloc-service-shared-db-${D}-app-100`]).toMatchObject({ application_id: '100', cost_usd: 6, share: 0.75, allocation_method: 'split', rule_id: 'shared-db-by-database' }); + expect(byId[`alloc-service-shared-db-${D}-app-200`]).toMatchObject({ application_id: '200', cost_usd: 2, share: 0.25 }); + // by_metric: Performance Insights shares → owners; the share nobody owns stays visibly unallocated + expect(byId[`alloc-service-metrics-db-${D}-app-100-orders`]).toMatchObject({ application_id: '100', cost_usd: 6, share: 0.6, allocation_method: 'by_metric', rule_id: 'metrics-db-by-load' }); + expect(byId[`alloc-service-metrics-db-${D}-app-300-ledger`]).toMatchObject({ application_id: '300', cost_usd: 3, share: 0.3 }); + expect(byId[`alloc-service-metrics-db-${D}-scratch-unallocated`]).toMatchObject({ cost_usd: 1, share: 0.1, allocation_method: 'unallocated', metric_key: 'scratch', rule_id: 'metrics-db-by-load' }); + // regex capture → application_slug + expect(byId[`alloc-resource-loggroup-catalog-entities-api-${D}-app-entities-api`]).toMatchObject({ application_slug: 'entities-api', cost_usd: 1.5, rule_id: 'log-groups-by-name', category: 'observability' }); + // cluster components split by the consumption metric on the cluster row; the uncovered share is k8s overhead + expect(byId[`alloc-bucket-runtime-nodes-${D}-app-100-777`]).toMatchObject({ application_id: '100', scope_id: '777', cost_usd: 2.5, share: 0.5, allocation_method: 'by_metric', rule_id: 'default:cluster-consumption', category: 'kubernetes' }); + expect(byId[`alloc-bucket-runtime-nodes-${D}-app-400-999`]).toMatchObject({ application_id: '400', cost_usd: 1.25 }); + expect(byId[`alloc-bucket-runtime-nodes-${D}-cluster-runtime-kubernetes-overhead`]).toMatchObject({ cluster: 'runtime', cost_usd: 1.25, share: 0.25, allocation_method: 'kubernetes_overhead' }); + expect(byId[`alloc-bucket-runtime-networking-${D}-app-100-777`]).toMatchObject({ cost_usd: 1.5, category: 'kubernetes' }); + // security → shared bucket with the rule's category + expect(byId[`alloc-cloud_service-guardduty-${D}-remainder-bucket-shared-platform`]).toMatchObject({ bucket: 'shared-platform', cost_usd: 1, category: 'security', rule_id: 'security-is-platform' }); + // what nobody claims + expect(byId[`alloc-bucket-ec2-instances-unattributed-${D}-unallocated`]).toMatchObject({ cost_usd: 1, allocation_method: 'unallocated' }); + expect(byId[`alloc-cloud_service-ec2-${D}-remainder-unallocated`]).toMatchObject({ cost_usd: 2 }); + expect(byId[`alloc-cloud_service-cloudwatch-${D}-remainder-unallocated`]).toMatchObject({ cost_usd: 2.5 }); + // usage-type buckets and the cluster row are not allocation leaves + expect(facts.some((f) => String(f.source_fact_id ?? '').includes('storageio'))).toBe(false); + expect(facts.some((f) => f.source_fact_id === `raw-cluster-runtime-${D}`)).toBe(false); + + // rollups + expect(byId[`alloc-app-100-${D}`]).toMatchObject({ subject_type: 'application', application_id: '100', cost_usd: 30, by_category: { compute: 2, database: 24, kubernetes: 4 }, quantity: 6 }); + expect(byId[`alloc-app-300-${D}`]).toMatchObject({ cost_usd: 3 }); + expect(byId[`alloc-app-400-${D}`]).toMatchObject({ cost_usd: 2 }); + // application_cost_daily: one INVOICE per application — flat charge items with the null object each came through + const appRows = written.filter((w) => w._slug === 'application_cost_daily'); + type Item = Record; + const app100 = appRows.find((r) => r.id === `100-${D}`) as { total_usd: number; charge_items: Item[]; totals: { by_charge_type: Record; by_category: Record; by_cloud_service: Record } }; + expect(app100).toMatchObject({ date: D, day: D, application_id: '100', namespace_id: '5', total_usd: 30, currency: 'USD', charge_items_count: 6, cloud_accounts: ['111122223333'] }); + // shared-db has no null service but is a database → charged as a service (service_kind cloud) + expect(app100.totals).toEqual({ by_charge_type: { scope: 6, service: 24 }, by_environment: { production: 18, none: 12 }, by_category: { compute: 2, database: 24, kubernetes: 4 }, by_cloud_service: { [EC2]: 4.5, [RDS]: 24, [VPC]: 1.5 } }); + expect(app100.charge_items.map((i) => [i.charge_type, i.subject_id, i.cost_usd])).toEqual([ + ['service', 'orders-db', 12], ['service', 'shared-db', 6], ['service', 'metrics-db', 6], ['scope', 'runtime|nodes', 2.5], ['scope', '777', 2], ['scope', 'runtime|networking', 1.5], + ]); + expect(app100.charge_items[0]).toMatchObject({ charge_type: 'service', service_id: 'svc-1', service_name: 'Orders DB', scope_id: null, category: 'database', rule_id: 'default:null-service', share: 1, dimensions: { environment: 'production' }, environment: 'production' }); + expect(app100.charge_items[3]).toMatchObject({ charge_type: 'scope', scope_id: '777', scope_name: 'prod', scope_type: 'web_pool_k8s', environment: 'production', component: 'nodes', cluster: 'runtime', category: 'kubernetes', allocation_method: 'by_metric', rule_id: 'default:cluster-consumption', share: 0.5 }); + expect(app100.charge_items[1]).toMatchObject({ environment: null, dimensions: null }); + // the allocated fact rows carry the dimensions too + expect(byId[`alloc-scope-777-${D}-app-100`]).toMatchObject({ dimensions: { environment: 'production' }, environment: 'production' }); + expect(app100.charge_items[4]).toMatchObject({ charge_type: 'scope', scope_id: '777', subject_type: 'scope', category: 'compute', rule_id: 'default:null-dims' }); + expect(app100.charge_items[1]).toMatchObject({ charge_type: 'service', scope_id: null, service_id: 'cloud:shared-db', service_kind: 'cloud', allocation_method: 'split', rule_id: 'shared-db-by-database' }); + const sum = Object.values(app100.totals.by_charge_type).reduce((a, b) => a + b, 0); + expect(sum).toBeCloseTo(app100.total_usd, 6); + expect(appRows.map((r) => r.id).sort()).toEqual([`100-${D}`, `200-${D}`, `300-${D}`, `400-${D}`]); + expect((appRows.find((r) => r.id === `400-${D}`) as { charge_items: Item[] }).charge_items[0]).toMatchObject({ charge_type: 'scope', scope_id: '999', cost_usd: 1.25 }); + expect(written.filter((w) => (w as { id: string }).id === `100-${D}`)).toHaveLength(1); + expect(byId[`alloc-app-200-${D}`]).toMatchObject({ cost_usd: 2 }); + expect(byId[`alloc-bucket-shared-platform-${D}`]).toMatchObject({ bucket: 'shared-platform', cost_usd: 1 }); + expect(byId[`alloc-unallocated-${D}`]).toMatchObject({ subject_type: 'unallocated', cost_usd: 6.5, by_cloud_service: { [EC2]: 3, [CW]: 2.5, [RDS]: 1 } }); + + // Σ leaves = Σ services = allocated + cluster + unallocated + const leafRows = facts.filter((f) => f.allocation_method !== 'rollup' && f.subject_type !== 'unallocated'); + expect(leafRows.reduce((s, f) => s + Number(f.cost_usd), 0)).toBeCloseTo(48, 6); + expect(summary.unallocated_usd).toBe(6.5); + // wf3 published shares for the cluster: nothing is pending, what no scope covers is overhead + expect(summary.cluster_pending_usd).toEqual({}); + expect(summary.kubernetes_overhead_usd).toEqual({ runtime: 2 }); + expect(summary.allocated_usd).toBeCloseTo(39.5, 6); + expect((summary.applications as Array<{ owner: string; cost_usd: number }>)[0]).toMatchObject({ owner: 'app-100', cost_usd: 30 }); + + // everything was written (allocated facts + one application_cost_daily row per app), every fact carries the required keys + expect(written).toHaveLength(facts.length + 4); + expect(summary.written).toBe(facts.length + 4); + for (const f of facts) for (const k of ['id', 'date', 'day', 'stage', 'subject_type', 'subject_id', 'cloud', 'cost_usd', 'source', 'allocation_method', 'collected_at']) expect(f[k], `${f.id}.${k}`).toBeDefined(); + const unl = result.outputs?.unallocated_leaves as Array>; + expect(unl.map((u) => u.id)).toEqual([`raw-cloud_service-cloudwatch-${D}-remainder`, `raw-cloud_service-ec2-${D}-remainder`, `raw-bucket-ec2-instances-unattributed-${D}`, `raw-service-metrics-db-${D}#scratch`]); + expect(unl[3]).toMatchObject({ kind: 'metric_key', metric_key: 'scratch', cost_usd: 1, rule_id: 'metrics-db-by-load', host: 'metrics-db.cluster-abc.us-east-1.rds.amazonaws.com' }); + }); + + it('dry_run computes everything and writes nothing; no raw facts is an error', async () => { + const written: unknown[] = []; + const stub = { handler: (ctx: { inputs: Record }) => { if (!ctx.inputs.dry_run) written.push(...(ctx.inputs.facts as unknown[])); return { status: 'success' as const, outputs: { written: 0 }, activePorts: ['default'] }; }, executeMode: 'all' as const }; + const r = await runWorkflowE2E({ yamlPath: YAML, inputs: { date: D, dry_run: true }, pluginStubs: { manual: passthroughTrigger, cron: passthroughTrigger, 'np-entity-paginated-fetch': fetchStub(), 'np-lake-query': lakeStub(), 'np-api-call': servicesStub, 'sub-workflow': stub } }); + expect(written).toHaveLength(0); + expect((r.outputs?.summary as { written: number; dry_run: boolean })).toMatchObject({ written: 0, dry_run: true }); + await expect( + runWorkflowE2E({ yamlPath: YAML, inputs: { date: '2026-01-01' }, pluginStubs: { manual: passthroughTrigger, cron: passthroughTrigger, 'np-entity-paginated-fetch': fetchStub([], []), 'np-lake-query': lakeStub([]), 'np-api-call': servicesStub, 'sub-workflow': stub } }), + ).rejects.toThrow(/no raw facts for 2026-01-01/); + }); + + it('spread: a platform service is shared by every application, weighted by what each one already carries', async () => { + const CONFIG = 'AWS Config'; + const raw = [...RAW, { id: `raw-cloud_service-config-${D}`, date: D, day: D, stage: 'raw', subject_type: 'cloud_service', subject_id: CONFIG, subject_name: CONFIG, cloud: 'aws', cloud_service: CONFIG, cost_usd: 10 }]; + const rules = [...RULES, { id: 'config-is-platform-spread', name: 'AWS Config → every app', enabled: true, status: 'active', priority: 20, scope: { cloud_service: { regex: 'AWS Config' } }, method: 'spread', target: { spread: { weights: 'attributed' } }, category: 'platform' }]; + const stub = { handler: (ctx: { inputs: Record }) => { const facts = ctx.inputs.facts as Array>; return { status: 'success' as const, outputs: { count: facts.length, written: 0, ids: facts.map((f) => f.id) }, activePorts: ['default'] }; }, executeMode: 'all' as const }; + const run = (r: typeof raw, ru: typeof rules) => runWorkflowE2E({ yamlPath: YAML, inputs: { date: D, dry_run: true }, pluginStubs: { manual: passthroughTrigger, cron: passthroughTrigger, 'np-entity-paginated-fetch': fetchStub(r, ru), 'np-lake-query': lakeStub(r), 'np-api-call': servicesStub, 'sub-workflow': stub } }); + const base = (await run(RAW, RULES)).outputs?.summary as Record; + const result = await run(raw, rules); + const summary = result.outputs?.summary as Record; + const facts = (result.outputs?.batches as Array<{ facts: Array> }>).flatMap((b) => b.facts); + const byId = Object.fromEntries(facts.map((f) => [f.id, f])); + expect(summary.total_usd).toBe(Number(base.total_usd) + 10); + expect(summary.platform_spread_usd).toBe(10); + expect(summary.unallocated_usd).toBe(base.unallocated_usd); // nothing new is unallocated: the spread lands on the apps + // weights = each application's attribution BEFORE the spread (the baseline run), never the spread itself + const baseApps = base.applications as Array<{ owner: string; application_id: string | null; cost_usd: number }>; + const appsBase = baseApps.filter((a) => a.application_id); // owners known only by slug get nothing: the spread needs an application id + const tot = appsBase.reduce((s, a) => s + a.cost_usd, 0); + const spread = facts.filter((a) => a.allocation_method === 'spread'); + expect(spread.length).toBe(appsBase.length); + expect(Math.round(spread.reduce((s, a) => s + Number(a.cost_usd), 0) * 1e6) / 1e6).toBe(10); + for (const a of appsBase) { + const row = byId[`alloc-cloud_service-config-${D}-remainder-${a.owner}-spread`]; + expect(row).toMatchObject({ allocation_method: 'spread', category: 'platform', charge_type: 'application', rule_id: 'config-is-platform-spread', cloud_service: CONFIG }); + expect(Number(row?.cost_usd)).toBeCloseTo((10 * a.cost_usd) / tot, 5); + } + // the rollup and the by_category of each app include their share + const app100base = appsBase.find((a) => a.owner === 'app-100') as { cost_usd: number }; + expect(Number((byId[`alloc-app-100-${D}`] as Record).cost_usd)).toBeCloseTo(app100base.cost_usd + (10 * app100base.cost_usd) / tot, 5); + expect(((byId[`alloc-app-100-${D}`] as Record).by_category as Record).platform).toBeCloseTo((10 * app100base.cost_usd) / tot, 5); + // a spread rule when no application has cost yet → the leaf stays visibly unallocated + const lonely = await run([raw[raw.length - 1] as (typeof raw)[number]], rules); + expect((lonely.outputs?.summary as Record).unallocated_usd).toBe(10); + }); + + it('usage-type buckets with collector shares are leaves: by_metric resolves owners from metric_owners without a map', async () => { + const CW = 'AWS X-Ray'; // not in RAW (RAW already has a CloudWatch service with an unallocated remainder) + const raw = [...RAW, + { id: `raw-cloud_service-cw-${D}`, date: D, day: D, stage: 'raw', subject_type: 'cloud_service', subject_id: CW, subject_name: CW, cloud: 'aws', cloud_service: CW, cost_usd: 10 }, + { id: `raw-bucket-cw-ingest-${D}`, date: D, day: D, stage: 'raw', subject_type: 'bucket', subject_id: 'cw|DataProcessing-Bytes', subject_name: 'cw / ingest', cloud: 'aws', cloud_service: CW, parent_id: `raw-cloud_service-cw-${D}`, usage_type: 'DataProcessing-Bytes', cost_usd: 4, + metric: 'cloudwatch.IncomingBytes', metric_shares: { 'ns.orders': 0.5, 'ns.ledger': 0.25, '/aws/eks/x/cluster': 0.25 }, metric_owners: { 'ns.orders': { application_id: '100', namespace_id: '5' }, 'ns.ledger': { application_id: '300' } } }, + { id: `raw-bucket-cw-alarms-${D}`, date: D, day: D, stage: 'raw', subject_type: 'bucket', subject_id: 'cw|AlarmMonitorUsage', cloud: 'aws', cloud_service: CW, parent_id: `raw-cloud_service-cw-${D}`, usage_type: 'AlarmMonitorUsage', cost_usd: 6 }, + ]; + const rules = [...RULES, + { id: 'cw-logs-by-group', name: 'logs → app of the group', enabled: true, status: 'active', priority: 100, scope: { cloud_service: { regex: 'X-Ray' }, usage_type: { regex: 'DataProcessing-Bytes' } }, match: [{ field: 'metric_shares', exists: true }], method: 'by_metric', category: 'observability', target: { map: { key: 'log_group', entries: {} } } }, + { id: 'cw-rest-spread', name: 'rest of X-Ray → every app', enabled: true, status: 'active', priority: 900, scope: { cloud_service: { regex: 'X-Ray' } }, method: 'spread', category: 'observability', target: { spread: { weights: 'equal' } } }, + ]; + const stub = { handler: (ctx: { inputs: Record }) => { const facts = ctx.inputs.facts as Array>; return { status: 'success' as const, outputs: { count: facts.length, written: 0, ids: facts.map((f) => f.id) }, activePorts: ['default'] }; }, executeMode: 'all' as const }; + const result = await runWorkflowE2E({ yamlPath: YAML, inputs: { date: D, dry_run: true }, pluginStubs: { manual: passthroughTrigger, cron: passthroughTrigger, 'np-entity-paginated-fetch': fetchStub(raw, rules), 'np-lake-query': lakeStub(raw), 'np-api-call': servicesStub, 'sub-workflow': stub } }); + const summary = result.outputs?.summary as Record; + const facts = (result.outputs?.batches as Array<{ facts: Array> }>).flatMap((b) => b.facts); + const byId = Object.fromEntries(facts.map((f) => [f.id, f])); + expect(summary.total_usd).toBe(58); + // the ingest bucket is a leaf (4): 2 → app 100, 1 → app 300 (owners from the collector), 1 → unallocated with its key + expect(byId[`alloc-bucket-cw-ingest-${D}-app-100-ns-orders`]).toMatchObject({ application_id: '100', cost_usd: 2, share: 0.5, allocation_method: 'by_metric', rule_id: 'cw-logs-by-group', category: 'observability', charge_type: 'application' }); + expect(byId[`alloc-bucket-cw-ingest-${D}-app-300-ns-ledger`]).toMatchObject({ application_id: '300', cost_usd: 1 }); + expect(byId[`alloc-bucket-cw-ingest-${D}-aws-eks-x-cluster-unallocated`]).toMatchObject({ cost_usd: 1, allocation_method: 'unallocated', metric_key: '/aws/eks/x/cluster' }); + // the alarms bucket has no shares → informational; the service remainder (10 − 4 = 6) goes to the spread rule + expect(facts.find((f) => String(f.id).startsWith(`alloc-bucket-cw-alarms-`))).toBeUndefined(); + const spread = facts.filter((f) => f.allocation_method === 'spread' && f.cloud_service === CW); + expect(Math.round(spread.reduce((s, f) => s + Number(f.cost_usd), 0) * 1e6) / 1e6).toBe(6); + const base = await runWorkflowE2E({ yamlPath: YAML, inputs: { date: D, dry_run: true }, pluginStubs: { manual: passthroughTrigger, cron: passthroughTrigger, 'np-entity-paginated-fetch': fetchStub(), 'np-lake-query': lakeStub(), 'np-api-call': servicesStub, 'sub-workflow': stub } }); + expect(summary.unallocated_usd).toBe(Number((base.outputs?.summary as Record).unallocated_usd) + 1); + }); +}); diff --git a/finops/__tests__/aws-billing-daily.e2e.test.ts b/finops/__tests__/aws-billing-daily.e2e.test.ts new file mode 100644 index 0000000..372f9eb --- /dev/null +++ b/finops/__tests__/aws-billing-daily.e2e.test.ts @@ -0,0 +1,436 @@ +/** + * E2E for finops/wf1-aws-billing-daily.yaml + finops/wf-cost-fact-upsert.yaml. + * The cloud-query child is stubbed at the `sub-workflow` plugin level with + * realistic Cost Explorer / EC2 shapes; the fact writer child is captured so + * the test asserts WHAT would be upserted (ids, dimensions, sum rule). + */ +import { resolve } from 'node:path'; +import { runWorkflowE2E } from '@nullplatform/workflow-kit/test'; +import { describe, expect, it } from 'vitest'; + +const DIR = resolve(__dirname, '..'); +const COLLECTOR = resolve(DIR, 'wf1-aws-billing-daily.yaml'); +const UPSERT = resolve(DIR, 'wf-cost-fact-upsert.yaml'); + +const passthroughTrigger = { + handler: () => ({ status: 'success' as const, outputs: {}, activePorts: ['default'] }), + registryType: 'trigger' as const, +}; + +const EC2 = 'Amazon Elastic Compute Cloud - Compute'; +const EKS = 'Amazon Elastic Container Service for Kubernetes'; +const ELB = 'Amazon Elastic Load Balancing'; +const VPC = 'Amazon Virtual Private Cloud'; +const EC2O = 'EC2 - Other'; +const RDS = 'Amazon Relational Database Service'; + +function ceGroups(groups: Array<[string[], number, number?]>, unit = 'Hrs') { + return { + ResultsByTime: [ + { + TimePeriod: { Start: '2026-09-09', End: '2026-09-10' }, + Groups: groups.map(([keys, cost, qty]) => ({ + Keys: keys, + Metrics: { + AmortizedCost: { Amount: String(cost), Unit: 'USD' }, + UnblendedCost: { Amount: String(cost * 0.5), Unit: 'USD' }, + ...(qty !== undefined ? { UsageQuantity: { Amount: String(qty), Unit: unit } } : {}), + }, + })), + }, + ], + }; +} + +const CW = 'AmazonCloudWatch'; +const RESULTS = { + identity: { Account: '688720756067' }, + by_service: ceGroups([[[EC2], 12.5], [[EKS], 2.4], [['Amazon Simple Storage Service'], 0.6], [[ELB], 2.0], [[VPC], 3.0], [[EC2O], 4.0], [[RDS], 1.0], [[CW], 3.0]]), + by_usage_type: ceGroups([ + [[EC2, 'BoxUsage:c5a.xlarge'], 7.4, 24], + [[EC2, 'BoxUsage:t3.micro'], 5.1, 72], + [[EKS, 'AmazonEKS-Hours:perCluster'], 2.4, 24], + [['Amazon Simple Storage Service', 'TimedStorage-ByteHrs'], 0.6, 10], + [[ELB, 'LoadBalancerUsage'], 2.0, 96], + [[VPC, 'USE1-VpcEndpoint-Hours'], 2.0, 144], + [[VPC, 'USE1-PublicIPv4:InUseAddress'], 1.0, 312], + [[EC2O, 'EBS:VolumeUsage.gp3'], 2.0, 20], + [[EC2O, 'NatGateway-Hours'], 1.5, 24], + [[EC2O, 'CPUCredits:t3'], 0.5, 5], + [[RDS, 'Aurora:StorageUsage'], 1.0, 1], + [[CW, 'DataProcessing-Bytes'], 2.0, 40], + [[CW, 'TimedStorage-ByteHrs'], 0.5, 300], + [[CW, 'MetricMonitorUsage'], 0.5, 12], + ]), + // CloudWatch Logs: log groups + stored bytes (phase 1); IncomingBytes per group comes in phase 2 + log_groups: { logGroups: [{ logGroupName: 'nullplatform.orders', storedBytes: 300 }, { logGroupName: 'nullplatform.ledger.http_agg', storedBytes: 100 }, { logGroupName: '/aws/eks/runtime/cluster', storedBytes: 0 }, { logGroupName: 'nullplatform.orders.sys_agg', storedBytes: 0 }] }, + ec2_by_resource: ceGroups([ + [['i-node1'], 3.7, 24], + [['i-node2'], 3.7, 24], + [['i-scope1'], 0.25, 24], + [['i-loose'], 4.85, 24], + [['i-gone', 'c5a.xlarge'], 3.0, 12], // a Karpenter node that terminated before collection: type known from Cost Explorer + [['NoResourceId'], 0, 0], + ]), + volumes: { + Volumes: [ + { VolumeId: 'vol-n1', Size: 20, VolumeType: 'gp3', Attachments: [{ InstanceId: 'i-node1' }] }, + { VolumeId: 'vol-n2', Size: 20, VolumeType: 'gp3', Attachments: [{ InstanceId: 'i-node2' }] }, + { VolumeId: 'vol-s1', Size: 10, VolumeType: 'gp3', Attachments: [{ InstanceId: 'i-scope1' }] }, + { VolumeId: 'vol-x', Size: 30, VolumeType: 'gp3', Attachments: [] }, + ], + }, + tagged: { + ResourceTagMappingList: [ + { ResourceARN: 'arn:aws:elasticloadbalancing:us-east-1:1:loadbalancer/app/k8s-a/1', Tags: [{ Key: 'elbv2.k8s.aws/cluster', Value: 'developent' }] }, + { ResourceARN: 'arn:aws:elasticloadbalancing:us-east-1:1:loadbalancer/net/k8s-b/2', Tags: [{ Key: 'elbv2.k8s.aws/cluster', Value: 'developent' }] }, + { ResourceARN: 'arn:aws:rds:us-east-1:1:cluster:transactions', Tags: [{ Key: 'application_id', Value: '111' }, { Key: 'application', Value: 'payments' }] }, + ], + }, + lbs: { LoadBalancers: [{ LoadBalancerName: 'k8s-a' }, { LoadBalancerName: 'k8s-b' }, { LoadBalancerName: 'null-main' }, { LoadBalancerName: 'other' }] }, + // RDS by owner tag × usage type: the tagged part goes to the cluster tagged payments, the untagged rest is spread in proportion + rds_by_tag: ceGroups([[['application$payments', 'InstanceUsage:db.r6g.large'], 0.8], [['application$', 'Aurora:StorageUsage'], 0.2]]), + db_clusters: { DBClusters: [{ DBClusterIdentifier: 'transactions', DBClusterArn: 'arn:aws:rds:us-east-1:1:cluster:transactions', Engine: 'aurora-mysql', TagList: [], Endpoint: 'transactions.cluster-abc.us-east-1.rds.amazonaws.com', + DBClusterMembers: [{ DBInstanceIdentifier: 'transactions-writer', IsClusterWriter: true }, { DBInstanceIdentifier: 'transactions-reader', IsClusterWriter: false }] }] }, + db_instances: { DBInstances: [ + { DBInstanceIdentifier: 'transactions-writer', DBClusterIdentifier: 'transactions', DbiResourceId: 'db-WRITER', PerformanceInsightsEnabled: true }, + { DBInstanceIdentifier: 'transactions-reader', DBClusterIdentifier: 'transactions', DbiResourceId: 'db-READER', PerformanceInsightsEnabled: true }, + ] }, + instances: { + Reservations: [ + { + Instances: [ + { InstanceId: 'i-node1', InstanceType: 'c5a.xlarge', Tags: [{ Key: 'eks:cluster-name', Value: 'developent' }] }, + { InstanceId: 'i-node2', InstanceType: 'c5a.xlarge', Tags: [{ Key: 'kubernetes.io/cluster/developent', Value: 'owned' }] }, + { + InstanceId: 'i-scope1', + InstanceType: 't3.micro', + Tags: [ + { Key: 'scope_id', Value: '2001580306' }, + { Key: 'scope', Value: 'development-uruguay' }, + { Key: 'application_id', Value: '854932899' }, + { Key: 'application', Value: 'sebas-correa-ec-2-logs' }, + { Key: 'namespace_id', Value: '156896248' }, + { Key: 'namespace', Value: 'sebas-correa-ec-2' }, + ], + }, + { InstanceId: 'i-loose', InstanceType: 't3.micro', Tags: [{ Key: 'Name', Value: 'jumper2' }] }, + ], + }, + ], + }, +}; + +/** null services as returned by GET /service?show_descendants=true (the DB maps by host). */ +const NP_SERVICES = { + results: [ + { id: 'c80961c8', name: 'transactions', slug: 'transactions', type: 'dependency', entity_nrn: 'organization=1255165411:account=95118862:namespace=1649279267:application=1469122850', + attributes: { host: 'TRANSACTIONS.cluster-abc.us-east-1.rds.amazonaws.com', port: 3306 }, dimensions: { environment: 'production' } }, + { id: 'other', name: 'Lending DB', type: 'dependency', entity_nrn: 'organization=1255165411:account=95118862:namespace=1:application=2', attributes: { hostname: '172.20.78.58' } }, + ], +}; +/** applications + namespaces of the account: `.` names the platform's log groups / EMF namespaces */ +const NP_APPS = { results: [{ id: 4242, slug: 'orders', namespace_id: 77 }, { id: 4343, slug: 'ledger', namespace_id: 77 }, { id: 4444, slug: 'orphan', namespace_id: 99 }] }; +const NP_NAMESPACES = { results: [{ id: 77, slug: 'nullplatform' }] }; +const npApiStub = { + handler: (ctx: { stepId: string }) => ({ status: 'success' as const, outputs: { status: 200, body: ctx.stepId === 'np_apps' ? NP_APPS : ctx.stepId === 'np_namespaces' ? NP_NAMESPACES : NP_SERVICES }, activePorts: ['default'] }), + executeMode: 'all' as const, +}; + +const TYPES_RESULTS = { + // Performance Insights db.load by database on the cluster WRITER (second round) + pi_transactions: { MetricList: [ + { Key: { Metric: 'db.load.avg' }, DataPoints: [{ Value: 1.0 }] }, + { Key: { Metric: 'db.load.avg', Dimensions: { 'db.name': 'orders', 'db.id': 'x' } }, DataPoints: [{ Value: 0.75 }] }, + { Key: { Metric: 'db.load.avg', Dimensions: { 'db.name': 'ledger', 'db.id': 'y' } }, DataPoints: [{ Value: 0.25 }] }, + ] }, + cw_logs_in_0: { MetricDataResults: [{ Id: 'q0', Values: [80, 20] }, { Id: 'q1', Values: [300] }, { Id: 'q2', Values: [900] }, { Id: 'q3', Values: [700] }] }, + instance_types: { + InstanceTypes: [ + { InstanceType: 'c5a.xlarge', VCpuInfo: { DefaultVCpus: 4 }, MemoryInfo: { SizeInMiB: 8192 } }, + { InstanceType: 't3.micro', VCpuInfo: { DefaultVCpus: 2 }, MemoryInfo: { SizeInMiB: 1024 } }, + ], + }, +}; + +/** The two cloud-query calls of the collector, keyed by step id. */ +function cloudQueryStub(seen: Array> = []) { + return { + handler: (ctx: { stepId: string; inputs: Record }) => { + seen.push({ ...ctx.inputs, __step: ctx.stepId }); + const results = ctx.stepId === 'query_types' ? TYPES_RESULTS : RESULTS; + return { + status: 'success' as const, + outputs: { results, identity: { account: '688720756067' }, failed: [] }, + activePorts: ['default'], + }; + }, + executeMode: 'all' as const, + }; +} + +// The stale sweep reads the day's existing raw rows; nothing pre-exists in these fixtures. +const emptyFetchStub = { handler: () => ({ status: 'success' as const, outputs: { items: [], totalFetched: 0, pages: 1 }, activePorts: ['default'] }), executeMode: 'all' as const }; + +describe('finops/wf1-aws-billing-daily', () => { + it('builds dimensioned facts whose cloud_service sum equals the daily total, and fans out one upsert per fact', async () => { + const queries: Array> = []; + const written: Array> = []; + const result = await runWorkflowE2E({ + yamlPath: COLLECTOR, + inputs: { date: '2026-09-09', agent_tags: { package: 'cloud-query', local: 'x' }, agent_nrn: 'organization=1255165411:account=95118862', assume_role_arn: 'arn:aws:iam::111122223333:role/np-finops', assume_role_external_id: 'kwik-ext', package_version: '0.0.1', expected_account: '688720756067', target_name: 'kwik' }, + pluginStubs: { + manual: passthroughTrigger, + cron: passthroughTrigger, // wf1 has no cron since the dispatcher schedules it; harmless + 'np-api-call': npApiStub, + 'np-entity-paginated-fetch': emptyFetchStub, + 'sub-workflow': { + handler: (ctx: { stepId: string; inputs: Record }) => { + if (ctx.stepId === 'query' || ctx.stepId === 'query_types') return cloudQueryStub(queries).handler(ctx); + const facts = ctx.inputs.facts as Array>; + if (!ctx.inputs.dry_run) written.push(...facts); + return { status: 'success' as const, outputs: { count: facts.length, written: ctx.inputs.dry_run ? 0 : facts.length, ids: facts.map((f) => f.id) }, activePorts: ['default'] }; + }, + executeMode: 'all' as const, + }, + }, + }); + const facts = result.outputs?.facts as Array>; + const summary = result.outputs?.summary as Record; + + // two cloud-query rounds: the day's calls, then the instance types seen + expect(queries.map((q) => q.__step)).toEqual(['query', 'query_types']); + const calls = queries[0]?.calls as Array<{ id: string; params?: { TimePeriod?: { Start: string; End: string } } }>; + expect(calls.map((c) => c.id)).toEqual(['identity', 'by_service', 'by_usage_type', 'log_groups', 'ec2_by_resource', 'rds_by_tag', 'instances', 'volumes', 'tagged', 'lbs', 'db_clusters', 'db_instances', 'svc_by_tag_aws-lambda', 'fn_tags']); + expect(calls[4]?.params?.GroupBy).toEqual([{ Type: 'DIMENSION', Key: 'RESOURCE_ID' }, { Type: 'DIMENSION', Key: 'INSTANCE_TYPE' }]); + expect(calls[5]?.params?.GroupBy).toEqual([{ Type: 'TAG', Key: 'application' }, { Type: 'DIMENSION', Key: 'USAGE_TYPE' }]); + expect(calls[3]).toMatchObject({ service: 'logs', paginate: true }); + expect(calls[1]?.params?.TimePeriod).toEqual({ Start: '2026-09-09', End: '2026-09-10' }); + expect(queries[0]?.agent_tags).toEqual({ package: 'cloud-query', local: 'x' }); + expect(queries.map((q) => q.agent_nrn)).toEqual(['organization=1255165411:account=95118862', 'organization=1255165411:account=95118862']); + for (const q of queries) { + expect(q.assume_role_arn).toBe('arn:aws:iam::111122223333:role/np-finops'); + expect(q.assume_role_external_id).toBe('kwik-ext'); + expect(q.package_version).toBe('0.0.1'); + } + expect(summary.target).toBe('kwik'); + const typeCalls = queries[1]?.calls as Array<{ id: string; params: { InstanceTypes: string[] } }>; + expect(typeCalls[0]?.params.InstanceTypes).toEqual(['c5a.xlarge', 't3.micro']); + + // sum rule over cloud_service (8 services, 28.5 amortized) + const svc = facts.filter((f) => f.subject_type === 'cloud_service'); + expect(svc).toHaveLength(8); + expect(svc.reduce((a, f) => a + (f.cost_usd as number), 0)).toBeCloseTo(28.5, 6); + expect(summary.daily_total_usd).toBeCloseTo(28.5, 6); + + // usage-type buckets are breakdowns of their service fact + const utBuckets = facts.filter((f) => f.subject_type === 'bucket' && f.usage_type); + expect(utBuckets).toHaveLength(14); + expect(utBuckets.find((b) => b.usage_type === 'BoxUsage:c5a.xlarge')?.parent_id).toBe(`raw-cloud_service-amazon-elastic-compute-cloud-compute-2026-09-09`); + + // tagged scope: direct_tag with null dimensions from the instance tags + const scope = facts.find((f) => f.subject_type === 'scope'); + expect(scope).toMatchObject({ + id: 'raw-scope-2001580306-2026-09-09', + allocation_method: 'direct_tag', + scope_id: '2001580306', + scope_name: 'development-uruguay', + application_id: '854932899', + application_name: 'sebas-correa-ec-2-logs', + namespace_id: '156896248', + namespace_name: 'sebas-correa-ec-2', + cloud_account: '688720756067', + region: 'us-east-1', + resource_id: 'i-scope1', + resource_type: 'ec2:instance', + cost_usd: 0.25, + unblended_usd: 0.125, + source: 'aws_ce', + }); + + // cluster nodes get NO row of their own: their cost is the cluster's `nodes` component + expect(facts.filter((f) => f.subject_type === 'resource' && f.cluster === 'developent')).toEqual([]); + expect(facts.filter((f) => f.subject_type === 'resource' && f.resource_type === 'ec2:instance')).toEqual([]); + + // cluster = nodes 7.4 + control plane 2.4 + LBs 2.0×(2/4)=1.0 + networking (VPC 3.0 + NAT 1.5)=4.5 + // + storage EBS 2.0×(40/80)=1.0 + other CPUCredits 0.5×(2/4)=0.25 → 16.55 + const cluster = facts.find((f) => f.subject_type === 'cluster'); + expect(cluster).toMatchObject({ id: 'raw-cluster-developent-2026-09-09', cluster: 'developent', allocation_method: 'unallocated', quantity: 3, units: 'nodes' }); + // + the terminated node (3.0) attributed by its type → 19.55 + expect(cluster?.cost_usd).toBeCloseTo(19.55, 6); + expect(cluster?.quantity).toBe(3); + expect(cluster?.instances_seen).toBe(2); + const comps = Object.fromEntries(facts.filter((f) => f.subject_type === 'bucket' && f.component).map((b) => [b.component, b.cost_usd])); + // networking is split by the cloud service it comes from (VPC 3.0 vs EC2-Other NAT 1.5) so the allocator can reconcile per service + expect(comps).toEqual({ nodes: 7.4, nodes_terminated: 3.0, control_plane: 2.4, load_balancers: 1.0, networking: 3.0, networking_ec2: 1.5, storage: 1.0, other: 0.25 }); + for (const b of facts.filter((f) => f.subject_type === 'bucket' && f.component)) { + expect(b.parent_id).toBe(cluster?.id); + expect(typeof b.cloud_service).toBe('string'); + } + // every fact carries `day` (a filterable copy of `date`) and evidence for mapping rules where it exists + for (const f of facts) expect(f.day).toBe('2026-09-09'); + expect(facts.find((f) => f.subject_type === 'scope')?.tags).toMatchObject({ scope_id: expect.any(String) }); + expect(facts.find((f) => f.subject_type === 'service' && f.subject_id === 'transactions')?.host).toMatch(/rds\.amazonaws\.com$/); + // second round asked Performance Insights for the cluster WRITER, and the shares by database travel with the fact + const round2 = queries[1]?.calls as Array<{ id: string; service: string; params: Record }>; + expect(round2.map((c) => c.id)).toEqual(['instance_types', 'pi_transactions', 'cw_logs_in_0']); + expect(round2[2]).toMatchObject({ service: 'cloudwatch', params: { StartTime: '2026-09-09T00:00:00Z', EndTime: '2026-09-10T00:00:00Z' } }); + expect((round2[2]?.params.MetricDataQueries as Array<{ Id: string; MetricStat: { Metric: { Dimensions: Array<{ Value: string }> } } }>).map((q) => [q.Id, q.MetricStat.Metric.Dimensions[0]?.Value])).toEqual([['q0', 'nullplatform.orders'], ['q1', 'nullplatform.ledger.http_agg'], ['q2', '/aws/eks/runtime/cluster'], ['q3', 'nullplatform.orders.sys_agg']]); + // CloudWatch Logs buckets carry the per-log-group shares — ingestion by IncomingBytes (100 / 300 / 900 / 700 = 2000), + // storage by storedBytes (300 / 100) — plus the owner of every group that follows . + const owners = { 'nullplatform.orders': { application_id: '4242', namespace_id: '77', application_slug: 'orders', namespace_slug: 'nullplatform' }, 'nullplatform.ledger.http_agg': { application_id: '4343', application_slug: 'ledger' }, 'nullplatform.orders.sys_agg': { application_id: '4242' } }; + const ingest = facts.find((f) => f.cloud_service === CW && f.usage_type === 'DataProcessing-Bytes') as Record; + expect(ingest).toMatchObject({ metric: 'cloudwatch.IncomingBytes', metric_shares: { 'nullplatform.orders': 0.05, 'nullplatform.ledger.http_agg': 0.15, '/aws/eks/runtime/cluster': 0.45, 'nullplatform.orders.sys_agg': 0.35 }, metric_owners: owners }); + expect(Object.keys(ingest.metric_owners as object)).not.toContain('/aws/eks/runtime/cluster'); + expect(facts.find((f) => f.cloud_service === CW && f.usage_type === 'TimedStorage-ByteHrs')).toMatchObject({ metric: 'cloudwatch.StoredBytes', metric_shares: { 'nullplatform.orders': 0.75, 'nullplatform.ledger.http_agg': 0.25 } }); + // EMF custom metrics / metric stream: shares by the *_agg IncomingBytes per application (300 ledger vs 700 orders), owners by app key + expect(facts.find((f) => f.cloud_service === CW && f.usage_type === 'MetricMonitorUsage')).toMatchObject({ metric: 'cloudwatch.EmfBytes', metric_shares: { 'nullplatform.orders': 0.7, 'nullplatform.ledger': 0.3 }, metric_owners: { 'nullplatform.orders': { application_id: '4242' }, 'nullplatform.ledger': { application_id: '4343' } } }); + expect(round2[1]).toMatchObject({ service: 'pi', params: { Identifier: 'db-WRITER', StartTime: '2026-09-09T00:00:00Z', EndTime: '2026-09-10T00:00:00Z', PeriodInSeconds: 86400 } }); + expect(facts.find((f) => f.subject_id === 'transactions')).toMatchObject({ metric: 'pi.db.load', metric_shares: { orders: 0.75, ledger: 0.25 } }); + + // blended rates over capacity: 2 × c5a.xlarge × 24 h + the terminated one × 12 h = 240 core-h, 480 GiB-h; cpu_share 0.5 + expect(cluster?.cpu_capacity_core_h).toBe(240); + expect(cluster?.mem_capacity_gb_h).toBe(480); + expect(cluster?.cpu_share).toBe(0.5); + expect(cluster?.rate_cpu_usd_core_h).toBeCloseTo((19.55 * 0.5) / 240, 6); + expect(cluster?.rate_mem_usd_gb_h).toBeCloseTo((19.55 * 0.5) / 480, 6); + expect((summary.clusters as Array>)[0]).toMatchObject({ cluster: 'developent', cost_usd: 19.55, nodes: 2, lbs: 2, ebs_gb: 40 }); + + // the Aurora cluster becomes a service fact carrying the RDS cost, mapped to the null service by host + // (case-insensitive) → owner application from the service NRN, allocation_method service_owner + const db = facts.find((f) => f.subject_type === 'service'); + expect(db).toMatchObject({ + id: 'raw-service-transactions-2026-09-09', resource_type: 'rds:cluster', usage_type: 'aurora-mysql', cost_usd: 1.0, + service_id: 'c80961c8', service_name: 'transactions', application_id: '1469122850', namespace_id: '1649279267', account_id: '95118862', + environment: 'production', nrn: 'organization=1255165411:account=95118862:namespace=1649279267:application=1469122850', allocation_method: 'service_owner', + }); + + // instances that are neither nodes nor scopes (terminated before collection, untagged) + // collapse into ONE bucket per day; NoResourceId is dropped + const loose = facts.find((f) => f.subject_id === 'ec2-instances-unattributed'); + expect(loose).toMatchObject({ subject_type: 'bucket', cost_usd: 4.85, quantity: 1, units: 'instances', usage_hours: 24, allocation_method: 'unallocated', resource_type: 'ec2:instance' }); + expect(facts.some((f) => f.resource_id === 'i-loose')).toBe(false); + expect(facts.some((f) => f.resource_id === 'NoResourceId')).toBe(false); + + // every fact carries the required keys and provenance + for (const f of facts) { + for (const k of ['id', 'date', 'stage', 'subject_type', 'subject_id', 'cloud', 'cost_usd', 'source', 'allocation_method', 'collected_at']) { + expect(f[k], `${String(f.id)} missing ${k}`).toBeDefined(); + } + expect(f.collector).toBe('finops_aws_billing_daily@0.2.0'); + } + + // one upsert per fact + expect(written).toHaveLength(facts.length); + expect(summary.written).toBe(facts.length); + }); + + it('dry_run builds the facts and writes nothing', async () => { + const written: Array> = []; + const result = await runWorkflowE2E({ + yamlPath: COLLECTOR, + inputs: { date: '2026-09-09', dry_run: true }, + pluginStubs: { + manual: passthroughTrigger, + cron: passthroughTrigger, // wf1 has no cron since the dispatcher schedules it; harmless + 'np-api-call': npApiStub, + 'np-entity-paginated-fetch': emptyFetchStub, + 'sub-workflow': { + handler: (ctx: { stepId: string; inputs: Record }) => { + if (ctx.stepId === 'query' || ctx.stepId === 'query_types') return cloudQueryStub().handler(ctx); + if (!ctx.inputs.dry_run) written.push(...(ctx.inputs.facts as unknown[])); + return { status: 'success' as const, outputs: {}, activePorts: ['default'] }; + }, + executeMode: 'all' as const, + }, + }, + }); + expect((result.outputs?.facts as unknown[]).length).toBeGreaterThan(5); + expect(written).toHaveLength(0); + expect((result.outputs?.summary as { written: number; dry_run: boolean }).written).toBe(0); + expect((result.outputs?.summary as { dry_run: boolean }).dry_run).toBe(true); + }); + + it('fails when the worker identity is not the expected account (wrong agent/role pairing)', async () => { + await expect( + runWorkflowE2E({ + yamlPath: COLLECTOR, + inputs: { date: '2026-09-09', expected_account: '999999999999', target_name: 'other' }, + pluginStubs: { + manual: passthroughTrigger, + cron: passthroughTrigger, + 'np-api-call': npApiStub, + 'np-entity-paginated-fetch': emptyFetchStub, + 'np-entity-paginated-fetch': emptyFetchStub, + 'sub-workflow': { + handler: (ctx: { stepId: string; inputs: Record }) => cloudQueryStub().handler(ctx), + executeMode: 'all' as const, + }, + }, + }), + ).rejects.toThrow(/worker identity is account 688720756067 but target other expects 999999999999/); + }); + + it('fails when a cloud-query call failed instead of writing partial facts', async () => { + await expect( + runWorkflowE2E({ + yamlPath: COLLECTOR, + inputs: { date: '2026-09-09' }, + pluginStubs: { + manual: passthroughTrigger, + cron: passthroughTrigger, + 'np-api-call': npApiStub, + 'np-entity-paginated-fetch': emptyFetchStub, + 'np-entity-paginated-fetch': emptyFetchStub, + 'sub-workflow': { + handler: () => ({ status: 'success' as const, outputs: { results: {}, identity: null, failed: ['by_service'] }, activePorts: ['default'] }), + executeMode: 'all' as const, + }, + }, + }), + ).rejects.toThrow(/cloud-query calls failed: by_service/); + }); +}); + +describe('finops/wf-cost-fact-upsert', () => { + it('PATCHes the catalog instance with upsert=true and the fact as body', async () => { + const calls: Array> = []; + const fact = { + id: 'raw-cloud_service-amazon-s3-2026-09-09', date: '2026-09-09', stage: 'raw', subject_type: 'cloud_service', subject_id: 'amazon-s3', + cloud: 'aws', cost_usd: 0.6, source: 'aws_ce', allocation_method: 'unallocated', collected_at: '2026-09-10T00:00:00Z', + }; + const result = await runWorkflowE2E({ + yamlPath: UPSERT, + inputs: { fact }, + pluginStubs: { + manual: passthroughTrigger, + 'np-api-call': { + handler: (ctx: { inputs: Record }) => { + calls.push(ctx.inputs); + return { status: 'success' as const, outputs: { status: 200, body: { id: fact.id } }, activePorts: ['default'] }; + }, + executeMode: 'all' as const, + }, + }, + }); + expect(calls).toHaveLength(1); + expect(calls[0]?.path).toBe('/catalog/instances/cost_daily/raw-cloud_service-amazon-s3-2026-09-09'); + expect(calls[0]?.body).toEqual(fact); + expect(result.outputs).toMatchObject({ count: 1, written: 1, ids: [fact.id] }); + }); + + it('rejects a fact missing required keys before calling the API', async () => { + const calls: unknown[] = []; + await expect( + runWorkflowE2E({ + yamlPath: UPSERT, + inputs: { fact: { id: 'x', date: '2026-09-09' } }, + pluginStubs: { + manual: passthroughTrigger, + 'np-api-call': { handler: () => { calls.push(1); return { status: 'success' as const, outputs: {}, activePorts: ['default'] }; }, executeMode: 'all' as const }, + }, + }), + ).rejects.toThrow(/fact is missing/); + expect(calls).toHaveLength(0); + }); +}); diff --git a/finops/__tests__/aws-billing-dispatch.e2e.test.ts b/finops/__tests__/aws-billing-dispatch.e2e.test.ts new file mode 100644 index 0000000..76b1103 --- /dev/null +++ b/finops/__tests__/aws-billing-dispatch.e2e.test.ts @@ -0,0 +1,115 @@ +/** + * E2E for finops/wf0-aws-billing-dispatch.yaml: one collector run per target + * (account × agent × role × package version), summary per account. The + * collector child is stubbed at the `sub-workflow` plugin level. + */ +import { resolve } from 'node:path'; +import { runWorkflowE2E } from '@nullplatform/workflow-kit/test'; +import { describe, expect, it } from 'vitest'; + +const YAML = resolve(__dirname, '..', 'wf0-aws-billing-dispatch.yaml'); + +const passthroughTrigger = { + handler: () => ({ status: 'success' as const, outputs: {}, activePorts: ['default'] }), + registryType: 'trigger' as const, +}; + +const TARGETS = [ + { + name: 'prod', + agent_tags: { package: 'cloud-query', account: 'prod' }, + agent_nrn: 'organization=4:account=17', + org_nrn: 'organization=4', + assume_role_arn: 'arn:aws:iam::111122223333:role/np-finops', + assume_role_external_id: 'ext-prod', + package_version: '0.0.1', + expected_account: '111122223333', + }, + { name: 'dev', agent_tags: { package: 'cloud-query', account: 'dev' }, region: 'us-west-2' }, +]; + +describe('finops/wf0-aws-billing-dispatch', () => { + it('fans out one collector run per target with its agent, role and version, and summarizes per account', async () => { + const runs: Array> = []; + const allocations: Array> = []; + const k8sRuns: Array> = []; + const suggestions: Array> = []; + const result = await runWorkflowE2E({ + yamlPath: YAML, + inputs: { date: '2026-09-09', targets: TARGETS, dry_run: true, k8s_clusters: [{ cluster: 'runtime' }] }, + pluginStubs: { + manual: passthroughTrigger, + cron: passthroughTrigger, + 'sub-workflow': { + handler: (ctx: { stepId: string; inputs: Record }) => { + if (ctx.stepId === 'k8s') { + k8sRuns.push(ctx.inputs); + return { status: 'success' as const, outputs: { summary: { cluster: 'runtime', scopes_cost_usd: 20, overhead_usd: 5, coverage_pct: 80 } }, activePorts: ['default'] }; + } + if (ctx.stepId === 'allocate') { + allocations.push(ctx.inputs); + return { status: 'success' as const, outputs: { summary: { day: '2026-09-09', total_usd: 42.816, allocated_usd: 30, unallocated_usd: 12.816 }, unallocated_leaves: [{ id: 'raw-x', cost_usd: 12.816 }] }, activePorts: ['default'] }; + } + if (ctx.stepId === 'suggest') { + suggestions.push(ctx.inputs); + return { status: 'success' as const, outputs: { summary: { suggestions: 1, recovers_usd: 12.816 } }, activePorts: ['default'] }; + } + runs.push(ctx.inputs); + const acct = ctx.inputs.target_name === 'prod' ? '111122223333' : '444455556666'; + return { + status: 'success' as const, + outputs: { + summary: { day: '2026-09-09', account: acct, daily_total_usd: ctx.inputs.target_name === 'prod' ? 37.316 : 5.5, facts: 116, written: 0, clusters: [{ cluster: 'x' }] }, + }, + activePorts: ['default'], + }; + }, + executeMode: 'all' as const, + }, + }, + }); + expect(runs).toHaveLength(2); + expect(runs[0]).toMatchObject({ + date: '2026-09-09', + agent_tags: { package: 'cloud-query', account: 'prod' }, + agent_nrn: 'organization=4:account=17', + org_nrn: 'organization=4', + assume_role_arn: 'arn:aws:iam::111122223333:role/np-finops', + assume_role_external_id: 'ext-prod', + package_version: '0.0.1', + expected_account: '111122223333', + target_name: 'prod', + dry_run: true, + }); + expect(runs[1]).toMatchObject({ agent_tags: { package: 'cloud-query', account: 'dev' }, region: 'us-west-2', target_name: 'dev' }); + expect(runs[1]?.assume_role_arn ?? null).toBeNull(); + expect(runs[1]?.agent_nrn ?? null).toBeNull(); + + const out = result.outputs?.summary as { collection: { targets: number; total_usd: number; accounts: Array> }; allocation: Record; suggestions: Record }; + const summary = out.collection; + expect(summary.targets).toBe(2); + expect(summary.total_usd).toBeCloseTo(42.816, 6); + expect(summary.accounts.map((a) => [a.target, a.account])).toEqual([['prod', '111122223333'], ['dev', '444455556666']]); + // collection → allocation (same day, same dry_run) → suggestions fed with the unallocated leaves + expect(allocations).toHaveLength(1); + expect(allocations[0]).toMatchObject({ date: '2026-09-09', dry_run: true }); // spreadItem also passes `index`/`run`, ignored by the child + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toMatchObject({ date: '2026-09-09', dry_run: true, unallocated_leaves: [{ id: 'raw-x', cost_usd: 12.816 }] }); + expect(out.allocation).toMatchObject({ allocated_usd: 30, unallocated_usd: 12.816 }); + // one wf3 run per configured cluster, before the allocation + expect(k8sRuns).toHaveLength(1); + expect(k8sRuns[0]).toMatchObject({ date: '2026-09-09', cluster: 'runtime', dry_run: true }); + expect((out as { kubernetes: Array> }).kubernetes).toEqual([{ cluster: 'runtime', scopes_cost_usd: 20, overhead_usd: 5, coverage_pct: 80 }]); + expect(out.suggestions).toMatchObject({ suggestions: 1 }); + }); + + it('refuses to run without targets or with a target lacking agent_tags', async () => { + const stub = { handler: () => ({ status: 'success' as const, outputs: {}, activePorts: ['default'] }), executeMode: 'all' as const }; + await expect( + runWorkflowE2E({ yamlPath: YAML, inputs: { date: '2026-09-09' }, pluginStubs: { manual: passthroughTrigger, cron: passthroughTrigger, 'sub-workflow': stub } }), + ).rejects.toThrow(/no targets configured/); + await expect( + runWorkflowE2E({ yamlPath: YAML, inputs: { date: '2026-09-09', targets: [{ name: 'x' }] }, pluginStubs: { manual: passthroughTrigger, cron: passthroughTrigger, 'sub-workflow': stub } }), + ).rejects.toThrow(/needs agent_tags/); + }); +}); diff --git a/finops/__tests__/k8s-consumption.e2e.test.ts b/finops/__tests__/k8s-consumption.e2e.test.ts new file mode 100644 index 0000000..94cbcfe --- /dev/null +++ b/finops/__tests__/k8s-consumption.e2e.test.ts @@ -0,0 +1,125 @@ +/** + * E2E for finops/wf3-k8s-consumption-daily.yaml: lake scopes + collector output (agent) + + * the day's raw cluster row → priced scope facts and the cluster's consumption shares. + */ +import { resolve } from 'node:path'; +import { runWorkflowE2E } from '@nullplatform/workflow-kit/test'; +import { describe, expect, it } from 'vitest'; + +const YAML = resolve(__dirname, '..', 'wf3-k8s-consumption-daily.yaml'); +const D = '2026-09-09'; +const passthroughTrigger = { handler: () => ({ status: 'success' as const, outputs: {}, activePorts: ['default'] }), registryType: 'trigger' as const }; + +const SCOPES = [ + { scope_id: 777, scope_name: 'prod', scope_slug: 'prod', scope_nrn: 'organization=4:account=17:namespace=5:application=100:scope=777', scope_type: 'web_pool_k8s', app_name: 'Orders', app_slug: 'orders-api' }, + { scope_id: 999, scope_name: 'prod', scope_slug: 'prod', scope_nrn: 'organization=4:account=17:namespace=6:application=400:scope=999', scope_type: 'web_pool_k8s', app_name: 'Users', app_slug: 'users-api' }, + { scope_id: 555, scope_name: 'ec2', scope_slug: 'ec2', scope_nrn: 'organization=4:account=17:namespace=6:application=400:scope=555', scope_type: 'custom', app_name: 'Users', app_slug: 'users-api' }, +]; +// cluster row of the day (wf1): 100 USD, rates 0.05 $/core-h and 0.01 $/GiB-h +const CLUSTER = { id: `raw-cluster-runtime-${D}`, date: D, day: D, stage: 'raw', subject_type: 'cluster', subject_id: 'runtime', cluster: 'runtime', cloud: 'aws', cloud_account: '283477532906', region: 'us-east-1', source: 'aws_ce', collected_at: 'x', allocation_method: 'unallocated', cost_usd: 100, rate_cpu_usd_core_h: 0.05, rate_mem_usd_gb_h: 0.01, cpu_share: 0.5, cpu_capacity_core_h: 2000, mem_capacity_gb_h: 8000 }; +// collector day mode per scope: 2 hours, usage vs request (mc / MB) +const OUT: Record = { + 777: { samples: 24, cpu_mc_hours: 3000, mem_mb_hours: 4096, cpu_req_mc_avg: 2000, mem_req_mb_avg: 2048, hours: [ + { cpu_mc: 1000, mem_mb: 1024, cpu_req_mc: 2000, mem_req_mb: 2048, pods: 2 }, // request wins: 2 core-h, 2 GiB-h + { cpu_mc: 2000, mem_mb: 3072, cpu_req_mc: 1000, mem_req_mb: 2048, pods: 3 }, // usage wins: 2 core-h, 3 GiB-h + ] }, + 999: { samples: 24, cpu_mc_hours: 500, mem_mb_hours: 512, hours: [{ cpu_mc: 500, mem_mb: 512, cpu_req_mc: 500, mem_req_mb: 512, pods: 1 }] }, // 0.5 core-h, 0.5 GiB-h + 555: { samples: 0 }, // not in the cluster +}; + +describe('finops/wf3-k8s-consumption-daily', () => { + it('prices each scope with the cluster rates (max(usage, request) per hour) and writes the cluster shares', async () => { + const cmds: string[] = []; const written: Array> = []; + const result = await runWorkflowE2E({ + yamlPath: YAML, + inputs: { date: D }, + pluginStubs: { + manual: passthroughTrigger, + 'np-lake-query': { handler: () => ({ status: 'success' as const, outputs: { rows: SCOPES, rowCount: 3 }, activePorts: ['default'] }), executeMode: 'all' as const }, + 'np-entity-paginated-fetch': { handler: () => ({ status: 'success' as const, outputs: { items: [{ id: 777, dimensions: { environment: 'production' } }, { id: 999, dimensions: {} }], totalFetched: 2, pages: 1 }, activePorts: ['default'] }), executeMode: 'all' as const }, + 'np-api-call': { handler: () => ({ status: 'success' as const, outputs: { status: 200, body: CLUSTER }, activePorts: ['default'] }), executeMode: 'all' as const }, + 'np-agent-command': { + handler: (ctx: { inputs: Record }) => { + const c = String(ctx.inputs.cmdline); cmds.push(c); + const id = c.split('--scope ')[1]; + return { status: 'success' as const, outputs: { status: 'success', stdout: JSON.stringify(OUT[id as string]), stderr: '' }, activePorts: ['default'] }; + }, + executeMode: 'all' as const, + }, + 'sub-workflow': { handler: (ctx: { inputs: Record }) => { written.push(ctx.inputs); return { status: 'success' as const, outputs: { id: (ctx.inputs.fact as { id: string }).id }, activePorts: ['default'] }; }, executeMode: 'all' as const }, + }, + }); + expect(cmds).toEqual([ + `nullplatform/platform-scopes-override/cost/collect_metrics --prom http://prometheus-server.default.svc.cluster.local --mode day --date ${D} --scope 777`, + `nullplatform/platform-scopes-override/cost/collect_metrics --prom http://prometheus-server.default.svc.cluster.local --mode day --date ${D} --scope 999`, + `nullplatform/platform-scopes-override/cost/collect_metrics --prom http://prometheus-server.default.svc.cluster.local --mode day --date ${D} --scope 555`, + ]); + const facts = result.outputs?.facts as Array>; + expect(facts.map((f) => f.id)).toEqual([`raw-k8s-scope-777-${D}`, `raw-k8s-scope-999-${D}`]); // 555 has no pods here + // 777: chargeable 4 core-h × 0.05 + 5 GiB-h × 0.01 = 0.25; used 3 core-h + 4 GiB-h = 0.19; waste 0.06 + expect(facts[0]).toMatchObject({ subject_type: 'scope', source: 'k8s', cluster: 'runtime', application_id: '100', namespace_id: '5', scope_id: '777', application_slug: 'orders-api', subject_name: 'orders-api.prod', + core_h_chargeable: 4, gb_h_chargeable: 5, core_h_used: 3, gb_h_used: 4, core_h_requested: 3, gb_h_requested: 4, pods_avg: 2.5, cost_usd: 0.25, usage_usd: 0.19, waste_usd: 0.06, allocation_method: 'direct_resource', metric: 'k8s.chargeable', dimensions: { environment: 'production' }, environment: 'production' }); + expect(facts[1]).toMatchObject({ scope_id: '999', application_id: '400', cost_usd: 0.03, usage_usd: 0.03, waste_usd: 0 }); + // cluster row patched with shares by scope (over the cluster cost) and the overhead + const cluster = written.find((w) => (w.fact as { id: string }).id === `raw-cluster-runtime-${D}`)?.fact as Record; + expect(cluster).toMatchObject({ metric: 'k8s.chargeable', metric_shares: { 777: 0.0025, 999: 0.0003 }, k8s_overhead_usd: 99.72, cost_usd: 100 }); + expect((cluster.metric_owners as Record>)[777]).toEqual({ application_id: '100', namespace_id: '5', scope_id: '777', account_id: '17', application_slug: 'orders-api', scope_name: 'prod', scope_type: 'web_pool_k8s', dimensions: { environment: 'production' } }); + const summary = result.outputs?.summary as Record; + // 2 usage rows (scope_usage_daily) + 2 priced facts + the cluster row + expect(summary).toMatchObject({ scopes: 3, with_data: 2, usage_rows: 2, source: 'agent', scopes_cost_usd: 0.28, overhead_usd: 99.72, cluster_cost_usd: 100, written: 5, error_count: 0 }); + expect(written.map((w) => w.catalog_slug)).toEqual(['scope_usage_daily', 'scope_usage_daily', 'cost_daily', 'cost_daily', 'cost_daily']); + const usage = result.outputs?.usage as Array>; + expect(usage.map((u) => u.id)).toEqual([`usage-777-${D}`, `usage-999-${D}`]); + // 777: used 3 core-h / 4 GiB-h, requested 3 core-h (2+1) / 4 GiB-h (2+2) → 100% cpu, 100% mem; chargeable 4 / 5 + expect(usage[0]).toMatchObject({ scope_id: '777', cluster: 'runtime', source: 'agent', application_id: '100', samples: 24, hours_with_data: 2, pods_avg: 2.5, core_h_used: 3, core_h_requested: 3, core_h_chargeable: 4, gb_h_used: 4, gb_h_requested: 4, gb_h_chargeable: 5, cpu_utilization_pct: 100, mem_utilization_pct: 100, cpu_waste_core_h: 0 }); + expect((usage[0].hours as unknown[]).length).toBe(2); + expect(facts[0]).toMatchObject({ usage_id: `usage-777-${D}` }); + }); + + it('fails clearly when the day has no raw cluster row', async () => { + await expect(runWorkflowE2E({ yamlPath: YAML, inputs: { date: '2026-01-01' }, pluginStubs: { + manual: passthroughTrigger, + 'np-lake-query': { handler: () => ({ status: 'success' as const, outputs: { rows: SCOPES }, activePorts: ['default'] }), executeMode: 'all' as const }, + 'np-entity-paginated-fetch': { handler: () => ({ status: 'success' as const, outputs: { items: [], totalFetched: 0, pages: 1 }, activePorts: ['default'] }), executeMode: 'all' as const }, + 'np-api-call': { handler: () => ({ status: 'success' as const, outputs: { status: 404, body: { message: 'not found' } }, activePorts: ['default'] }), executeMode: 'all' as const }, + 'np-agent-command': { handler: () => ({ status: 'success' as const, outputs: { stdout: '{}' }, activePorts: ['default'] }), executeMode: 'all' as const }, + 'sub-workflow': { handler: () => ({ status: 'success' as const, outputs: {}, activePorts: ['default'] }), executeMode: 'all' as const }, + } })).rejects.toThrow(/raw cluster row not found/); + }); + + it('newrelic mode: one NerdGraph query per cluster-day (scope × hour) replaces the agent collector', async () => { + const posts: Array> = []; const cmds: string[] = []; + // NR rows: average per pod-sample × distinct pods = the scope's hourly consumption. 777: two hours, 999: one. + const rows = [ + { facet: ['777', '0:00'], 'label.scope_id': '777', 'Hour of timestamp': '0:00', cpu: 0.5, mem: 512 * 1048576, cpuReq: 1.0, memReq: 1024 * 1048576, pods: 2, samples: 240 }, // 1000 mc / 1024 MB used, 2000 mc / 2048 MB req + { facet: ['777', '1:00'], 'label.scope_id': '777', 'Hour of timestamp': '1:00', cpu: 1.0, mem: 1024 * 1048576, cpuReq: 0.5, memReq: 1024 * 1048576, pods: 2, samples: 240 }, // 2000 / 2048 used, 1000 / 2048 req + { facet: ['999', '5:00'], 'label.scope_id': '999', 'Hour of timestamp': '5:00', cpu: 0.5, mem: 512 * 1048576, cpuReq: 0.5, memReq: 512 * 1048576, pods: 1, samples: 120 }, + ]; + const result = await runWorkflowE2E({ + yamlPath: YAML, + inputs: { date: D, collector_mode: 'newrelic', nr_account_id: 6332316 }, + pluginStubs: { + manual: passthroughTrigger, + 'np-lake-query': { handler: () => ({ status: 'success' as const, outputs: { rows: SCOPES, rowCount: 3 }, activePorts: ['default'] }), executeMode: 'all' as const }, + 'np-entity-paginated-fetch': { handler: () => ({ status: 'success' as const, outputs: { items: [{ id: 777, dimensions: { environment: 'production' } }, { id: 999, dimensions: {} }], totalFetched: 2, pages: 1 }, activePorts: ['default'] }), executeMode: 'all' as const }, + 'np-api-call': { handler: () => ({ status: 'success' as const, outputs: { status: 200, body: CLUSTER }, activePorts: ['default'] }), executeMode: 'all' as const }, + 'np-agent-command': { handler: (ctx: { inputs: Record }) => { cmds.push(String(ctx.inputs.cmdline)); return { status: 'success' as const, outputs: { status: 'success', stdout: '{}', stderr: '' }, activePorts: ['default'] }; }, executeMode: 'all' as const }, + 'http-request': { handler: (ctx: { inputs: Record }) => { posts.push(ctx.inputs); return { status: 'success' as const, outputs: { statusCode: 200, statusText: 'OK', headers: {}, body: { data: { actor: { account: { usage: { results: rows } } } } } }, activePorts: ['default'] }; }, executeMode: 'all' as const }, + 'sub-workflow': { handler: () => ({ status: 'success' as const, outputs: { written: 1 }, activePorts: ['default'] }), executeMode: 'all' as const }, + }, + }); + expect(cmds).toEqual([]); // the agent path is not taken + expect(posts).toHaveLength(1); + const q = String((posts[0]?.body as { query: string }).query); + expect(q).toContain('account(id: 6332316)'); + expect(q).toContain("clusterName = 'runtime' AND containerName = 'application'"); + expect(q).toContain("SINCE '2026-09-09 00:00:00 UTC' UNTIL '2026-09-10 00:00:00 UTC'"); + const facts = result.outputs?.facts as Array>; + expect(facts.map((f) => f.id)).toEqual([`raw-k8s-scope-777-${D}`, `raw-k8s-scope-999-${D}`]); + // 777: hour 0 request wins (2 core-h, 2 GiB-h), hour 1 usage wins (2 core-h, 2 GiB-h) → 4 core-h × 0.05 + 4 GiB-h × 0.01 = 0.24; used 3 core-h + 3 GiB-h = 0.18 + expect(facts[0]).toMatchObject({ scope_id: '777', application_id: '100', cost_usd: 0.24, usage_usd: 0.18, core_h_chargeable: 4, gb_h_chargeable: 4, core_h_used: 3, gb_h_used: 3, pods_avg: 2, quantity: 480 }); + expect(facts[1]).toMatchObject({ scope_id: '999', cost_usd: 0.03, core_h_chargeable: 0.5, gb_h_chargeable: 0.5 }); + const summary = result.outputs?.summary as Record; + expect(summary.scopes_with_data ?? summary.with_data ?? 2).toBeTruthy(); + }); +}); diff --git a/finops/__tests__/suggest-mappings.e2e.test.ts b/finops/__tests__/suggest-mappings.e2e.test.ts new file mode 100644 index 0000000..8e3eab8 --- /dev/null +++ b/finops/__tests__/suggest-mappings.e2e.test.ts @@ -0,0 +1,86 @@ +/** + * E2E for finops/wf-suggest-mappings.yaml: unallocated leaves + evidence (null services, + * application parameters) → cost_mapping_suggestion rows. Platform reads stubbed at the + * plugin level; the upsert child at the `sub-workflow` level. + */ +import { resolve } from 'node:path'; +import { runWorkflowE2E } from '@nullplatform/workflow-kit/test'; +import { describe, expect, it } from 'vitest'; + +const YAML = resolve(__dirname, '..', 'wf-suggest-mappings.yaml'); +const D = '2026-09-09'; +const RDS = 'Amazon Relational Database Service'; +const SHARED = 'shared-db.cluster-abc.us-east-1.rds.amazonaws.com'; +const ORDERS = 'orders-db.cluster-abc.us-east-1.rds.amazonaws.com'; + +const passthroughTrigger = { handler: () => ({ status: 'success' as const, outputs: {}, activePorts: ['default'] }), registryType: 'trigger' as const }; + +const LEAVES = [ + { id: `raw-service-shared-db-${D}`, cloud_service: RDS, subject_type: 'service', subject_id: 'shared-db', subject_name: 'shared-db', resource_type: 'rds:cluster', host: SHARED, cost_usd: 8 }, + { id: `raw-service-orders-db-${D}`, cloud_service: RDS, subject_type: 'service', subject_id: 'orders-db', subject_name: 'orders-db', resource_type: 'rds:cluster', host: ORDERS, cost_usd: 12 }, + { id: `raw-cloud_service-guardduty-${D}-remainder`, cloud_service: 'Amazon GuardDuty', subject_type: 'cloud_service', subject_id: 'guardduty', cost_usd: 1 }, + { id: `raw-bucket-tiny-${D}`, cloud_service: RDS, subject_type: 'service', subject_id: 'tiny-db', host: 'tiny.rds.amazonaws.com', cost_usd: 0.01 }, + // by_metric keys nobody owns: one matches a DB_NAME parameter, one matches an application slug by name + { id: `raw-service-shared-db-${D}#orders`, cloud_service: RDS, subject_type: 'service', subject_id: 'shared-db', subject_name: 'shared-db', resource_type: 'rds:cluster', host: SHARED, cost_usd: 2, kind: 'metric_key', metric_key: 'orders', rule_id: 'shared-by-load' }, + { id: `raw-service-shared-db-${D}#users_api_production`, cloud_service: RDS, subject_type: 'service', subject_id: 'shared-db', subject_name: 'shared-db', resource_type: 'rds:cluster', host: SHARED, cost_usd: 0.5, kind: 'metric_key', metric_key: 'users_api_production', rule_id: 'shared-by-load' }, +]; +const SERVICES = { results: [{ id: 'svc-1', name: 'Orders DB', slug: 'orders-db', entity_nrn: 'organization=4:account=17:namespace=5:application=100', attributes: { host: ORDERS } }] }; +const APPS = [ + { id: 100, slug: 'orders-api', namespace_id: 5 }, + { id: 200, slug: 'users-api', namespace_id: 6 }, + { id: 300, slug: 'unrelated', namespace_id: 6 }, +]; +const PARAMS: Record = { + 100: { results: [{ name: 'DB_HOST', values: [{ value: SHARED }] }, { name: 'DB_NAME', values: [{ value: 'orders' }] }] }, + 200: { results: [{ name: 'DATABASE_URL', values: [{ value: `postgres://u:p@${SHARED}:5432/users` }] }] }, + 300: { results: [{ name: 'SECRET_URL', secret: true, values: [{ value: null }] }] }, +}; + +describe('finops/wf-suggest-mappings', () => { + it('proposes a direct rule from a null service host and a split from application parameters', async () => { + const written: Array> = []; + const result = await runWorkflowE2E({ + yamlPath: YAML, + inputs: { date: D, unallocated_leaves: LEAVES, org_nrn: 'organization=4', account_id: '17' }, + pluginStubs: { + manual: passthroughTrigger, + 'np-api-call': { + handler: (ctx: { stepId: string; inputs: Record }) => { + if (ctx.stepId === 'np_services') return { status: 'success' as const, outputs: { status: 200, body: SERVICES }, activePorts: ['default'] }; + const nrn = String((ctx.inputs.query as { nrn: string }).nrn); + const appId = nrn.split('application=')[1]; + return { status: 'success' as const, outputs: { status: 200, body: PARAMS[appId as string] ?? { results: [] } }, activePorts: ['default'] }; + }, + executeMode: 'all' as const, + }, + 'np-entity-paginated-fetch': { handler: () => ({ status: 'success' as const, outputs: { items: APPS, totalFetched: 3, pages: 1 }, activePorts: ['default'] }), executeMode: 'all' as const }, + 'sub-workflow': { + handler: (ctx: { inputs: Record }) => { written.push(ctx.inputs); return { status: 'success' as const, outputs: { id: (ctx.inputs.fact as { id: string }).id }, activePorts: ['default'] }; }, + executeMode: 'all' as const, + }, + }, + }); + const sugg = result.outputs?.suggestions as Array>; + const summary = result.outputs?.summary as Record; + expect(sugg.map((s) => s.id)).toEqual([`sugg-orders-db-${D}`, `sugg-shared-db-${D}`, `sugg-shared-db-orders-${D}`, `sugg-shared-db-users_api_production-${D}`]); // by recovered USD; tiny (< min_usd) and GuardDuty (no evidence) skipped + // metric keys: DB_NAME parameter (0.7) and slug naming (0.5) → a map entry for the by_metric rule + expect(sugg[2]).toMatchObject({ confidence: 0.7, recovers_usd: 2, rule: { for_rule: 'shared-by-load', map_entry: { key: 'orders', target: { application_id: '100', application_slug: 'orders-api' } } } }); + expect(sugg[3]).toMatchObject({ confidence: 0.5, rule: { map_entry: { key: 'users_api_production', target: { application_id: '200', application_slug: 'users-api' } } } }); + + const orders = sugg[0] as { rule: Record; evidence: Array>; confidence: number }; + expect(orders.confidence).toBe(0.95); + expect(orders.rule).toEqual({ scope: { cloud_service: RDS }, match: [{ field: 'host', equals: ORDERS }], method: 'direct', target: { application_id: '100', namespace_id: '5', service_id: 'svc-1' } }); + expect(orders.evidence[0]).toMatchObject({ kind: 'null_service', service_id: 'svc-1', application_id: '100' }); + + const shared = sugg[1] as { rule: { method: string; target: { split: Array<{ weight: number; target: Record }> } }; evidence: Array>; confidence: number; recovers_usd: number }; + expect(shared.confidence).toBe(0.6); + expect(shared.recovers_usd).toBe(8); + expect(shared.rule.method).toBe('split'); + expect(shared.rule.target.split.map((p) => [p.weight, p.target.application_id, p.target.application_slug])).toEqual([[1, '100', 'orders-api'], [1, '200', 'users-api']]); + expect(shared.evidence.map((e) => e.kind)).toEqual(['parameter', 'parameter']); + + expect(summary).toMatchObject({ suggestions: 4, recovers_usd: 22.5, apps_scanned: 3, services: 1, written: 4 }); + expect(new Set(written.map((w) => w.catalog_slug))).toEqual(new Set(['cost_mapping_suggestion'])); + expect((written[0]?.fact as { status: string }).status).toBe('proposed'); + }); +}); diff --git a/finops/__tests__/tool-cloud-query.e2e.test.ts b/finops/__tests__/tool-cloud-query.e2e.test.ts new file mode 100644 index 0000000..bded039 --- /dev/null +++ b/finops/__tests__/tool-cloud-query.e2e.test.ts @@ -0,0 +1,185 @@ +/** + * E2E for finops/tool-cloud-query.yaml on the local executor. The engine + * plugin `np-package-call` is stubbed at the PLUGIN level (spec invariant 10): + * the stub asserts the request the workflow hands to it and returns the shape + * the real plugin emits ({ response, calls, failed, ... }). + * + * `runWorkflowE2E` resolves with `{ outputs, finalSnapshot, definition }` on a + * completed run and REJECTS when the run fails. + */ +import { resolve } from 'node:path'; +import { runWorkflowE2E } from '@nullplatform/workflow-kit/test'; +import { describe, expect, it } from 'vitest'; + +const YAML = resolve(__dirname, '..', 'tool-cloud-query.yaml'); + +const RESPONSE = { + provider: 'aws', + identity: { account: '688720756067' }, + calls: [ + { id: 'who', ok: true, pages: 1, durationMs: 5, result: { Account: '688720756067' } }, + { + id: 'cost', + ok: true, + pages: 1, + durationMs: 9, + result: { ResultsByTime: [{ Total: { UnblendedCost: { Amount: '1.5' } } }] }, + }, + ], +}; + +const passthroughTrigger = { + handler: () => ({ status: 'success' as const, outputs: {}, activePorts: ['default'] }), + registryType: 'trigger' as const, +}; + +function packageCallStub(outputs: Record, seen: Array> = []) { + return { + handler: (ctx: { inputs: Record }) => { + seen.push(ctx.inputs); + return { status: 'success' as const, outputs, activePorts: ['default'] }; + }, + executeMode: 'all' as const, + }; +} + +type Snapshot = { steps: Record }; + +describe('finops/tool-cloud-query', () => { + it('returns results keyed by call id', async () => { + const seen: Array> = []; + const result = await runWorkflowE2E({ + yamlPath: YAML, + inputs: { + agent_tags: { package: 'cloud-query' }, + calls: [ + { id: 'who', service: 'sts', operation: 'GetCallerIdentity' }, + { id: 'cost', service: 'ce', operation: 'GetCostAndUsage' }, + ], + }, + pluginStubs: { + manual: passthroughTrigger, + 'np-package-call': packageCallStub( + { + commandId: 'c1', + agentId: 'a1', + response: RESPONSE, + calls: RESPONSE.calls, + failed: [], + mode: 'async', + }, + seen, + ), + }, + }); + const snap = result.finalSnapshot as Snapshot; + expect(snap.steps.shape_results?.status).toBe('completed'); + expect(result.outputs?.results).toEqual({ + who: { Account: '688720756067' }, + cost: { ResultsByTime: [{ Total: { UnblendedCost: { Amount: '1.5' } } }] }, + }); + expect(result.outputs?.identity).toEqual({ account: '688720756067' }); + expect(result.outputs?.failed).toEqual([]); + + expect(seen).toHaveLength(1); + const ac = seen[0]?.action_context as { cloud_query: { calls: unknown[]; provider: string } }; + expect(ac.cloud_query.provider).toBe('aws'); + expect(ac.cloud_query.calls).toHaveLength(2); + expect(seen[0]?.agent_selector).toEqual({ package: 'cloud-query' }); + expect(seen[0]?.mode).toBe('async'); + // the released image travels as an immutable reference (default from variables.image) + expect(String(seen[0]?.image)).toMatch(/^public\.ecr\.aws\/nullplatform\/agent-plugins\/workflows\/aws-cost-explorer@sha256:[a-f0-9]{64}$/); + // the action context carries no artifact any more: the platform derives it from package.image + expect((seen[0]?.action_context as { notification?: unknown }).notification).toBeUndefined(); + }); + + it('scopes the agent selector to agent_nrn when given (agents registered under an account)', async () => { + const seen: Array> = []; + await runWorkflowE2E({ + yamlPath: YAML, + inputs: { + agent_tags: { cluster: 'runtime' }, + agent_nrn: 'organization=4:account=17', + calls: [{ id: 'who', service: 'sts', operation: 'GetCallerIdentity' }], + }, + pluginStubs: { + manual: passthroughTrigger, + 'np-package-call': packageCallStub({ commandId: 'c1', agentId: 'a1', response: RESPONSE, calls: RESPONSE.calls, failed: [], mode: 'async' }, seen), + }, + }); + expect(seen[0]?.agent_selector).toEqual({ nrn: 'organization=4:account=17', tags: { cluster: 'runtime' } }); + await expect( + runWorkflowE2E({ + yamlPath: YAML, + inputs: { agent_tags: { cluster: 'runtime' }, agent_nrn: 'not-an-nrn', calls: [{ id: 'who', service: 'sts', operation: 'GetCallerIdentity' }] }, + pluginStubs: { manual: passthroughTrigger, 'np-package-call': packageCallStub({ commandId: 'c1', agentId: 'a1', response: RESPONSE, calls: RESPONSE.calls, failed: [], mode: 'async' }) }, + }), + ).rejects.toThrow(/agent_nrn is not an NRN/); + }); + + it('surfaces failed calls in outputs when the plugin reports them', async () => { + const failedCall = { id: 'big', ok: false, errorCode: 'RESULT_TOO_LARGE', error: 'too big' }; + const seen: Array> = []; + const result = await runWorkflowE2E({ + yamlPath: YAML, + inputs: { + agent_tags: { package: 'cloud-query' }, + calls: [{ id: 'big', service: 'ce', operation: 'GetCostAndUsage' }], + mode: 'sync', + }, + pluginStubs: { + manual: passthroughTrigger, + 'np-package-call': packageCallStub( + { + commandId: 'c1', + agentId: 'a1', + response: { calls: [failedCall] }, + calls: [failedCall], + failed: ['big'], + mode: 'sync', + }, + seen, + ), + }, + }); + expect(result.outputs?.failed).toEqual(['big']); + expect(result.outputs?.results).toEqual({ + big: { error: 'too big', errorCode: 'RESULT_TOO_LARGE' }, + }); + expect(seen[0]?.mode).toBe('sync'); + }); + + it('refuses a callback host outside the allow-list, before dispatching anything', async () => { + const seen: Array> = []; + await expect( + runWorkflowE2E({ + yamlPath: YAML, + inputs: { + agent_tags: { package: 'cloud-query' }, + calls: [{ id: 'who', service: 'sts', operation: 'GetCallerIdentity' }], + callback_base_url: 'http://169.254.169.254', + }, + pluginStubs: { + manual: passthroughTrigger, + 'np-package-call': packageCallStub({ response: {}, calls: [], failed: [] }, seen), + }, + }), + ).rejects.toThrow(/callback host not allowed/); + expect(seen).toHaveLength(0); + }); + + it('fails the run when calls is empty, before dispatching anything', async () => { + const seen: Array> = []; + await expect( + runWorkflowE2E({ + yamlPath: YAML, + inputs: { agent_tags: { package: 'cloud-query' }, calls: [] }, + pluginStubs: { + manual: passthroughTrigger, + 'np-package-call': packageCallStub({ response: {}, calls: [], failed: [] }, seen), + }, + }), + ).rejects.toThrow(/calls must be a non-empty array/); + expect(seen).toHaveLength(0); + }); +}); diff --git a/finops/docs/analysis-kwik-e-mart-2026-09-10.md b/finops/docs/analysis-kwik-e-mart-2026-09-10.md new file mode 100644 index 0000000..4d2367c --- /dev/null +++ b/finops/docs/analysis-kwik-e-mart-2026-09-10.md @@ -0,0 +1,104 @@ +# FinOps analysis — kwik-e-mart (AWS 688720756067), 2026-09-10 + +Everything below was measured on 2026-09-10 with the `kwik_admin` profile +(Cost Explorer, EC2, tagging API, RDS) and the nullplatform API for org +1255165411. Day analyzed: **2026-09-09**. It is the reference the collectors +in this suite were built and verified against; repeat it for a new account +with [mapping-playbook.md](./mapping-playbook.md). + +## 1. The account in one look + +| Fact | Value | Why it matters | +|---|---|---| +| Billing role | **Linked account** of an organization payer | no CUR, no Data Exports, cost allocation tags managed at the payer (`ListCostAllocationTags` → AccessDenied, `GROUP BY TAG` returns 0). Cost Explorer works. | +| Spend | ~USD 600 / 30 days unblended | small, ideal to iterate | +| Amortized vs unblended, 09-09 | **37.32** amortized vs 20.69 unblended | EC2 is Savings-Plan covered: unblended EC2 = 0.001/day with a -22.85 negation line on `NoResourceId`; per-instance unblended shows on-demand rates. **Amortized is the chargeback basis** (`cost_usd`); unblended kept in `unblended_usd`. | +| Kubernetes | one EKS cluster `developent` (1.34), 9 nodes: 4× c5a.xlarge, 2× t3.large, 3× t3.medium = 26 vCPU / 60 GiB | Karpenter + an ASG; no Prometheus, no Container Insights, no metrics-server addon, no AMP | +| Load balancers | 4 (3 belong to the cluster by tag `elbv2.k8s.aws/cluster`, 1 `null-main-balancer`) | | +| Networking | 1 NAT gateway + VPC endpoints in the cluster VPC `vpc-01cb60321d5e7b150` | attributed to the cluster | +| Databases | Aurora MySQL cluster `transactions` with **no instances** (backup + 1 GB storage, cents); null service `transactions` exists with `attributes.host` = the cluster endpoint | the mapping key for databases is the **host** | +| Tagged resources (tagging API) | 2,064 | see §3 | + +## 2. Daily cost by service, 2026-09-09 (amortized) + +| Service | USD | Notes | +|---|---|---| +| EC2 – Compute | 16.57 | 32 resources at resource level; SP covered | +| EC2 – Other | 6.20 | EBS gp3 1.52 + gp2 1.42, NAT hours 1.08, NAT bytes 0.99, regional data transfer 0.80, CPU credits 0.38 | +| CloudWatch | 3.10 | metric monitoring 2.33 | +| VPC | 3.04 | public IPv4 1.56, VPC endpoints 1.44 | +| EKS | 2.40 | control plane | +| ELB | 2.16 | 96 LB-hours = 4 LBs | +| Kiro | ~2 | seat licence, org-level | +| S3, ECR, Secrets Manager, Lambda, DynamoDB, KMS, Route53, … | < 1 each | | +| **Total** | **37.32** | Σ of the `cloud_service` facts | + +## 3. Which resources carry a null identity + +From the tagging API (only resources that were tagged at least once are +returned — never-tagged ones are invisible there): + +| Service | Resources | With null tags | Tag convention | +|---|---|---|---| +| ECR | 915 | 914 | `account`, `application`, `namespace` (+ `_id`), `nullplatform` | +| ELB (LBs, TGs, listeners) | 321 | 156 | `elbv2.k8s.aws/cluster`, `ingress.k8s.aws/stack` | +| EKS objects | 206 | 36 | k8s labels | +| API Gateway | 145 | 103 | **`nullplatform:scope-id`, `nullplatform:application`** (prefixed variant) | +| EC2 | 140 | 5 | `scope_id`, `application_id`, `namespace_id`, `scope`, `application`, `namespace` | +| Lambda | 117 | 90 | plain `scope_id`… | +| CloudWatch Logs | 113 | 44 | plain | +| CloudFront, ElastiCache, Amplify | 2 / 1 / 1 | all | plain | +| **DynamoDB (21 tables), S3 (19 buckets)** | — | **none** | untagged → rule by name/config or provisioning must tag | + +Two tag conventions coexist (`scope_id` and `nullplatform:scope-id`); the +collector normalizes both (`npDims()` in `wf1-aws-billing-daily.yaml`). + +## 4. What Cost Explorer can and cannot attribute + +- **Per resource**: only EC2 instances (and NAT gateways, which show up under + EC2-Compute with their ARN), last **14 days**, `GetCostAndUsageWithResources`. + EBS volumes come back as `NoResourceId` → prorated by attached GB. +- **Per tag**: needs cost allocation tags activated at the payer. Not available here. +- **Per usage type**: always; it is the breakdown the `bucket` facts carry. +- Each Cost Explorer call costs USD 0.01. + +## 5. The EKS cluster, composed (09-09) + +| Component | USD | Rule | +|---|---|---| +| nodes | 15.79 | amortized cost of instances tagged to the cluster (`eks:cluster-name`, `kubernetes.io/cluster/`) | +| networking | 5.92 | VPC service (IPv4, endpoints) + EC2-Other NAT/data-transfer usage types, split evenly across clusters | +| control_plane | 2.40 | EKS service ÷ clusters | +| load_balancers | 1.62 | ELB service × (LBs tagged to the cluster ÷ all LBs from `DescribeLoadBalancers`) | +| storage | 1.84 | EC2-Other EBS usage types × (GB attached to cluster nodes ÷ all attached GB) | +| other | 0.17 | rest of EC2-Other (CPU credits) × instance share | +| **cluster** | **27.74** | | + +Capacity: 624 core-hours, 1,440 GiB-hours. Blended rates with `cpu_share = 0.5`: +**0.02223 USD/core-hour** (0.0000222 per millicore-hour) and **0.009633 USD/GiB-hour**. +The cluster is not split per scope in the collector; the allocator charges +consumers `cpu_core_h × rate + gib_h × rate`, idle stays on the cluster. + +## 6. Decisions taken + +1. Amortized cost as `cost_usd`; unblended alongside. +2. Σ `cloud_service` = the day; every other subject is an attribution with + `parent_id` (buckets, resources, scopes, services) or derived (cluster). +3. Cluster = nodes + control plane + LBs + networking + storage + other; blended + rate per core-hour and GiB-hour over reserved capacity. +4. Databases map to the null service by **host**; `allocation_method: service_owner`, + owner application from the service NRN. +5. Facts are **one row per subject per day** (116 for this account/day: 21 + services, 50 usage-type buckets, 29 EC2 resources, 2 scopes, 1 cluster + 6 + components, 1 database). Falabella runs the same pattern at 2.5k rows/day. +6. Next stage (`allocated`): one row **per application per resource per day**, + categorized (compute, kubernetes, database, storage, network, other), cut by + environment / namespace / account — see the design spec. + +## 7. Open items for this account + +- Activate cost allocation tags `scope_id`, `application_id` at the payer to + attribute Lambda, API Gateway, ECR, Logs by tag in Cost Explorer. +- Tag DynamoDB tables and S3 buckets at provisioning (or add naming rules). +- In-cluster consumption (phase 3) to allocate the cluster by usage. +- Aurora `transactions` has no instances: cents today; the mapping is in place. diff --git a/finops/docs/customer-onboarding.md b/finops/docs/customer-onboarding.md new file mode 100644 index 0000000..fb5c6d1 --- /dev/null +++ b/finops/docs/customer-onboarding.md @@ -0,0 +1,240 @@ +# Bringing the FinOps suite to a new organization — runbook + +What we learned putting the suite on nullplatform's own org (2026-09-10/11). Follow it top to +bottom for a new customer; every step names the artifact in this folder that does the work and +the trap we hit the first time. The user-facing model (rules, invoice entity, dashboard) is in +`../README.md`; the mapping design in `mapping-rules-design.md`; the hands-on rule authoring in +`mapping-playbook.md`. + +## 0. What you get at the end + +- One `cost_daily` catalog entity per **raw** cost fact (cloud service, usage-type bucket, EC2 + instance/scope, EKS cluster + components, database, Lambda scope, Kubernetes scope consumption) + and per **allocated** fact (raw fact × owner). +- One `application_cost_daily` **invoice** per application per day: flat `charge_items[]` + (`charge_type` scope | service | application), dimensions, totals by charge type / environment / + category / cloud service. +- `cost_mapping_rule` (what the customer decided) and `cost_mapping_suggestion` (what we inferred, + for them to accept). +- A daily loop (`wf0`, cron 04:15 UTC): collect (`wf1`) → Kubernetes consumption (`wf3`) → + allocate + invoices (`wf2`) → suggestions (`wf-suggest-mappings`). +- A Lake-backed dashboard ("FinOps — Costos por aplicación") over `catalog_entities`. + +## 1. Discovery (half a day, no writes) + +Answer these before touching anything; each one changes the configuration. + +| Question | Where to look | Why it matters | +|---|---|---| +| Which AWS accounts pay for what? | `sts GetCallerIdentity` through the agent, Cost Explorer `GetCostAndUsage` by `LINKED_ACCOUNT` | one `wf0` target per account × agent × role | +| Which **cost allocation tags** are active? | CE `GetTags` (or Billing console) | CE can only group by ACTIVE tags. nullplatform had `application`, `namespace`, `scope` (slugs) but not `scope_id`/`application_id`; the ids come from the resources' own tags (tagging API) instead | +| Do resources carry the null tags? | `tagging GetResources` per type (`lambda:function`, `rds:cluster`, `elasticloadbalancing:loadbalancer`…) | EC2 instances and Lambda functions created by null carry `application_id`, `scope_id`, `namespace_id`, `application`, `scope`, `namespace`; databases carry `application` at most | +| Is there an EKS cluster? Karpenter? | `ec2 DescribeInstances` + tags `eks:cluster-name` / `kubernetes.io/cluster/`; CE `GetCostAndUsageWithResources` grouped by `RESOURCE_ID, INSTANCE_TYPE` | nodes that terminated before collection only exist in CE-with-resources; the instance TYPE tells which cluster they belonged to (`inferCluster`) | +| Which shared databases hold many logical databases? | `rds DescribeDBClusters` + Performance Insights `GetResourceMetrics` (`db.load.avg` by `db`) | shared clusters are split by database load (`by_metric`), never mapped to one app | +| Who consumes each shared database? | application **parameters** (`GET /parameter?nrn=`): hosts / CNAMEs (`*.db.nullservices.io`), `DB_NAME`-like values | seeds the `by_metric` map (database name → application) | +| Which null services exist and what is their host? | `GET /service?nrn=&show_descendants=true` | RDS/ElastiCache/OpenSearch whose endpoint equals a service host are `service_owner` automatically | +| Which null account maps to which AWS account? | `GET /runtime_configuration?nrn=` then `GET /runtime_configuration/` → `values.aws.account_id` + `region` (one config per dimension; the org-level one is the default) | one `wf0` target per (null account, AWS account); `expected_account` guards the pairing. itti: 49 null accounts, ~2 AWS accounts each (us-east-1 + sa-east-1) | +| Where do the agents run, which VERSION, with what identity? | `GET /controlplane/agent?nrn=&limit=100&offset=…` (paginate; account-level agents are invisible from the org root), field `version`; the agent's `NP_WORKER_RULES` / `NP_ALLOWED_REGISTRIES` | **`package-exec` needs controlplane-agent ≥ 0.9.0 (use 0.11.1, what null runs).** An older agent answers `ping` but never emits `started` for `package-exec`, so the API returns `Command failed to start after all retry attempts` (10 s × 3 attempts, agentId `unknown`) — that error means "old agent", not "bad request". itti's 107 agents were 0.4.1–0.8.0; the worker pod needs a read-only cloud role (see §2) | +| Is Cost Explorer **resource-level** data enabled? | `GetCostAndUsageWithResources` grouped by `RESOURCE_ID`: rows come back as `NoResourceId` when the payer never opted in | without it EC2 has no per-instance rows; `wf1` prices the cluster from the `INSTANCE_TYPE` rows instead (running nodes → cluster by type, or the account's sole cluster). Spot fleets churn types all day, so the type list comes from CE, not from what is running now | +| Where do the pod metrics come from? | Prometheus reachable from the agent (`collector_mode: agent`, null) or New Relic's Kubernetes integration (`collector_mode: newrelic`: `K8sContainerSample` carries `label.scope_id`; config entries `NR_USER_KEY`, `NR_ACCOUNT_ID` at `/finops`) | `wf3` splits the cluster among scopes with max(usage, request) per hour either way; itti/tuti has no Prometheus but reports to NR account 6332316 | +| Which databases do the applications use? | application parameters (`DB_HOST`/`DB_NAME`, `DBM_HOST`, `REDIS_HOST`) + Performance Insights `db.load` by database (`ServiceType` DOCDB for DocumentDB) | itti: `DB_NAME` = the PI database name → exact `by_metric` map; DocumentDB names follow `db`; ElastiCache consumers → equal `split` | +| How is CloudWatch spent? | CE by `USAGE_TYPE`/`OPERATION` for `AmazonCloudWatch` | itti: 67% metric stream, 25% EMF custom metrics from `..http_agg`/`.sys_agg` log groups, 4% logs → by log group / by app (see README) | +| Amortized or unblended? | Cost Explorer both metrics | we attribute **amortized** (Savings Plans / RIs spread over covered usage); the console defaults to unblended, so the daily total looks ~25% lower there. Both are stored (`cost_usd`, `unblended_usd`) | + +Do the discovery calls through the agent with the cloud-query package +(`tool-cloud-query.yaml`, `mode: sync` for one-off probes). Never with local cloud credentials: +customers will not hand us any. + +## 2. Worker identity (IaC) and the agent rule + +`setup/02-aws-worker-identity.sh` (or the Terraform in `docs/iam/`) creates: + +1. An IAM role for the worker pod (`k8s-np-finops-worker` in nullplatform) with the read-only + policy in `docs/iam/` (Cost Explorer, EC2/RDS/ELB describes, tagging, Performance Insights, + CloudWatch Logs metadata, service listings) — EKS Pod Identity or IRSA. +2. A Kubernetes ServiceAccount in the agent's worker namespace (`np-workers`) bound to that role. +3. The agent rule so exactly OUR image gets that ServiceAccount: + ```json + [{"match":{"registry":"public.ecr.aws/nullplatform/agent-plugins/workflows/aws-cost-explorer","package":"cloud-query"},"serviceAccount":"np-finops-worker"}] + ``` + (`NP_WORKER_RULES`, base64 in the agent secret; the registry must also be in + `NP_ALLOWED_REGISTRIES`). The rule compares the image WITHOUT digest, so pin the digest on our + side (`tool-cloud-query.yaml` variable `image`) — a tag can be re-pointed by anyone who can push. + +Verify with a sync `sts GetCallerIdentity` through the tool: the ARN must be the worker role, not +the node role. Before the workflows exist in the org, the same probe as a curl (session or API-key +bearer of THAT org; read-only, ~200 ms on a working agent): + +```bash +curl -s -X POST https://api.nullplatform.com/controlplane/agent_command \ + -H "Authorization: Bearer $NP_TOKEN" -H 'Content-Type: application/json' -d '{ + "selector": {"stage": "sdlc"}, "nrn": "organization=:account=", + "execution_config": {"retry": {"max_attempts": 1}}, + "command": {"type": "package-exec", "data": { + "package": {"slug": "cloud-query", "image": "public.ecr.aws/nullplatform/agent-plugins/workflows/aws-cost-explorer@sha256:"}, + "environment": {"NP_ACTION_CONTEXT": "{\"cloud_query\":{\"provider\":\"aws\",\"region\":\"us-east-1\",\"calls\":[{\"id\":\"who\",\"service\":\"sts\",\"operation\":\"GetCallerIdentity\",\"params\":{}}]}}"}}}}' +``` + +`executions[0].results.stdOut` is the worker's JSON (`calls[0].result.Arn`). Operation names are the +SDK's PascalCase (`GetCallerIdentity`); a wrong name returns an empty stdout, not an error. + +Since agents-api #228/#230 and engine #182 the image travels as `command.data.package = +{slug, image}`; the platform lowers it into the worker's `oci_image` artifact, so no package +registration is needed. Without `image` the organization's registered package runs. + +## 3. Catalog specs + +`setup/01-catalog-spec.sh` creates or patches the five specs from `specs/*.spec.json` (`cost_daily`, `scope_usage_daily`, the two mapping specs, `application_cost_daily`). + +Traps that cost us hours: +- **Undeclared attributes are dropped silently.** Patch the spec BEFORE writing a new field + (`charge_type` went missing for a whole afternoon). +- **List filters only work on fields indexed when the spec was CREATED** (`stage`, `subject_type`, + `cloud_service`, `cluster`); `day` came later and `date` is ignored → the workflows read + `stage=raw|allocated` and filter the day in code. +- **Grants**: the workflows write with the organization's API key, which is not the spec's owner. + `schema.authorization.entities.grants` must give `read,list,create,write,delete` to `*` (or to + the key's principal) on every spec — the script rewrites the placeholder admin `732189543` to the + token's user; the 403 on `application_cost_daily` was exactly this. +- The spec files keep the placeholder principal `732189543`; the script rewrites EVERY `type: user` + grant to the token's user (a foreign user id → 400 `authorization references unknown user_id`). + Never commit an org-specific user id into `specs/*.spec.json`. +- Enums (`source`, `allocation_method`, `subject_type`, `charge_type`) must contain every value a + workflow emits; the spec description is capped at 255 chars. +- **DELETEs never reach the Lake**: `catalog_entities` keeps deleted instances with `_deleted=0`. + Any Lake query over re-written entities keeps only the latest run + (`QUALIFY collected_at = max(collected_at) OVER (PARTITION BY day)`). + +## 4. Publish the workflows + +From the engine repo (the DSL parser is imported from there): + +```bash +NP_TOKEN= pnpm tsx finops/setup/publish.ts finops \ + --base https://api.nullplatform.com --vars finops/setup/vars..json +``` + +Copy `setup/vars.nullplatform.json` (or `vars.itti.json` for a New Relic / single-dimension account) and set: `agent_tags` + `agent_nrn` (REQUIRED for account-level +agents), `org_nrn`, the dispatcher `targets` (one per account; `expected_account` guards a wrong +agent/role pairing; `dimensions` = the null dimension the whole AWS account maps to, e.g. +`{environment: development}`, stamped on every fact without a scope/service of its own), +`k8s_clusters`, and for `wf3` either the cluster's Prometheus URL (`collector_cmd`, `collector_mode: +agent`) or `collector_mode: newrelic` + config entries `NR_USER_KEY` / `NR_ACCOUNT_ID` at `/finops` +(`org_nrn` of `wf3` = the subtree whose scopes the cluster serves). + +The workflows read `${{ secrets.NP_API_KEY }}`: a config entry named `NP_API_KEY` must exist at +`/finops` or be inherited from `/` (`GET /workflows/config?path=/finops`). If the agent is not ready +yet, publish anyway and turn the daily cron off until it is +(`POST /workflows/definitions//aliases/live/deactivate`; `…/activate` later) — otherwise +`wf0` fails every morning at 04:15 UTC. + +For new revisions ALWAYS pass all seven ids (`--update file=id,…`); a partial list creates NEW +definitions with a live cron (the script refuses unless `--allow-create`). Save the printed ids. + +## 5. First day, dry then real + +1. `wf0` with `{"date":"YYYY-MM-DD","dry_run":true}`: read `summary` (daily total must match Cost + Explorer amortized for the day), `unallocated_leaves` (what needs rules). +2. Real run for one day; check the catalog: raw rows = one collection (`collected_at` groups), + allocated rows = `summary.written`, invoices = number of applications. +3. Load the seed rules (`setup/03-mapping-rules.sh setup/rules..json`, needs a session + bearer for rows created by a user) — start from `rules.nullplatform.json`: + security/compliance services → `shared-platform` bucket, CloudWatch remainder → platform, + shared databases → `by_metric` with the database→application map from the parameters. +4. Re-allocate the day (`wf2` alone, ~5 min); the stale sweep removes the previous allocation. +5. Read the suggestions (`cost_mapping_suggestion`, status `proposed`) with the customer; accept = + copy into a rule with `status: active`. +6. Activate the cron (already `live` after publish) and backfill: run `wf0` per past day (Cost + Explorer with resources covers the last 14 days). + +### 5.1 How to run a day today (engine caveat, 2026-09-11) + +`wf0 → wf2` as one chain wedges on itti: the dispatcher does not always see the allocator child +finish, and after ~6 minutes the sub-workflow step is retried and a SECOND allocator starts on the +same day (`:allocate:0:2`, then `:0:3`). Deterministic ids keep the data consistent, but three +allocators × 18 write batches saturate the worker. Until the engine fix lands, run a day as: + +```bash +# 1) collection + Kubernetes only (no allocation in the dispatcher) +POST /workflows/definitions//execute {"inputs": {"date": "YYYY-MM-DD", "allocate": false}} +# 2) allocation alone, when (1) is completed +POST /workflows/definitions//execute {"inputs": {"date": "YYYY-MM-DD"}} +``` + +The daily schedule follows the same split: `wf0` at 04:15 UTC collects + Kubernetes (`allocate_in_chain: +false` in the org's vars), `wf2`'s own cron allocates yesterday at 05:00 UTC. Suggestions are not +produced by the daily runs while `allocate_in_chain` is false (run `wf-suggest-mappings` by hand). + +`setup/backfill.sh …` does exactly that, one day at a time (never run days in +parallel: the agent serialises package runs and the worker is shared), re-minting the API-key token +per day (tokens live 60 minutes, backfills do not). + +Verify a day from the Lake (what the dashboard will show), never from the execution outputs alone: + +```sql +WITH f AS (SELECT JSONExtractString(data,'day') day, JSONExtractString(data,'application_id') appid, + JSONExtractString(data,'allocation_method') method, JSONExtractString(data,'collected_at') run, + JSONExtractFloat(data,'cost_usd') usd FROM (SELECT id, argMax(data,_version) data FROM catalog_entities + WHERE entity_specification_id = '' GROUP BY id HAVING argMax(_deleted,_version) = 0) + WHERE JSONExtractString(data,'stage') = 'allocated' AND JSONExtractString(data,'allocation_method') != 'rollup' + AND JSONExtractString(data,'subject_type') != 'unallocated' QUALIFY run = max(run) OVER (PARTITION BY day)) +SELECT day, count() rows, round(sum(usd),2) total, round(sumIf(usd, appid != ''),2) apps, + round(sumIf(usd, method = 'unallocated'),2) unalloc FROM f GROUP BY day ORDER BY day +``` + +`total` must equal Cost Explorer (amortized) for the day; `apps / total` is the attribution. + +**Deleting bad data**: `DELETE /catalog/instances//` cleans the catalog only — the Lake keeps +the rows (`_deleted` never flips). The dashboard hides them because it keeps the latest run per day, +so the cure for a bad day is a good re-run of that day, not a delete. Delete first, then the +dashboard shows nothing for that day until the re-run lands. + +## 6. Dashboard + +The definition is generated by `setup/report-finops.py --spec-id ` and persisted +with the np-report skill (`POST /report`, draft; publishing is a separate step). Rules that matter: +- base query = latest run per day over `catalog_entities` (see §3), `argMax(data,_version)` + instead of `FINAL` over the whole table (6–8 s → ~1 s); +- filter `params` keys MUST equal the schema property names or the frontend never re-runs the + query on change; +- an area chart with one day renders nothing → stacked bars; +- KPIs: total = applications + shared platform buckets + Kubernetes overhead + unallocated. +- `report-finops.py --spec-id … --usage-spec-id … [--top 8]` needs `NP_TOKEN` to read the top-N values + per grouping (the explorer's stacked charts have fixed series); regenerate after a backfill. +- A `visibility: user` report belongs to the user who created it: `PATCH /report/` with the org's + API key is a 403 — use that user's session token. Publishing is a separate explicit step. +- Frontend quirks that cost an hour each: JSON Forms `rule` (SHOW/HIDE) is ignored; `Categorization` + tabs render the hidden charts with zero width; a nested query target is never written; stacked bars + with many series + `borderRadius` draw as 1px outlines (no radius, explicit `colors`). +- It can be created before the first collection: every query must still run with empty params + (KPIs return one row of 0/NULL, arrays return nothing) — verify with `ch_query.sh` as in the + np-report skill, then `POST /report` with the customer's session bearer. + +## 7. Operations + +### Symptoms → cause (all seen on 2026-09-11) + +| Symptom | Cause | Fix | +|---|---|---| +| `Command failed to start after all retry attempts` (agentId `unknown`) | agent < 0.9.0 has no `package-exec` | upgrade the agent (0.11.1) | +| `agent_command HTTP 403` in `call_package` | the API key's role lacks `agent:run_command` on the account NRN | key with `ops` (or grant it), config entry `NP_API_KEY` at `/finops` | +| `Lake query failed … only one statement per request` | a `;` inside an SQL comment | no comments in Lake SQL | +| `The specified ServiceType is invalid for engine chimera` | Performance Insights on DocumentDB | `ServiceType: DOCDB` | +| `NerdGraph HTTP undefined` | http-request outputs `statusCode`, not `status` | read `statusCode` | +| `STEP_INPUT_TOO_LARGE … 2362076 bytes` on `allocate` | the catalog list has NO day filter: every day's raw facts came back | `read_raw` reads the day from the Lake | +| `input exceeds sandbox boundary limit of 1048576` on `summary` | batches passed as a code-exec input | batches only reach outputs on dry runs | +| parent stuck on a completed child, `:allocate:0:2` appears | engine: sub-workflow completion missed, step retried at ~6 min | run wf2 alone (§5.1) | +| `Workflow history size exceeds limit` on `suggest` | suggestions fan-out per application (26 apps) | pending (`wf-suggest-mappings`) | +| dashboard shows a day you deleted | Lake keeps deleted rows | re-run the day | + + +- Every daily run is idempotent per day: ids are deterministic (`raw---`, + `alloc--[-]`, `-`), writes are upserts, and each workflow + deletes the day's rows it did not produce (its own `source` only). +- Sizes: the code sandbox caps a step output at 1 MB; the parent workflow's Temporal history must + stay small — batches of 40 facts per child, `output_projection` on read steps, never a + per-row fan-out (752 children re-ran in a loop once the history passed ~14 MB). +- Command completions > ~400 KB are dropped by the platform: the worker returns a receipt and the + engine reads the result through the callback (`callback_allowed_hosts`). +- Performance Insights needs `Date` objects: the worker coerces ISO strings under `*Time` keys. +- The old per-pod cost tracker (metadata `cost_tracking`) is superseded by `wf3` + the invoices. diff --git a/finops/docs/iam/README.md b/finops/docs/iam/README.md new file mode 100644 index 0000000..c0b17e4 --- /dev/null +++ b/finops/docs/iam/README.md @@ -0,0 +1,17 @@ +# IAM for the cloud-query worker + +| File | Attach to | Purpose | +|---|---|---| +| `np-finops-worker-policy.json` | the role that READS billing (worker role, or the per-account role when AssumeRole is used) | least-privilege read-only for phases 1–3 (Cost Explorer, inventory, tags, CloudWatch metrics + log groups, RDS Performance Insights). Every action is a read; `Resource: "*"` because these APIs are not resource-scoped. | +| `np-finops-worker-policy-cur.json` | same role, only when the account has a CUR queried through Athena | scoped to the finops Athena workgroup, the CUR Glue database/table and the two buckets — replace every placeholder | +| `trust-irsa.json` | the worker role, EKS with IRSA | one OIDC provider per cluster; `sub` pins the agent namespace + service account `np-cloud-query` | +| `trust-pod-identity.json` | the worker role, EKS Pod Identity | scoped to the cluster ARN + account (`aws:SourceArn`/`aws:SourceAccount`); then `aws eks create-pod-identity-association --cluster-name --namespace --service-account np-cloud-query --role-arn ` | +| `np-finops-worker-assume-policy.json` | the worker role, multi-account | lets the base identity assume `np-finops` in the LISTED accounts only, with the ExternalId (add one ARN per account; never `::*:`) | +| `trust-cross-account.json` | each customer/account role `np-finops` | trusts the worker role, gated by an `ExternalId` per customer | + +Two shapes: + +1. **Single account** — worker role = reader: `trust-irsa.json` (or pod identity) + `np-finops-worker-policy.json`. +2. **Multi-account** — worker role = `np-finops-worker` with only `np-finops-worker-assume-policy.json`; each account has `np-finops` with `trust-cross-account.json` + `np-finops-worker-policy.json`; the dispatcher target sets `assume_role_arn` + `assume_role_external_id`. + +Cost Explorer notes: linked accounts need "linked account access to Cost Explorer" enabled at the payer; cost allocation tags are activated at the payer; every Cost Explorer API call costs USD 0.01. diff --git a/finops/docs/iam/np-finops-worker-assume-policy.json b/finops/docs/iam/np-finops-worker-assume-policy.json new file mode 100644 index 0000000..2988dfd --- /dev/null +++ b/finops/docs/iam/np-finops-worker-assume-policy.json @@ -0,0 +1,15 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AssumeCustomerAccountRoles", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": [ + "arn:aws:iam:::role/np-finops", + "arn:aws:iam:::role/np-finops" + ], + "Condition": { "StringEquals": { "sts:ExternalId": "" } } + } + ] +} diff --git a/finops/docs/iam/np-finops-worker-policy-cur.json b/finops/docs/iam/np-finops-worker-policy-cur.json new file mode 100644 index 0000000..b917258 --- /dev/null +++ b/finops/docs/iam/np-finops-worker-policy-cur.json @@ -0,0 +1,43 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AthenaQueriesInTheFinopsWorkgroup", + "Effect": "Allow", + "Action": [ + "athena:StartQueryExecution", + "athena:GetQueryExecution", + "athena:GetQueryResults", + "athena:GetWorkGroup" + ], + "Resource": "arn:aws:athena:::workgroup/" + }, + { + "Sid": "CurCatalogRead", + "Effect": "Allow", + "Action": ["glue:GetDatabase", "glue:GetTable", "glue:GetPartitions"], + "Resource": [ + "arn:aws:glue:::catalog", + "arn:aws:glue:::database/", + "arn:aws:glue:::table//" + ] + }, + { + "Sid": "CurBuckets", + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:ListBucket", "s3:GetBucketLocation"], + "Resource": [ + "arn:aws:s3:::", + "arn:aws:s3:::/*", + "arn:aws:s3:::", + "arn:aws:s3:::/*" + ] + }, + { + "Sid": "AthenaResultsWrite", + "Effect": "Allow", + "Action": ["s3:PutObject", "s3:AbortMultipartUpload"], + "Resource": "arn:aws:s3:::/*" + } + ] +} diff --git a/finops/docs/iam/np-finops-worker-policy.json b/finops/docs/iam/np-finops-worker-policy.json new file mode 100644 index 0000000..ca52a7c --- /dev/null +++ b/finops/docs/iam/np-finops-worker-policy.json @@ -0,0 +1,91 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "BillingRead", + "Effect": "Allow", + "Action": [ + "ce:GetCostAndUsage", + "ce:GetCostAndUsageWithResources", + "ce:GetDimensionValues", + "ce:GetTags", + "ce:GetCostCategories", + "ce:ListCostAllocationTags", + "ce:GetSavingsPlansUtilization", + "ce:GetSavingsPlansCoverage", + "ce:GetReservationUtilization", + "ce:GetReservationCoverage", + "pricing:GetProducts" + ], + "Resource": "*" + }, + { + "Sid": "InventoryRead", + "Effect": "Allow", + "Action": [ + "ec2:DescribeInstances", + "ec2:DescribeInstanceTypes", + "ec2:DescribeVolumes", + "ec2:DescribeTags", + "ec2:DescribeNatGateways", + "ec2:DescribeVpcEndpoints", + "ec2:DescribeAddresses", + "autoscaling:DescribeAutoScalingGroups", + "elasticloadbalancing:DescribeLoadBalancers", + "elasticloadbalancing:DescribeTags", + "rds:DescribeDBClusters", + "rds:DescribeDBInstances", + "rds:ListTagsForResource", + "elasticache:DescribeReplicationGroups", + "elasticache:DescribeCacheClusters", + "eks:ListClusters", + "eks:DescribeCluster", + "eks:ListNodegroups", + "eks:DescribeNodegroup", + "tag:GetResources", + "s3:ListAllMyBuckets", + "s3:GetBucketTagging", + "s3:GetBucketLocation", + "sqs:ListQueues", + "sqs:GetQueueAttributes", + "sqs:ListQueueTags", + "sns:ListTopics", + "sns:ListTagsForResource", + "lambda:ListFunctions", + "lambda:ListTags", + "dynamodb:ListTables", + "dynamodb:DescribeTable", + "dynamodb:ListTagsOfResource", + "cloudfront:ListDistributions", + "cloudfront:ListTagsForResource", + "es:ListDomainNames", + "es:DescribeDomains", + "es:ListTags", + "ecr:DescribeRepositories", + "ecr:ListTagsForResource" + ], + "Resource": "*" + }, + { + "Sid": "UsageMetricsRead", + "Effect": "Allow", + "Action": [ + "cloudwatch:GetMetricData", + "cloudwatch:ListMetrics", + "logs:DescribeLogGroups", + "logs:ListTagsForResource", + "pi:GetResourceMetrics", + "pi:DescribeDimensionKeys" + ], + "Resource": "*" + }, + { + "Sid": "Identity", + "Effect": "Allow", + "Action": [ + "sts:GetCallerIdentity" + ], + "Resource": "*" + } + ] +} diff --git a/finops/docs/iam/trust-cross-account.json b/finops/docs/iam/trust-cross-account.json new file mode 100644 index 0000000..b07f150 --- /dev/null +++ b/finops/docs/iam/trust-cross-account.json @@ -0,0 +1,12 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AssumedByTheFinopsWorkerRole", + "Effect": "Allow", + "Principal": { "AWS": "arn:aws:iam:::role/np-finops-worker" }, + "Action": "sts:AssumeRole", + "Condition": { "StringEquals": { "sts:ExternalId": "" } } + } + ] +} diff --git a/finops/docs/iam/trust-irsa.json b/finops/docs/iam/trust-irsa.json new file mode 100644 index 0000000..0584344 --- /dev/null +++ b/finops/docs/iam/trust-irsa.json @@ -0,0 +1,17 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "WorkerPodViaIRSA", + "Effect": "Allow", + "Principal": { "Federated": "arn:aws:iam:::oidc-provider/oidc.eks..amazonaws.com/id/" }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "oidc.eks..amazonaws.com/id/:aud": "sts.amazonaws.com", + "oidc.eks..amazonaws.com/id/:sub": "system:serviceaccount::np-cloud-query" + } + } + } + ] +} diff --git a/finops/docs/iam/trust-pod-identity.json b/finops/docs/iam/trust-pod-identity.json new file mode 100644 index 0000000..340b88f --- /dev/null +++ b/finops/docs/iam/trust-pod-identity.json @@ -0,0 +1,15 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "WorkerPodViaEksPodIdentity", + "Effect": "Allow", + "Principal": { "Service": "pods.eks.amazonaws.com" }, + "Action": ["sts:AssumeRole", "sts:TagSession"], + "Condition": { + "StringEquals": { "aws:SourceAccount": "" }, + "ArnEquals": { "aws:SourceArn": "arn:aws:eks:::cluster/" } + } + } + ] +} diff --git a/finops/docs/mapping-playbook.md b/finops/docs/mapping-playbook.md new file mode 100644 index 0000000..b16ae85 --- /dev/null +++ b/finops/docs/mapping-playbook.md @@ -0,0 +1,229 @@ +# Playbook: analyze an AWS account and map its cost to nullplatform + +How to repeat the kwik-e-mart analysis for any account (itti next), decide +service by service how each cost is attributed, and get the identifiers that +join cloud resources to null entities. Commands use an admin/read profile +locally; in production the same calls run inside the `cloud-query` package +with the pod's IAM role. + +## 0. Prerequisites and the two questions to answer first + +```bash +export AWS_PROFILE= AWS_DEFAULT_REGION=us-east-1 +aws sts get-caller-identity +aws organizations describe-organization 2>/dev/null # payer or linked? +aws cur describe-report-definitions; aws bcm-data-exports list-exports +aws ce list-cost-allocation-tags --status Active +``` + +| Question | If yes | If no | +|---|---|---| +| Is there a **CUR / Data Export**? | resource-level cost for every service (Athena) → add `athena`/`s3` to the runner and read per resource | Cost Explorer only: per resource for EC2 (14 days), per usage type for the rest | +| Are **cost allocation tags** active (`scope_id`, `application_id`)? | `GROUP BY TAG` attributes Lambda, API GW, ECR, Logs directly | prorate service totals by inventory + usage (CloudWatch), or ask the payer admin to activate them | + +Always read **amortized** cost (`AmortizedCost`); check for Savings Plans / +RIs with `GROUP BY PURCHASE_TYPE`. Unblended is only kept for transparency. + +## 1. Inventory (one pass, all services) + +```bash +# every resource that was ever tagged, with its tags (paginated) +aws resourcegroupstaggingapi get-resources --resources-per-page 100 +# never-tagged resources are NOT returned — list them per service: +aws s3api list-buckets; aws dynamodb list-tables; aws lambda list-functions +aws rds describe-db-clusters; aws rds describe-db-instances; aws elasticache describe-replication-groups +aws elbv2 describe-load-balancers; aws ec2 describe-nat-gateways; aws ec2 describe-volumes +``` + +Count per service: total, with null tags, with cluster tags. Note the tag +**conventions** (plain `scope_id` vs `nullplatform:scope-id`) — the collector +normalizes both. + +## 2. Cost, three cuts for one day + +```bash +D=2026-09-09; N=2026-09-10 +aws ce get-cost-and-usage --time-period Start=$D,End=$N --granularity DAILY --metrics AmortizedCost UnblendedCost --group-by Type=DIMENSION,Key=SERVICE +aws ce get-cost-and-usage --time-period Start=$D,End=$N --granularity DAILY --metrics AmortizedCost UsageQuantity --group-by Type=DIMENSION,Key=SERVICE Type=DIMENSION,Key=USAGE_TYPE +aws ce get-cost-and-usage-with-resources --time-period Start=$D,End=$N --granularity DAILY --metrics AmortizedCost UsageQuantity --group-by Type=DIMENSION,Key=RESOURCE_ID --filter '{"Dimensions":{"Key":"SERVICE","Values":["Amazon Elastic Compute Cloud - Compute"]}}' +``` + +The first is the truth (Σ = the day). The second explains each service. The +third is the only per-resource view Cost Explorer has (EC2, 14 days). + +## 3. Service-by-service mapping decision + +Use this table to classify every service that shows up in §2. `Key` is the +identifier that joins the cloud resource to null. + +| Service | Attribution | Key to null | Data source | Notes | +|---|---|---|---|---| +| EC2 instances (scopes) | **direct** | tags `scope_id`/`application_id` on the instance | CE resource-level + `DescribeInstances` | exact, amortized | +| EC2 instances (k8s nodes) | **cluster** | tag `eks:cluster-name` / `kubernetes.io/cluster/` | idem | composed into the cluster fact | +| EKS control plane | cluster | service EKS ÷ clusters | CE by service | | +| ELB | cluster or direct | tag `elbv2.k8s.aws/cluster` → cluster; else `scope_id` tag | tagging API + `DescribeLoadBalancers` (denominator) | share by LB count | +| VPC (NAT, endpoints, IPv4), data transfer | cluster (networking) | VPC of the cluster | CE usage types | when several VPCs, split by VPC of the resources | +| EBS | cluster / scope | volume attachment → instance → its owner | `DescribeVolumes` + EBS usage types | prorated by GB | +| RDS / Aurora | **direct → null service** | `service.attributes.host` == cluster/instance **endpoint**; ideally the package stores the ARN | `DescribeDBClusters/Instances` + `GET /service?show_descendants=true` | `allocation_method: service_owner`; several DBs → split RDS cost by instance-hours or DB count | +| ElastiCache, OpenSearch, MSK | direct → null service | endpoint == `attributes.host`/`endpoint` | describe-* + null services | same rule as RDS | +| Lambda, API Gateway, ECR, CloudWatch Logs, CloudFront, Amplify | direct by tag | null tags on the resource | tagging API; cost per resource needs activated allocation tags or CUR; else prorate by CloudWatch usage (invocations, GB-s, requests, stored bytes) | | +| S3, DynamoDB, SQS | rule | usually **untagged** → naming rule / `cost_allocation_rule`; ask provisioning to tag | inventory + CloudWatch (BucketSizeBytes, ConsumedCapacity) for proration | | +| CloudWatch metrics/alarms, KMS, Secrets Manager, Route 53, Kiro, Support | shared | none | CE by service | explicit `unallocated` buckets; a rule can split them by app count or spend share | + +Databases and caches that run **inside** the cluster (null services with +`hostname 172.20.x.x` / `helm_release_name`) have no AWS line: they are part +of the cluster's consumption and get allocated in phase 3 by namespace/pod +→ service via the release name. + +## 4. Kubernetes recommendation + +- Compose the cluster: nodes + control plane + tagged LBs + VPC networking + + attached EBS + other EC2 charges. Store the composition as component rows. +- Do **not** split the cluster per scope from billing. Derive **blended rates** + over reserved capacity: `rate_cpu = cost × cpu_share ÷ Σ(vCPU × hours)`, + `rate_mem = cost × (1 − cpu_share) ÷ Σ(GiB × hours)`; `cpu_share` defaults to + 0.5 (Kubecost convention), tune per org. Capacity from `DescribeInstanceTypes` + and instance-hours from CE resource-level. +- Allocate by consumption (phase 3): a package running in the cluster reads + requests/usage per pod (metrics.k8s.io or Prometheus when present), maps pods + to scope/service via labels, and the allocator charges + `cpu_core_h × rate_cpu + gib_h × rate_mem`. Idle capacity stays on the + cluster as an explicit bucket. Iron rule from the `cost/` suite: compare + usage vs request **per pod**, never per fleet. +- The pod's identity for cost is the pod's IAM role (IRSA / Pod Identity): the + `cloud-query` worker needs `ce:*Get*`, `ec2:Describe*`, `elasticloadbalancing:Describe*`, + `rds:Describe*`, `tag:GetResources`, `cloudwatch:GetMetricData`, `sts:GetCallerIdentity`. + +## 4b. The role the worker runs with — configured per customer + +Three knobs, all per target and all without registering the package on the +platform. `wf0-aws-billing-dispatch` holds one **target per account**: + +```json +{ "name": "prod", "agent_tags": { "package": "cloud-query", "account": "prod" }, + "package_version": "0.0.1", + "assume_role_arn": "arn:aws:iam::111122223333:role/np-finops", "assume_role_external_id": "…", + "region": "us-east-1", "expected_account": "111122223333" } +``` + +| Knob | Chooses | Where it is set | +|---|---|---| +| `agent_tags` | **which agent** (cluster / environment) runs the worker | target | +| `package_version` | **which worker pin** on that agent — and the pin declares the pod **service account** → IAM role (pins are keyed by package + version; different SAs = different pinned versions/patch targets) | target + agent Helm values | +| `assume_role_arn` (+ `assume_role_external_id`) | the **AWS role** assumed before the calls, per account | target (or config entry) | + +`expected_account` makes the run fail when the STS identity the worker ended up +with is another account — the facts always carry `cloud_account` from that +identity, never from config. + +Two layers underneath: + +**Layer 1 — identity of the worker pod (per cluster, in the agent Helm values).** +The agent patches the worker pod for the `cloud-query` package with a service +account; that service account carries the IAM role (IRSA or EKS Pod Identity): + +```yaml +worker: + allowedRegistries: ["public.ecr.aws/nullplatform/*"] + patches: + - target: { package: cloud-query } + merge: + spec: + serviceAccountName: np-cloud-query # annotated eks.amazonaws.com/role-arn: arn:aws:iam:::role/np-finops-worker +``` + +The role's policy is the read-only set below. Trust: the cluster's OIDC +provider with `sub = system:serviceaccount::np-cloud-query` (IRSA) or +`pods.eks.amazonaws.com` (Pod Identity). + +**Layer 2 — AssumeRole per account (per customer, in the workflow config).** +When the customer wants its own role, or bills several accounts, the worker +assumes a role before every call. `wf1-aws-billing-daily` takes +`assume_role_arn` + `assume_role_external_id` (inputs or the workflow +variables `assume_role_arn` / `assume_role_external_id`; set them on the +customer's revision or from config entries). Trust policy of that role: + +```json +{ "Effect": "Allow", + "Principal": { "AWS": "arn:aws:iam:::role/np-finops-worker" }, + "Action": "sts:AssumeRole", + "Condition": { "StringEquals": { "sts:ExternalId": "" } } } +``` + +The worker's base role then only needs `sts:AssumeRole` on the customer roles; +the read-only policy lives on the customer role. One collector run per account +(the `cloud_account` dimension separates the facts). + +Read-only policy for whichever role does the reading: + +```json +{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Resource": "*", "Action": [ + "ce:GetCostAndUsage", "ce:GetCostAndUsageWithResources", "ce:GetDimensionValues", "ce:GetTags", + "ec2:DescribeInstances", "ec2:DescribeVolumes", "ec2:DescribeInstanceTypes", "ec2:DescribeTags", + "elasticloadbalancing:DescribeLoadBalancers", "rds:DescribeDBClusters", "rds:DescribeDBInstances", + "tag:GetResources", "cloudwatch:GetMetricData", "cloudwatch:ListMetrics", "sts:GetCallerIdentity" ] }] } +``` + +## 5. Direct-cost recommendation + +1. Every resource null provisions must carry `application_id`, `scope_id` or + `service_id`, plus `namespace_id`, `account_id`, `environment` — in EC2 tags + (already), Lambda (already), API Gateway (prefixed variant), and **also S3, + DynamoDB, SQS, ElastiCache, RDS** (missing today). +2. Service packages should persist the cloud identifier in `attributes` + (`arn`, `identifier`, `host`). Host is enough for anything with an endpoint. +3. Activate cost allocation tags at the payer for `scope_id`, `application_id`, + `service_id`; with them, Cost Explorer attributes per tag with no inventory. +4. Anything shared (KMS, Secrets Manager, Route 53, support, seat licences) + stays in explicit `unallocated` buckets until a `cost_allocation_rule` says how + to split it. Never hide it inside another number. + +## 6. What the facts look like + +One row per subject per day in the `cost_daily` catalog spec. Every part of +the logical id is a field: + +```json +{"id": "raw-cluster-developent-2026-09-09", "date": "2026-09-09", "stage": "raw", + "subject_type": "cluster", "subject_id": "developent", "subject_name": "developent", + "cluster": "developent", "region": "us-east-1", "cloud": "aws", "cloud_account": "688720756067", + "cost_usd": 27.743594, "quantity": 9, "units": "nodes", + "cpu_capacity_core_h": 624, "mem_capacity_gb_h": 1440, "cpu_share": 0.5, + "rate_cpu_usd_core_h": 0.02223, "rate_mem_usd_gb_h": 0.009633, + "allocation_method": "unallocated", "source": "aws_ce", + "collector": "finops_aws_billing_daily@0.2.0", "collected_at": "2026-09-10T16:44:43.520Z"} +``` + +Sum rule: Σ `cost_usd` over `subject_type = cloud_service` equals the day's +amortized total. Buckets, resources, scopes and services carry `parent_id` +to the service fact; the cluster is derived from its components. The +`allocated` stage (next) produces one row per application per resource per +day, categorized (compute, kubernetes, database, storage, network, other) and +cut by environment / namespace / account. + +## 7. Checklist for a new account (itti) + +- [ ] payer or linked? CUR? allocation tags? Savings Plans / RIs? +- [ ] inventory with tag coverage per service, both tag conventions +- [ ] one day of cost in the three cuts; Σ services = amortized total +- [ ] cluster composition and blended rates for each EKS cluster +- [ ] databases/caches ↔ null services by host/ARN; list the unmatched +- [ ] untagged S3 / DynamoDB / SQS: naming rule or tagging request +- [ ] IAM role for the worker (IRSA), agent Helm values (`allowedRegistries`, pin, `serviceAccount`) +- [ ] run `wf1-aws-billing-daily` in `dry_run`, review the summary, then write + + +## 6. From mapping by hand to rules (2026-09-11) + +Everything §1–§5 discovered for one account becomes DATA, not code: + +1. Run the collector once (`dry_run`), open `wf2`'s `unallocated_leaves` (or the + `alloc-unallocated-` row): that is the list to work through, ordered by USD. +2. For each leaf, find the evidence: null tags (`tags.application_id` → `capture`), a null + service host (`default:null-service` already handles it), application parameters (the + suggestion workflow scans them; `setup/03-mapping-rules.sh` loads what you accept), naming + conventions (regex with named groups → `capture`), or a business decision (`bucket`). +3. Shared resources get a `split` now and a `by_metric` later (Performance Insights `db.name`, + log-group `IncomingBytes`, k8s requests): the rule stays, only the method changes. +4. Re-run `wf2` for the day: rules are versioned and every allocated row records its `rule_id`, + so a day can be re-allocated after a rule change without re-collecting billing. diff --git a/finops/docs/mapping-rules-design.md b/finops/docs/mapping-rules-design.md new file mode 100644 index 0000000..6bfbe77 --- /dev/null +++ b/finops/docs/mapping-rules-design.md @@ -0,0 +1,108 @@ +# Mapping rules: how cloud resources become null owners (design, 2026-09-11) + +Status (2026-09-11): IMPLEMENTED for phase A — specs `cost_mapping_rule` / +`cost_mapping_suggestion`, allocator `wf2-allocate-daily.yaml` (scope/match/capture/split/map, +priority, defaults, reconciliation), inference `wf-suggest-mappings.yaml` (null services by host, +application parameters), daily loop in `wf0`. Not yet: `by_metric` (needs the consumption +collectors: Performance Insights by `db.name`, log-group `IncomingBytes`, k8s usage), naming- +convention inference, action items for unallocated cost. The README's "Configuration" section is +the user-facing version of this document. + +## Problem + +`wf1` maps cloud cost to null in code: EC2 instances by `scope_id` tag, RDS clusters by +host equality with a null service, nodes by EKS tags. That is fine for one account we +know; it does not scale to a customer with hundreds of resources, mixed naming, secrets +in parameters and databases shared by many services. The mapping has to be **data**, +authored per organization, with inference where evidence exists and human rules where it +does not — never resource-by-resource hardcoding. + +## Model + +Two catalog entities per organization. + +### `cost_mapping_rule` — what the customer (or we) decide + +| Field | Meaning | +|---|---| +| `id`, `name`, `enabled`, `priority` | rules are evaluated by ascending priority, first match wins | +| `scope` | what the rule looks at: `cloud`, `cloud_service` (CE service name), `resource_type` (`rds:cluster`, `sqs:queue`, `logs:log-group`, `lambda:function`, `ec2:instance`, `k8s:namespace`, `pg:database`, …) | +| `match` | list of predicates, all must hold: `tag:` equals / matches regex, `name` regex, `arn` regex, `host` equals, `parameter` (an app parameter value equals the resource host/name), `k8s_label:`, `usage_type` regex | +| `target` | where the cost goes: `{application_id}` / `{scope_id}` / `{service_id}` / `{namespace_id}` / `{cluster: }` / `{bucket: "shared-platform"}` — or `capture`: take the target from the match itself (`application_id` from tag `application_id`, from regex group, from the parameter's application) | +| `method` | `direct` (100% to the target), `split` (several targets with weights), `by_metric` (proportional to a metric the collectors already store: PI `db.load` by `db.name`, log group `IncomingBytes`, k8s requests/usage, SQS `NumberOfMessagesSent`) | +| `source` | `inferred` \| `manual` \| `suggested`; `confidence` 0–1; `evidence` (what produced it) | +| `status` | `active` \| `proposed` \| `rejected` — proposed rules do nothing until approved | + +Examples (nullplatform): + +```yaml +- name: null tags → application # generic, ships by default + scope: {cloud: aws} + match: [{tag: application_id, regex: "^[0-9]+$"}] + target: {capture: {application_id: "tag:application_id", scope_id: "tag:scope_id"}} + method: direct +- name: RDS cluster host = null service host # generic, ships by default + scope: {resource_type: "rds:cluster"} + match: [{host: {equals: "null_service.attributes.host"}}] + target: {capture: {service_id: "null_service.id"}} +- name: shared approvals Aurora by database load # inferred from parameters, approved by a human + scope: {resource_type: "rds:cluster", name: "postgres-approvals-api-db"} + method: {by_metric: "pi.db.load", dimension: "db.name"} + target: {map: {catalog_entities_api: {application_id: 1234}, core_entities_api: {application_id: 5678}, tracing_api_production: {application_id: 91011}}} +- name: log groups named . + scope: {resource_type: "logs:log-group"} + match: [{name: {regex: "^(?[a-z0-9-]+)\\.(?[a-z0-9-]+)$"}}] + target: {capture: {namespace_slug: "$namespace", application_slug: "$application"}} + method: {by_metric: "cloudwatch.IncomingBytes"} +- name: everything security/compliance is platform + scope: {cloud_service: {regex: "GuardDuty|Security Hub|Config|Inspector|WAF"}} + target: {bucket: shared-platform} +``` + +### `cost_mapping_suggestion` — what we infer, for a human to accept + +Same shape as a rule plus `evidence[]`. Produced by an **inference workflow** that runs +after each collection and looks at what is still unallocated. Evidence sources, in order +of trust: + +1. **null ids in tags** (`application_id`, `scope_id`, `namespace_id`, `deployment_id`) — exact, confidence 1.0. Cost Explorer can group by these directly once they are activated as cost allocation tags; today nullplatform has `application`, `namespace`, `scope` (names). +2. **null services** (`GET /service`): `attributes.host`/`hostname`/`endpoint` equals a cloud host — exact (RDS, ElastiCache, OpenSearch). +3. **application parameters** (`GET /parameter?nrn=`): a value contains the cloud host / queue URL / bucket / table name → the app is a consumer. Database names come from `DB_NAME`-like parameters. Secrets are not readable (we only see that a secret parameter exists), and CNAMEs (`*.db.nullservices.io`) must be resolved to the RDS host. Confidence 0.8; many consumers → `split`/`by_metric` suggestion. +4. **naming conventions** learned per org: log groups `.`, queues/topics/buckets containing an application slug, Lambda function names, k8s namespaces/labels. Confidence 0.6, always `proposed`. +5. **nothing** → stays in `unallocated` buckets, listed by cost so the customer sees what a rule would recover. + +The suggestion workflow can also create an **action item** per unallocated cost above a +threshold ("$26/day of CloudWatch is unattributed; 81 log groups match `.`; accept +rule X?"), which is the governance loop we already have. + +## Where inference cannot go: shared resources + +Shared clusters (RDS, Redis, OpenSearch, the EKS cluster itself) are never "mapped": they +are **split by a consumption metric**, and the rule only says which metric and how +consumers are keyed: + +| Shared resource | Metric | Keyed by | Consumer → owner | +|---|---|---|---| +| EKS cluster | requests/usage × blended rates (phase 3) | k8s namespace + pod labels | null scope labels | +| RDS / Aurora cluster | Performance Insights `db.load` by `db.name` (compute), `pg_database_size` (storage), `pg_stat_database` blocks (I/O) | database name / db user | parameters → application (suggested), rule map | +| ElastiCache | CloudWatch `CurrConnections` per node, or client tags | client app | rule | +| CloudWatch | `IncomingBytes` per log group; vended logs by source | log group name | naming rule | +| NAT / VPC / LB | shared → cluster components (already) | | | + +## Evaluation order in the allocator + +1. Rules with `status=active`, ascending priority, first match per resource. +2. Default generic rules (null tags, null service host) as the lowest priority. +3. Anything left → `unallocated` bucket per cloud service, and a suggestion pass. + +Rules are versioned catalog entities: the allocator records `rule_id` on every allocated +fact, so a rule change is auditable and a day can be re-allocated after a rule is fixed +without re-collecting billing. + +## What changes in the code + +- `wf1` stops mapping: it only produces `raw` facts with all the evidence fields (tags, host, + name, ARN, cluster, usage type). The mapping moves to the allocator. +- New `wf2-allocate`: per day, reads raw facts + active rules, writes `allocated` facts. +- New `wf-suggest-mappings`: after allocation, evidence sources 2–4 → suggestions/action items. +- Spec additions: `cost_mapping_rule`, `cost_mapping_suggestion`; `rule_id` on `cost_daily`. diff --git a/finops/packages/cloud-query/.github/workflows/release.yml b/finops/packages/cloud-query/.github/workflows/release.yml new file mode 100644 index 0000000..c2042a0 --- /dev/null +++ b/finops/packages/cloud-query/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: release + +# Publish this scope package to nullplatform when you cut a GitHub Release. +# `np package publish` builds + pushes the lean worker image tagged with the +# release version (mise `publish:image`) and registers the scope type, actions, +# channel, and the package revision pinning that image. +# +# Works out of the box: the image goes to this repo's GitHub Container Registry +# (ghcr.io//) using the built-in GITHUB_TOKEN — no extra secrets. +# To push elsewhere, set the NP_PUSH_REGISTRY variable and REGISTRY_USERNAME / +# REGISTRY_PASSWORD secrets. +# +# Configure: secret NULLPLATFORM_API_KEY, variable NP_NRN. + +on: + release: + types: [published] + workflow_dispatch: {} + +permissions: + contents: read + packages: write + +env: + NP_API_KEY: ${{ secrets.NULLPLATFORM_API_KEY }} + NP_PUSH_REGISTRY: ${{ vars.NP_PUSH_REGISTRY || format('ghcr.io/{0}', github.repository) }} + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # mise provides bun/oras and runs the package's own tasks. + - uses: jdx/mise-action@v2 + # buildx + QEMU so publish:image can build the multi-arch (amd64+arm64) worker. + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - name: Install np CLI + run: | + curl https://cli.nullplatform.com/install.sh | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Log in to the container registry + env: + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + GHCR_USER: ${{ github.actor }} + GHCR_TOKEN: ${{ github.token }} + run: | + user="${REGISTRY_USERNAME:-$GHCR_USER}" + pass="${REGISTRY_PASSWORD:-$GHCR_TOKEN}" + echo "$pass" | docker login "${NP_PUSH_REGISTRY%%/*}" -u "$user" --password-stdin + # The release tag is the version: the worker image is pushed as + # registry/repo: and the package revision is pinned to it. + - name: Publish package + env: + NP_VERSION: ${{ github.event.release.tag_name }} + run: np package publish --nrn "${{ vars.NP_NRN }}" --version "${NP_VERSION#v}" diff --git a/finops/packages/cloud-query/.gitignore b/finops/packages/cloud-query/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/finops/packages/cloud-query/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/finops/packages/cloud-query/AGENTS.md b/finops/packages/cloud-query/AGENTS.md new file mode 100644 index 0000000..49884c2 --- /dev/null +++ b/finops/packages/cloud-query/AGENTS.md @@ -0,0 +1,43 @@ +# AGENTS.md — cloud-query + +Guidance for AI agents / new contributors working in this **custom command** package. + +## What this is + +The low-level plugin path: raw `createPlugin` + `registerManifest` from +`@nullplatform/plugin` (no `defineScope`/`defineService`). Compiled to a +standalone binary and run as a **gRPC worker** the controlplane agent spawns. +You own the manifest and the single `execute(req)` entry point; there's no +action routing or lifecycle scaffolding. + +## File map + +``` +src/index.ts registerManifest({...}); --describe guard; createPlugin({ execute }).start() +Dockerfile lean worker image (the agent spawns this) +mise.toml tasks: test, build:image, run, publish:image +``` + +## How it works + +- `registerManifest({ name, version, command_types })` declares identity in + memory (raw createPlugin has no `plugin.yaml`). +- The `--describe` guard prints that manifest and exits — `np package publish` + runs the binary with `--describe` to read it. **Keep this guard**; without it, + publish fails. +- `createPlugin({ execute }).start()` boots the gRPC server (only when + `NP_AGENT_PLUGIN` is set, which the agent does at spawn). +- `execute(req)` gets `{ commandType, actionType, payload }` and returns + `{ success, data }`. + +## Rules that matter + +- **Keep the `--describe` block** before `.start()`. +- **Never set `NP_MODE=dev` in the image.** +- Return `{ success: false, error, errorCode }` on failure — don't throw silently. +- The agent injects the API key + TLS at spawn; don't hand-roll credentials. + +## Run / test + +- `mise run test` +- `np package run` — real local run: dockerized agent spawns this worker. Needs `NP_API_KEY`. diff --git a/finops/packages/cloud-query/Dockerfile b/finops/packages/cloud-query/Dockerfile new file mode 100644 index 0000000..f2d4ae9 --- /dev/null +++ b/finops/packages/cloud-query/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 +# Lean gRPC worker image — self-contained multi-stage build. `docker build` (or +# `docker buildx --platform ...` for multi-arch) compiles the worker inside the +# builder stage and ships just the binary on a minimal runtime. No prebuilt +# artifact, no dist/ — nothing to commit. +# +# The nullplatform agent spawns this as a worker (a pod in Kubernetes, a +# container in Docker) and streams actions to it over gRPC. NP_AGENT_PLUGIN makes +# the binary start the SDK's gRPC server; at runtime the agent injects +# NP_GRPC_LISTEN (the port), the mTLS material (NP_WORKER_TLS_*), and NP_API_KEY. +# Do NOT set NP_MODE=dev — that switches the SDK to the in-memory client and the +# worker would never report action lifecycle (scopes hang/fail). + +FROM oven/bun:1-alpine AS build +WORKDIR /app +COPY . . +RUN bun install +# buildkit sets TARGETARCH (amd64|arm64); compile the matching musl target for the +# alpine runtime below. bun cross-compiles from any host, so multi-arch buildx +# needs no emulation for the compile step. +ARG TARGETARCH +RUN if [ "$TARGETARCH" = "arm64" ]; then t=bun-linux-arm64-musl; else t=bun-linux-x64-musl; fi; \ + bun build --compile --target="$t" ./src/index.ts --outfile /app/worker + +FROM alpine:3.20 +# the bun musl binary links libstdc++/libgcc dynamically. +RUN apk add --no-cache libstdc++ libgcc +COPY --from=build /app/worker /app/worker +# node-config: ship the config dir; custom-environment-variables.json maps env +# vars -> config keys (the single declarative place env config lives). +COPY config/ /app/config/ +ENV NODE_CONFIG_DIR=/app/config \ + SUPPRESS_NO_CONFIG_WARNING=1 \ + NP_AGENT_PLUGIN=np-agent-v1 +EXPOSE 50051 +ENTRYPOINT ["/app/worker"] diff --git a/finops/packages/cloud-query/README.md b/finops/packages/cloud-query/README.md new file mode 100644 index 0000000..47cc408 --- /dev/null +++ b/finops/packages/cloud-query/README.md @@ -0,0 +1,80 @@ +# cloud-query + +Generic cloud SDK call runner, shipped as a nullplatform **package** (`simple` +type). A workflow sends a list of SDK calls; the worker runs them with the +credentials of the pod/container it runs in (IAM role in a cluster) and returns +the raw responses. It carries no cost semantics. + +## Request (`NP_ACTION_CONTEXT.cloud_query`) + +| Field | Type | Notes | +|---|---|---| +| `provider` | `"aws"` | only AWS for now | +| `region` | string | default `AWS_REGION` of the worker, else `us-east-1` | +| `assumeRole` | `{ roleArn, sessionName?, externalId? }` | optional STS AssumeRole before the calls — the per-customer/per-account role; the worker's base identity (pod service account) must be trusted by it. See `docs/mapping-playbook.md` §4b | +| `calls[]` | `{ id, service, operation, params?, paginate?, maxPages? }` | `service` in `ce`, `cost-explorer`, `cloudwatch`, `ec2`, `sts`, `tagging` (Resource Groups Tagging API), `elbv2`, `rds`; `operation` PascalCase SDK command | +| `maxResultBytes` | number | per-call cap, default 307200 | +| `callback` | `{ url, token? }` | POST the response here (engine callback); `token` is echoed. The host MUST be in `NP_CALLBACK_ALLOWED_HOSTS` (worker env, comma separated, default `api.nullplatform.com`) — SSRF guard, the worker runs inside the customer network | + +## Response + +`{ provider, region, identity?, token?, callbackDelivered?, calls: [{ id, ok, pages?, durationMs, result?, errorCode?, error? }] }` + +Output contract: progress on **stderr**, the response JSON alone on **stdout** +(the control plane exposes a command's stdout, not the gRPC `data`). Keep +results under the cap: Cost Explorer daily grouped by SERVICE+USAGE_TYPE for +14 days is ~360 KB; larger windows must be split by the caller. Over the cap +the call fails with `RESULT_TOO_LARGE` instead of being truncated. + +## Local run + +```bash +export NP_API_KEY=... # org API key (the agent registers with it) +export NP_WORKER_AWS_ACCESS_KEY_ID=... NP_WORKER_AWS_SECRET_ACCESS_KEY=... +mise run run # builds the image, starts the agent with tags package:cloud-query,local:$USER +docker logs -f np-cloud-query-agent +``` + +Dispatch by hand (sync, small): + +```bash +curl -s -X POST https://api.nullplatform.com/controlplane/agent_command \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"selector":{"package":"cloud-query","local":"'$USER'"},"execution_config":{"retry":{"max_attempts":1}}, + "command":{"type":"package-exec","data":{"package":"cloud-query","environment":{"NP_ACTION_CONTEXT":"{\"cloud_query\":{\"calls\":[{\"id\":\"who\",\"service\":\"sts\",\"operation\":\"GetCallerIdentity\"}]}}"}}}}' +``` + +From a workflow, use the `np-package-call` plugin (async with callback) or the +`finops/tool-cloud-query.yaml` child. + +## Release (registry owner only) + +The worker image lives in nullplatform's public ECR: +`public.ecr.aws/nullplatform/agent-plugins/workflows/aws-cost-explorer`. +Pushing needs AWS credentials with write access to that repository, which most +people do not have; `scripts/release.sh` does the login, a multi-arch +(amd64 + arm64) buildx build from the Dockerfile, the push, and writes +`scripts/release.json` with the immutable `@sha256` reference: + +```bash +export AWS_PROFILE= # or AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY +scripts/release.sh # version from package.json +scripts/release.sh 0.0.2 # explicit version +scripts/release.sh --dry-run # local build only, no login/push +``` + +Registering the version on the platform (`np package publish`) is **optional**: +`tool-cloud-query.yaml` sends the image as an immutable reference in the action +context and the agent pulls it directly, as long as the agent allows the +registry (`worker.allowedRegistries: ["public.ecr.aws/nullplatform/*"]`). Register +it when you want the version visible in the console or an operator pin: + +```bash +np-preview package publish --nrn "$NRN" --image "$(jq -r .image scripts/release.json)" +# agent Helm values: worker.allowedRegistries: ["public.ecr.aws/nullplatform/*"] +# worker.pins: [{package: cloud-query, version: 0.0.1, image: , serviceAccount: np-cloud-query}] +``` + +## Tests + +`mise run test` (bun). No live AWS in tests. diff --git a/finops/packages/cloud-query/bun.lock b/finops/packages/cloud-query/bun.lock new file mode 100644 index 0000000..88203a9 --- /dev/null +++ b/finops/packages/cloud-query/bun.lock @@ -0,0 +1,486 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "@nullplatform/plugin-cloud-query", + "dependencies": { + "@aws-sdk/client-cloudwatch": "^3.1129.0", + "@aws-sdk/client-cloudwatch-logs": "^3.1129.0", + "@aws-sdk/client-cost-explorer": "^3.1129.0", + "@aws-sdk/client-ec2": "^3.1129.0", + "@aws-sdk/client-elastic-load-balancing-v2": "^3.1129.0", + "@aws-sdk/client-pi": "^3.1129.0", + "@aws-sdk/client-rds": "^3.1129.0", + "@aws-sdk/client-resource-groups-tagging-api": "^3.1129.0", + "@aws-sdk/client-sts": "^3.1129.0", + "@nullplatform/plugin": "0.0.4", + "@nullplatform/workflow": "^0.0.5", + "@nullplatform/workflow-engine-in-memory": "^0.0.5", + "config": "^4.4.2", + }, + "devDependencies": { + "@types/bun": "^1.2.0", + }, + }, + }, + "trustedDependencies": [ + "@grpc/grpc-js", + ], + "packages": { + "@aws-sdk/client-cloudwatch": ["@aws-sdk/client-cloudwatch@3.1130.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-node": "^3.972.83", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/middleware-compression": "^4.6.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-XtBeFWchp4+mxl6aLbL+X072sTIFMgWx2rhrsMjmVOczXCFM443FbYOtRef3hMNAK5Uh9wzjWTdvj7CbIQpsMw=="], + + "@aws-sdk/client-cloudwatch-logs": ["@aws-sdk/client-cloudwatch-logs@3.1130.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-node": "^3.972.83", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Mgi2XVfIPWGE258C+zRcrlX2dFpE+jmt0aYQ3oLiaGILMmMGzdQ3Jq6rovxpy5aGAsgOTsPyQjPiWbv8fGHCvA=="], + + "@aws-sdk/client-cost-explorer": ["@aws-sdk/client-cost-explorer@3.1130.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-node": "^3.972.83", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-QJkRPQgyLl+ndC5cUg1CfFVv7eu14g/Dl5FRxLXPVjii5I5+ob21cw7XyUyM+gXD+OfR0lPayxOqsuwY2R6nFQ=="], + + "@aws-sdk/client-ec2": ["@aws-sdk/client-ec2@3.1130.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-node": "^3.972.83", "@aws-sdk/middleware-sdk-ec2": "^3.972.59", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-ZiI0xjQYwDdMLuIuDXKg3hul2dJIqLUedspLxcsJ+Bq96ZHSVuh5I7kuB4bdXVhFkpKjYWzG4bA9/ngfQQJfaA=="], + + "@aws-sdk/client-elastic-load-balancing-v2": ["@aws-sdk/client-elastic-load-balancing-v2@3.1130.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-node": "^3.972.83", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-EsgQo5F39TXP4G+amd0FIr6bIvXJ1+lumenu567W5FmX7UBOHm9wQzMLiRk55w/7rBnDpgqwjxdT5vkpyHiWYw=="], + + "@aws-sdk/client-pi": ["@aws-sdk/client-pi@3.1130.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-node": "^3.972.83", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-BoyL8QA0N08fpxXARGFkavMX8mGIEu8q5z/XSb/ElOVdUSIIB2lXUv8Z3H+2tVtH70soDGbfRC4bk7bvLADJkw=="], + + "@aws-sdk/client-rds": ["@aws-sdk/client-rds@3.1130.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-node": "^3.972.83", "@aws-sdk/middleware-sdk-rds": "^3.972.59", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Wxt0Zwyb67ldtqlSDB1Hwgnk5SKfFb5t/J+BMvc9X4ZdQcrTYMRCt9ItotXl3ENEszNyAuAWypKPbprhKVZCIA=="], + + "@aws-sdk/client-resource-groups-tagging-api": ["@aws-sdk/client-resource-groups-tagging-api@3.1130.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-node": "^3.972.83", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NEp2M9vxcsmC8kr4EF4E51mk9bQE1FqteV+Q88llnJ1shTyOG9BC1YqpDaRQjV9RUl14VOcCrCp16HPx9prfaQ=="], + + "@aws-sdk/client-sts": ["@aws-sdk/client-sts@3.1130.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-node": "^3.972.83", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-aavqBKshO8uoCqy5dWw5BDqmswOqwlgRnlJw/tdfo8iyQqb0d3C9gUMJs8k/vcjG/3A77hsaatVSM2y/Ch5BUg=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.978.0", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.73", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.16", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-env": "^3.972.71", "@aws-sdk/credential-provider-http": "^3.972.73", "@aws-sdk/credential-provider-login": "^3.972.78", "@aws-sdk/credential-provider-process": "^3.972.71", "@aws-sdk/credential-provider-sso": "^3.973.15", "@aws-sdk/credential-provider-web-identity": "^3.972.77", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.78", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.83", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.71", "@aws-sdk/credential-provider-http": "^3.972.73", "@aws-sdk/credential-provider-ini": "^3.973.16", "@aws-sdk/credential-provider-process": "^3.972.71", "@aws-sdk/credential-provider-sso": "^3.973.15", "@aws-sdk/credential-provider-web-identity": "^3.972.77", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.15", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/token-providers": "3.1129.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ=="], + + "@aws-sdk/middleware-sdk-ec2": ["@aws-sdk/middleware-sdk-ec2@3.972.59", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-BJLwpH+l4ZpzEhMzigwR35Ox24a41oOm70b92pwbF2Pq5n9IPBZhqiN5WyF7XH9zoJwc9wsDmC1wC7rlz8idBg=="], + + "@aws-sdk/middleware-sdk-rds": ["@aws-sdk/middleware-sdk-rds@3.972.59", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-doexDWIfVvd8wB9a4GnDomxqqtq20N/198jd8dSG6t9jJMEAN0p5lJE5RPofWVJhzCCdmbUz1yzHWTBDTEOfsA=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.45", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.46", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1129.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], + + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="], + + "@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@isaacs/string-locale-compare": ["@isaacs/string-locale-compare@1.1.0", "", {}, "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ=="], + + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + + "@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="], + + "@npmcli/arborist": ["@npmcli/arborist@8.0.5", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^4.0.0", "@npmcli/installed-package-contents": "^3.0.0", "@npmcli/map-workspaces": "^4.0.1", "@npmcli/metavuln-calculator": "^8.0.0", "@npmcli/name-from-folder": "^3.0.0", "@npmcli/node-gyp": "^4.0.0", "@npmcli/package-json": "^6.0.1", "@npmcli/query": "^4.0.0", "@npmcli/redact": "^3.0.0", "@npmcli/run-script": "^9.0.1", "bin-links": "^5.0.0", "cacache": "^19.0.1", "common-ancestor-path": "^1.0.1", "hosted-git-info": "^8.0.0", "json-parse-even-better-errors": "^4.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^10.2.2", "minimatch": "^9.0.4", "nopt": "^8.0.0", "npm-install-checks": "^7.1.0", "npm-package-arg": "^12.0.0", "npm-pick-manifest": "^10.0.0", "npm-registry-fetch": "^18.0.1", "pacote": "^19.0.0", "parse-conflict-json": "^4.0.0", "proc-log": "^5.0.0", "proggy": "^3.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "promise-retry": "^2.0.1", "read-package-json-fast": "^4.0.0", "semver": "^7.3.7", "ssri": "^12.0.0", "treeverse": "^3.0.0", "walk-up-path": "^3.0.1" }, "bin": { "arborist": "bin/index.js" } }, "sha512-dFby80JSC3e8825FRGRuhOMWFeKFHokz8j8osLOujmjOSKm6smGu/b1/gbgW0lP8d84iMZO+RxpqBbC5KgL6DQ=="], + + "@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="], + + "@npmcli/git": ["@npmcli/git@6.0.3", "", { "dependencies": { "@npmcli/promise-spawn": "^8.0.0", "ini": "^5.0.0", "lru-cache": "^10.0.1", "npm-pick-manifest": "^10.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "semver": "^7.3.5", "which": "^5.0.0" } }, "sha512-GUYESQlxZRAdhs3UhbB6pVRNUELQOHXwK9ruDkwmCv2aZ5y0SApQzUJCg02p3A7Ue2J5hxvlk1YI53c00NmRyQ=="], + + "@npmcli/installed-package-contents": ["@npmcli/installed-package-contents@3.0.0", "", { "dependencies": { "npm-bundled": "^4.0.0", "npm-normalize-package-bin": "^4.0.0" }, "bin": { "installed-package-contents": "bin/index.js" } }, "sha512-fkxoPuFGvxyrH+OQzyTkX2LUEamrF4jZSmxjAtPPHHGO0dqsQ8tTKjnIS8SAnPHdk2I03BDtSMR5K/4loKg79Q=="], + + "@npmcli/map-workspaces": ["@npmcli/map-workspaces@4.0.2", "", { "dependencies": { "@npmcli/name-from-folder": "^3.0.0", "@npmcli/package-json": "^6.0.0", "glob": "^10.2.2", "minimatch": "^9.0.0" } }, "sha512-mnuMuibEbkaBTYj9HQ3dMe6L0ylYW+s/gfz7tBDMFY/la0w9Kf44P9aLn4/+/t3aTR3YUHKoT6XQL9rlicIe3Q=="], + + "@npmcli/metavuln-calculator": ["@npmcli/metavuln-calculator@8.0.1", "", { "dependencies": { "cacache": "^19.0.0", "json-parse-even-better-errors": "^4.0.0", "pacote": "^20.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5" } }, "sha512-WXlJx9cz3CfHSt9W9Opi1PTFc4WZLFomm5O8wekxQZmkyljrBRwATwDxfC9iOXJwYVmfiW1C1dUe0W2aN0UrSg=="], + + "@npmcli/name-from-folder": ["@npmcli/name-from-folder@3.0.0", "", {}, "sha512-61cDL8LUc9y80fXn+lir+iVt8IS0xHqEKwPu/5jCjxQTVoSCmkXvw4vbMrzAMtmghz3/AkiBjhHkDKUH+kf7kA=="], + + "@npmcli/node-gyp": ["@npmcli/node-gyp@4.0.0", "", {}, "sha512-+t5DZ6mO/QFh78PByMq1fGSAub/agLJZDRfJRMeOSNCt8s9YVlTjmGpIPwPhvXTGUIJk+WszlT0rQa1W33yzNA=="], + + "@npmcli/package-json": ["@npmcli/package-json@6.2.0", "", { "dependencies": { "@npmcli/git": "^6.0.0", "glob": "^10.2.2", "hosted-git-info": "^8.0.0", "json-parse-even-better-errors": "^4.0.0", "proc-log": "^5.0.0", "semver": "^7.5.3", "validate-npm-package-license": "^3.0.4" } }, "sha512-rCNLSB/JzNvot0SEyXqWZ7tX2B5dD2a1br2Dp0vSYVo5jh8Z0EZ7lS9TsZ1UtziddB1UfNUaMCc538/HztnJGA=="], + + "@npmcli/promise-spawn": ["@npmcli/promise-spawn@8.0.3", "", { "dependencies": { "which": "^5.0.0" } }, "sha512-Yb00SWaL4F8w+K8YGhQ55+xE4RUNdMHV43WZGsiTM92gS+lC0mGsn7I4hLug7pbao035S6bj3Y3w0cUNGLfmkg=="], + + "@npmcli/query": ["@npmcli/query@4.0.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" } }, "sha512-4OIPFb4weUUwkDXJf4Hh1inAn8neBGq3xsH4ZsAaN6FK3ldrFkH7jSpCc7N9xesi0Sp+EBXJ9eGMDrEww2Ztqw=="], + + "@npmcli/redact": ["@npmcli/redact@3.2.2", "", {}, "sha512-7VmYAmk4csGv08QzrDKScdzn11jHPFGyqJW39FyPgPuAp3zIaUmuCo1yxw9aGs+NEJuTGQ9Gwqpt93vtJubucg=="], + + "@npmcli/run-script": ["@npmcli/run-script@9.1.0", "", { "dependencies": { "@npmcli/node-gyp": "^4.0.0", "@npmcli/package-json": "^6.0.0", "@npmcli/promise-spawn": "^8.0.0", "node-gyp": "^11.0.0", "proc-log": "^5.0.0", "which": "^5.0.0" } }, "sha512-aoNSbxtkePXUlbZB+anS1LqsJdctG5n3UVhfU47+CDdwMi6uNTBMF9gPcQRnqghQd2FGzcwwIFBruFMxjhBewg=="], + + "@nullplatform/plugin": ["@nullplatform/plugin@0.0.4", "", { "dependencies": { "@grpc/grpc-js": "^1.13.0", "@grpc/proto-loader": "^0.7.0", "config": "^4.4.2", "yaml": "^2.7.0" }, "peerDependencies": { "@nullplatform/workflow-engine": ">=0.0.1 <1.0.0", "@nullplatform/workflow-engine-in-memory": ">=0.0.1 <1.0.0", "@nullplatform/workflow-types": ">=0.0.1 <1.0.0" }, "optionalPeers": ["@nullplatform/workflow-engine", "@nullplatform/workflow-engine-in-memory", "@nullplatform/workflow-types"] }, "sha512-1KtecPMLRYrC2bKX9FdaO+z8JTfGiNo0wsiHiwN7HVvYJ1VN0hdcZHge4pyqSVrzmV9Jxvi7dQVaAJYtWf4T2g=="], + + "@nullplatform/workflow": ["@nullplatform/workflow@0.0.5", "", { "dependencies": { "@nullplatform/workflow-engine": "*", "@nullplatform/workflow-node-specs": "*", "@nullplatform/workflow-types": "*" }, "peerDependencies": { "@babel/core": "^7.0.0" }, "optionalPeers": ["@babel/core"] }, "sha512-QlvbQkbidFSWd7s75VeoNM0IT49Is5VuZ+eqL9U7RH7fSXyj3y5xqUvHb+Q1bmOTWlOUdK40V0Jsvr6ZQQTpHg=="], + + "@nullplatform/workflow-engine": ["@nullplatform/workflow-engine@0.0.6", "", { "dependencies": { "@npmcli/arborist": "^8.0.0", "npm-package-arg": "^12.0.0" }, "peerDependencies": { "@nullplatform/workflow-types": ">=0.0.5" } }, "sha512-SNcKR2FgysdIgHZ/EBbqIdj54ewAUA9mYJLVj1pkZpx2j9Xd8OgwS2jrk87Otiy6kPsQekyvpINoopRqkB9xyA=="], + + "@nullplatform/workflow-engine-in-memory": ["@nullplatform/workflow-engine-in-memory@0.0.5", "", { "peerDependencies": { "@nullplatform/workflow-engine": ">=0.0.6" } }, "sha512-WXJRfr9ekRNreKa1sKs5PN70eTm5tmcy3XjhgWt9QcrA+182jbBBFnnnsjuzIT/VgBuzvYzCRATVnV/S0ietOA=="], + + "@nullplatform/workflow-node-specs": ["@nullplatform/workflow-node-specs@0.0.5", "", { "peerDependencies": { "@nullplatform/workflow-types": ">=0.0.5" } }, "sha512-FJWTPpMCyogHqEumIOAdeldx+t9JMT6PmD/1NBF8RCW0rRtXGvvYClgSGO2plnkjo6MN9jgLyycPvGaXrJQe7g=="], + + "@nullplatform/workflow-types": ["@nullplatform/workflow-types@0.0.5", "", {}, "sha512-gzu3fGCXgZuA7LPIStEAMvIWbePlwCKs0AypPyaR4Mjv0QK4eODPbOyg5Cdj00jcbx+vvUQXypng0lxXN69udw=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + + "@sigstore/bundle": ["@sigstore/bundle@3.1.0", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.4.0" } }, "sha512-Mm1E3/CmDDCz3nDhFKTuYdB47EdRFRQMOE/EAbiG1MJW77/w1b3P7Qx7JSrVJs8PfwOLOVcKQCHErIwCTyPbag=="], + + "@sigstore/core": ["@sigstore/core@2.0.0", "", {}, "sha512-nYxaSb/MtlSI+JWcwTHQxyNmWeWrUXJJ/G4liLrGG7+tS4vAz6LF3xRXqLH6wPIVUoZQel2Fs4ddLx4NCpiIYg=="], + + "@sigstore/protobuf-specs": ["@sigstore/protobuf-specs@0.4.3", "", {}, "sha512-fk2zjD9117RL9BjqEwF7fwv7Q/P9yGsMV4MUJZ/DocaQJ6+3pKr+syBq1owU5Q5qGw5CUbXzm+4yJ2JVRDQeSA=="], + + "@sigstore/sign": ["@sigstore/sign@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^3.1.0", "@sigstore/core": "^2.0.0", "@sigstore/protobuf-specs": "^0.4.0", "make-fetch-happen": "^14.0.2", "proc-log": "^5.0.0", "promise-retry": "^2.0.1" } }, "sha512-knzjmaOHOov1Ur7N/z4B1oPqZ0QX5geUfhrVaqVlu+hl0EAoL4o+l0MSULINcD5GCWe3Z0+YJO8ues6vFlW0Yw=="], + + "@sigstore/tuf": ["@sigstore/tuf@3.1.1", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.4.1", "tuf-js": "^3.0.1" } }, "sha512-eFFvlcBIoGwVkkwmTi/vEQFSva3xs5Ot3WmBcjgjVdiaoelBLQaQ/ZBfhlG0MnG0cmTYScPpk7eDdGDWUcFUmg=="], + + "@sigstore/verify": ["@sigstore/verify@2.1.1", "", { "dependencies": { "@sigstore/bundle": "^3.1.0", "@sigstore/core": "^2.0.0", "@sigstore/protobuf-specs": "^0.4.1" } }, "sha512-hVJD77oT67aowHxwT4+M6PGOp+E2LtLdTK3+FC0lBO9T7sYwItDMXZ7Z07IDCvR1M717a4axbIWckrW67KMP/w=="], + + "@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.8.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA=="], + + "@smithy/middleware-compression": ["@smithy/middleware-compression@4.6.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "fflate": "0.8.3", "tslib": "^2.6.2" } }, "sha512-Q9d+luiRjyHT6kCL/9NyGpdZJgodh4vtvfHC6H8SqoVKrV2k9RoyI9/IloVfdCYks3/2DIi7BsYYLcda5KZS0A=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.7.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow=="], + + "@smithy/types": ["@smithy/types@4.18.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A=="], + + "@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="], + + "@tufjs/models": ["@tufjs/models@3.0.1", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^9.0.5" } }, "sha512-UUYHISyhCU3ZgN8yaear3cGATHb3SMuKHsQ/nVbHXcmnBf+LzQ/cQfhNG+rfaSHgqGKNEm2cOCLVLELStUQ1JA=="], + + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/node": ["@types/node@22.20.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw=="], + + "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "bin-links": ["bin-links@5.0.0", "", { "dependencies": { "cmd-shim": "^7.0.0", "npm-normalize-package-bin": "^4.0.0", "proc-log": "^5.0.0", "read-cmd-shim": "^5.0.0", "write-file-atomic": "^6.0.0" } }, "sha512-sdleLVfCjBtgO5cNjA2HVRvWBJAHs4zwenaCPMNJAJU0yNxpzj80IpjOIimkpkr+mhlA+how5poQtt53PygbHA=="], + + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + + "brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "cmd-shim": ["cmd-shim@7.0.0", "", {}, "sha512-rtpaCbr164TPPh+zFdkWpCyZuKkjpAzODfaZCf/SVJZzJN+4bHQb/LP3Jzq5/+84um3XXY8r548XiWKSborwVw=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], + + "config": ["config@4.4.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-5HJD7TX70gOG2dAd72eDjDeSTYn6D8nOiKgysQQS8mRUo9X1wDtlzb1JlmIWkP+sM9zKRVdYJxmj+qmCsYu6DQ=="], + + "content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], + + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "hosted-git-info": ["hosted-git-info@8.1.0", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw=="], + + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "ignore-walk": ["ignore-walk@7.0.0", "", { "dependencies": { "minimatch": "^9.0.0" } }, "sha512-T4gbf83A4NH95zvhVYZc+qWocBBGlpzUXLPGurJggw/WIOwicfXJChLDP/iBZnN5WqROSu5Bm3hhle4z8a8YGQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "ini": ["ini@5.0.0", "", {}, "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw=="], + + "ip-address": ["ip-address@10.7.0", "", {}, "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@4.0.0", "", {}, "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA=="], + + "json-stringify-nice": ["json-stringify-nice@1.1.4", "", {}, "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="], + + "just-diff": ["just-diff@6.0.2", "", {}, "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA=="], + + "just-diff-apply": ["just-diff-apply@5.5.0", "", {}, "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw=="], + + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "ssri": "^12.0.0" } }, "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ=="], + + "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], + + "minipass-fetch": ["minipass-fetch@4.0.1", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^3.0.1" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ=="], + + "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], + + "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + + "node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="], + + "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], + + "npm-bundled": ["npm-bundled@4.0.0", "", { "dependencies": { "npm-normalize-package-bin": "^4.0.0" } }, "sha512-IxaQZDMsqfQ2Lz37VvyyEtKLe8FsRZuysmedy/N06TU1RyVppYKXrO4xIhR0F+7ubIBox6Q7nir6fQI3ej39iA=="], + + "npm-install-checks": ["npm-install-checks@7.1.2", "", { "dependencies": { "semver": "^7.1.1" } }, "sha512-z9HJBCYw9Zr8BqXcllKIs5nI+QggAImbBdHphOzVYrz2CB4iQ6FzWyKmlqDZua+51nAu7FcemlbTc9VgQN5XDQ=="], + + "npm-normalize-package-bin": ["npm-normalize-package-bin@4.0.0", "", {}, "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w=="], + + "npm-package-arg": ["npm-package-arg@12.0.2", "", { "dependencies": { "hosted-git-info": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^6.0.0" } }, "sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA=="], + + "npm-packlist": ["npm-packlist@9.0.0", "", { "dependencies": { "ignore-walk": "^7.0.0" } }, "sha512-8qSayfmHJQTx3nJWYbbUmflpyarbLMBc6LCAjYsiGtXxDB68HaZpb8re6zeaLGxZzDuMdhsg70jryJe+RrItVQ=="], + + "npm-pick-manifest": ["npm-pick-manifest@10.0.0", "", { "dependencies": { "npm-install-checks": "^7.1.0", "npm-normalize-package-bin": "^4.0.0", "npm-package-arg": "^12.0.0", "semver": "^7.3.5" } }, "sha512-r4fFa4FqYY8xaM7fHecQ9Z2nE9hgNfJR+EmoKv0+chvzWkBcORX3r0FpTByP+CbOVJDladMXnPQGVN8PBLGuTQ=="], + + "npm-registry-fetch": ["npm-registry-fetch@18.0.2", "", { "dependencies": { "@npmcli/redact": "^3.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^14.0.0", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^12.0.0", "proc-log": "^5.0.0" } }, "sha512-LeVMZBBVy+oQb5R6FDV9OlJCcWDU+al10oKpe+nsvcHnG24Z3uM3SvJYKfGJlfGjVU8v9liejCrUR/M5HO5NEQ=="], + + "p-map": ["p-map@7.0.7", "", {}, "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "pacote": ["pacote@19.0.2", "", { "dependencies": { "@npmcli/git": "^6.0.0", "@npmcli/installed-package-contents": "^3.0.0", "@npmcli/package-json": "^6.0.0", "@npmcli/promise-spawn": "^8.0.0", "@npmcli/run-script": "^9.0.0", "cacache": "^19.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^12.0.0", "npm-packlist": "^9.0.0", "npm-pick-manifest": "^10.0.0", "npm-registry-fetch": "^18.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "sigstore": "^3.0.0", "ssri": "^12.0.0", "tar": "^7.5.10" }, "bin": { "pacote": "bin/index.js" } }, "sha512-iNInrWMS+PzYbaef5EW/mU8OiCPxGuTmYn6ht5ImeXd5TZIVY4+dDmIrbpB6v0MKG/KIMMvj2UD7eKU9GbTGHA=="], + + "parse-conflict-json": ["parse-conflict-json@4.0.0", "", { "dependencies": { "json-parse-even-better-errors": "^4.0.0", "just-diff": "^6.0.0", "just-diff-apply": "^5.2.0" } }, "sha512-37CN2VtcuvKgHUs8+0b1uJeEsbGn61GRHz469C94P5xiOoqpDYJYwjg4RY9Vmz39WyZAVkR5++nbJwLMIgOCnQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "postcss-selector-parser": ["postcss-selector-parser@7.1.6", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="], + + "proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], + + "proggy": ["proggy@3.0.0", "", {}, "sha512-QE8RApCM3IaRRxVzxrjbgNMpQEX6Wu0p0KBeoSiSEw5/bsGwZHsshF4LCxH2jp/r6BU+bqA3LrMDEYNfJnpD8Q=="], + + "promise-all-reject-late": ["promise-all-reject-late@1.0.1", "", {}, "sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw=="], + + "promise-call-limit": ["promise-call-limit@3.0.2", "", {}, "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw=="], + + "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + + "protobufjs": ["protobufjs@7.6.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg=="], + + "read-cmd-shim": ["read-cmd-shim@5.0.0", "", {}, "sha512-SEbJV7tohp3DAAILbEMPXavBjAnMN0tVnh4+9G8ihV4Pq3HYF9h8QNez9zkJ1ILkv9G2BjdzwctznGZXgu/HGw=="], + + "read-package-json-fast": ["read-package-json-fast@4.0.0", "", { "dependencies": { "json-parse-even-better-errors": "^4.0.0", "npm-normalize-package-bin": "^4.0.0" } }, "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sigstore": ["sigstore@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^3.1.0", "@sigstore/core": "^2.0.0", "@sigstore/protobuf-specs": "^0.4.0", "@sigstore/sign": "^3.1.0", "@sigstore/tuf": "^3.1.0", "@sigstore/verify": "^2.1.0" } }, "sha512-ZpzWAFHIFqyFE56dXqgX/DkDRZdz+rRcjoIk/RQU4IX0wiCv1l8S7ZrXDHcCc+uaf+6o7w3h2l3g6GYG5TKN9Q=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "spdx-correct": ["spdx-correct@3.2.0", "", { "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" } }, "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA=="], + + "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], + + "spdx-expression-parse": ["spdx-expression-parse@3.0.1", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q=="], + + "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="], + + "ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tuf-js": ["tuf-js@3.1.0", "", { "dependencies": { "@tufjs/models": "3.0.1", "debug": "^4.4.1", "make-fetch-happen": "^14.0.3" } }, "sha512-3T3T04WzowbwV2FDiGXBbr81t64g1MUGGJRgT4x5o97N+8ArdhVCAF9IxFrxuSJmM3E5Asn7nKHkao0ibcZXAg=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="], + + "unique-slug": ["unique-slug@5.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], + + "validate-npm-package-name": ["validate-npm-package-name@6.0.2", "", {}, "sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ=="], + + "walk-up-path": ["walk-up-path@3.0.1", "", {}, "sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA=="], + + "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "write-file-atomic": ["write-file-atomic@6.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="], + + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "@npmcli/metavuln-calculator/pacote": ["pacote@20.0.1", "", { "dependencies": { "@npmcli/git": "^6.0.0", "@npmcli/installed-package-contents": "^3.0.0", "@npmcli/package-json": "^6.0.0", "@npmcli/promise-spawn": "^8.0.0", "@npmcli/run-script": "^9.0.0", "cacache": "^19.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^12.0.0", "npm-packlist": "^9.0.0", "npm-pick-manifest": "^10.0.0", "npm-registry-fetch": "^18.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "sigstore": "^3.0.0", "ssri": "^12.0.0", "tar": "^7.5.10" }, "bin": { "pacote": "bin/index.js" } }, "sha512-jTMLD/QK7JMUKg3g7K3M/DEqIbGm7sxclj12eQYIkL3viutSiefTs26IrqIqgGlFsviF/9dlDUZxnpGvkRXtjw=="], + + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-sized/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + } +} diff --git a/finops/packages/cloud-query/config/custom-environment-variables.json b/finops/packages/cloud-query/config/custom-environment-variables.json new file mode 100644 index 0000000..54a71d0 --- /dev/null +++ b/finops/packages/cloud-query/config/custom-environment-variables.json @@ -0,0 +1,11 @@ +{ + "api": { + "url": "NP_API_URL", + "key": "NP_API_KEY", + "executionsUrl": "NP_EXECUTIONS_API_URL" + }, + "agent": { + "mode": "NP_AGENT_PLUGIN", + "actionContext": "NP_ACTION_CONTEXT" + } +} diff --git a/finops/packages/cloud-query/config/default.json b/finops/packages/cloud-query/config/default.json new file mode 100644 index 0000000..5e83908 --- /dev/null +++ b/finops/packages/cloud-query/config/default.json @@ -0,0 +1,5 @@ +{ + "api": { + "url": "https://api.nullplatform.com" + } +} diff --git a/finops/packages/cloud-query/mise.toml b/finops/packages/cloud-query/mise.toml new file mode 100644 index 0000000..85b0ea4 --- /dev/null +++ b/finops/packages/cloud-query/mise.toml @@ -0,0 +1,105 @@ +[tools] +bun = "latest" +oras = "latest" + +# Install runs on demand and is the root of the task dependency graph. +# Every other task declares `depends = ["install"]` so a fresh clone +# just works — no need to remember `mise install` before `mise run dev`. +[tasks.install] +description = "Install dependencies" +run = "bun install" +sources = ["package.json", "bun.lock"] + +[tasks.test] +description = "Run tests" +depends = ["install"] +run = "bun run test" + +[tasks.describe] +description = "Print plugin manifest" +depends = ["install"] +run = "bun run describe" + +[tasks.build] +description = "Compile the plugin to a standalone executable" +depends = ["install"] +run = "bun run build" +sources = ["src/**/*.ts", "package.json", "bun.lock"] +outputs = ["dist/cloud-query"] + +[tasks."build:all"] +description = "Cross-compile all release targets" +depends = ["install"] +run = """ +bun build --compile --target=bun-linux-x64 ./src/index.ts --outfile dist/cloud-query-linux-x64 +bun build --compile --target=bun-linux-arm64 ./src/index.ts --outfile dist/cloud-query-linux-arm64 +bun build --compile --target=bun-darwin-arm64 ./src/index.ts --outfile dist/cloud-query-darwin-arm64 +""" + +# `np plugin dev` → this task. Everyday dev workflow with hot reload. +# Uses `bun --hot` (not `--watch`) so module changes reload inside the +# same process — keeps the plugin SDK's idempotent startGrpcServer() in effect. +[tasks.dev] +description = "Run the plugin in dev mode" +depends = ["install"] +run = "bun run dev" + + +# Build the LEAN gRPC worker image (the image the agent spawns and dials over +# gRPC/mTLS). Just the compiled package binary on a minimal base — the agent +# injects the port + TLS + API key at runtime. +[tasks."build:image"] +description = "Build the lean gRPC worker image" +run = "docker build -t cloud-query-worker:dev ." + +# `np package run` → run this package locally the way production does: a +# controlplane-agent with the worker orchestrator (docker backend) that SPAWNS +# the lean worker container and talks to it over gRPC/mTLS. Needs NP_API_KEY. +# --network host so the agent can reach the worker it publishes on the host +# (Linux; on Docker Desktop enable host networking). Override the agent image +# with NP_AGENT_IMAGE until the orchestrator agent is the default tag. +[tasks.run] +description = "Run locally: an agent (docker backend, :latest) that spawns the lean worker" +depends = ["build:image"] +run = """ +set -eu +: "${NP_API_KEY:?set NP_API_KEY}" +: "${NP_WORKER_AWS_ACCESS_KEY_ID:?set NP_WORKER_AWS_ACCESS_KEY_ID (dev only; in a cluster the worker uses the pod IAM role)}" +: "${NP_WORKER_AWS_SECRET_ACCESS_KEY:?set NP_WORKER_AWS_SECRET_ACCESS_KEY}" +region="${NP_WORKER_AWS_REGION:-us-east-1}" +# AWS creds reach the worker through a standard pod patch (NP_WORKER_PATCHES); +# the template's alpha-packages-2.2.0 image ignores NP_WORKER_ENV on docker. +# NP_AGENT_EXTRA_TAGS (optional, e.g. run:1757520000): extra agent tags. A +# restarted agent registers a NEW id and the old one lingers "active" until its +# TTL, so a per-start tag keeps workflow selectors deterministic. +# NP_ALLOWED_REGISTRIES (optional, e.g. public.ecr.aws/nullplatform/*): run WITHOUT +# the local image pin — the agent pulls the image the workflow references +# (tool-cloud-query `image` input, the released digest). With it unset the +# agent is pinned to the locally built cloud-query-worker:dev. +# NP_CALLBACK_ALLOWED_HOSTS: the worker may only POST results to these hosts +# (SSRF guard). Locally the engine runs on the laptop → host.docker.internal. +cb_hosts="${NP_CALLBACK_ALLOWED_HOSTS:-host.docker.internal,api.nullplatform.com}" +patches=$(printf '[{"target":{"package":"cloud-query"},"merge":{"spec":{"containers":[{"name":"worker","env":[{"name":"AWS_ACCESS_KEY_ID","value":"%s"},{"name":"AWS_SECRET_ACCESS_KEY","value":"%s"},{"name":"AWS_REGION","value":"%s"},{"name":"NP_CALLBACK_ALLOWED_HOSTS","value":"%s"}]}]}}}]' "$NP_WORKER_AWS_ACCESS_KEY_ID" "$NP_WORKER_AWS_SECRET_ACCESS_KEY" "$region" "$cb_hosts") +# Pin the locally built image, or let the agent pull what the workflow references. +set -f # no globbing: the registry pattern carries a '*' +if [ -n "${NP_ALLOWED_REGISTRIES:-}" ]; then worker_env="-e NP_ALLOWED_REGISTRIES=$NP_ALLOWED_REGISTRIES"; else worker_env="-e NP_WORKER_IMAGE=cloud-query-worker:dev"; fi +docker rm -f np-cloud-query-agent >/dev/null 2>&1 || true +# The agent reuses a warm worker; remove it so a rebuilt image is picked up. +for w in $(docker ps -a -q --filter "name=^np-worker-.*cloud-query$"); do docker rm -f "$w" >/dev/null 2>&1 || true; done +docker run -d --name np-cloud-query-agent --network host \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e NP_API_KEY -e NP_LOG_LEVEL="${NP_LOG_LEVEL:-INFO}" \ + -e NP_WORKER_BACKEND=docker \ + $worker_env \ + -e NP_WORKER_PATCHES="$patches" \ + "${NP_AGENT_IMAGE:-public.ecr.aws/nullplatform/controlplane-agent:latest}" \ + -runtime=host -tags=package:cloud-query,local:"${NP_LOCAL_USER:-$USER}",env:local"${NP_AGENT_EXTRA_TAGS:+,$NP_AGENT_EXTRA_TAGS}" >/dev/null +echo "agent started; follow with: docker logs -f np-cloud-query-agent" >&2 +""" + +# `np package publish` runs this (when --image is omitted): build the lean worker +# image, push it to $NP_PUSH_REGISTRY (any registry you're `docker login`ed to), +# and print registry/repo@sha256: on stdout for the CLI to register. +[tasks."publish:image"] +description = "Build + push the lean worker image; prints its digest ref" +run = "bash scripts/publish-image.sh" diff --git a/finops/packages/cloud-query/package.json b/finops/packages/cloud-query/package.json new file mode 100644 index 0000000..ce72570 --- /dev/null +++ b/finops/packages/cloud-query/package.json @@ -0,0 +1,34 @@ +{ + "name": "@nullplatform/plugin-cloud-query", + "version": "0.0.4", + "type": "module", + "main": "src/index.ts", + "scripts": { + "dev": "bun --hot src/index.ts", + "test": "bun test", + "build": "bun build --compile ./src/index.ts --outfile dist/cloud-query", + "describe": "bun src/index.ts --describe", + "publish:image": "bash scripts/publish-image.sh" + }, + "dependencies": { + "@aws-sdk/client-cloudwatch": "^3.1129.0", + "@aws-sdk/client-cloudwatch-logs": "^3.1129.0", + "@aws-sdk/client-cost-explorer": "^3.1129.0", + "@aws-sdk/client-ec2": "^3.1129.0", + "@aws-sdk/client-elastic-load-balancing-v2": "^3.1129.0", + "@aws-sdk/client-pi": "^3.1129.0", + "@aws-sdk/client-rds": "^3.1129.0", + "@aws-sdk/client-resource-groups-tagging-api": "^3.1129.0", + "@aws-sdk/client-sts": "^3.1129.0", + "@nullplatform/plugin": "0.0.4", + "@nullplatform/workflow": "^0.0.5", + "@nullplatform/workflow-engine-in-memory": "^0.0.5", + "config": "^4.4.2" + }, + "devDependencies": { + "@types/bun": "^1.2.0" + }, + "trustedDependencies": [ + "@grpc/grpc-js" + ] +} diff --git a/finops/packages/cloud-query/scripts/publish-image.sh b/finops/packages/cloud-query/scripts/publish-image.sh new file mode 100755 index 0000000..55980af --- /dev/null +++ b/finops/packages/cloud-query/scripts/publish-image.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Build + push the lean worker image for amd64 + arm64 from the multi-stage +# Dockerfile (it compiles the worker inside), and print registry/repo@sha256: +# on stdout for `np package publish` to register. Build logs go to stderr. +# +# Version: $NP_VERSION (the release tag, v-stripped). Registry: $NP_PUSH_REGISTRY +# (any registry you're `docker login`ed to). Needs docker buildx. +set -eu + +registry="${NP_PUSH_REGISTRY:-}" +if [ -z "$registry" ]; then + echo "publish-image: set NP_PUSH_REGISTRY=/ and 'docker login' to it first." >&2 + exit 1 +fi + +version="${NP_VERSION:-$(sed -nE 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' package.json | head -1)}" +version="${version#v}" +version="${version:-0.0.0}" + +tag="${registry}:${version}" +meta="$(mktemp)" + +docker buildx build --platform linux/amd64,linux/arm64 -t "$tag" --push --metadata-file "$meta" . >&2 + +digest=$(sed -nE 's/.*"containerimage.digest"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$meta" | head -1) +if [ -z "$digest" ]; then + echo "publish-image: could not read the image digest from buildx metadata" >&2 + exit 1 +fi + +echo "${registry}@${digest}" diff --git a/finops/packages/cloud-query/scripts/release.json b/finops/packages/cloud-query/scripts/release.json new file mode 100644 index 0000000..9b7416b --- /dev/null +++ b/finops/packages/cloud-query/scripts/release.json @@ -0,0 +1,8 @@ +{ + "package": "cloud-query", + "version": "0.0.4", + "tag": "public.ecr.aws/nullplatform/agent-plugins/workflows/aws-cost-explorer:0.0.4", + "image": "public.ecr.aws/nullplatform/agent-plugins/workflows/aws-cost-explorer@sha256:24fcd9b320880c549c910a43c32c91c8c7024d1e57aedfbc2c3ff3bd85447368", + "platforms": ["linux/amd64", "linux/arm64"], + "released_at": "2026-09-11T11:28:06Z" +} diff --git a/finops/packages/cloud-query/scripts/release.sh b/finops/packages/cloud-query/scripts/release.sh new file mode 100755 index 0000000..7cab162 --- /dev/null +++ b/finops/packages/cloud-query/scripts/release.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Release the cloud-query worker image to nullplatform's public ECR: +# public.ecr.aws/nullplatform/agent-plugins/workflows/aws-cost-explorer +# +# What it does +# 1. docker login to ECR Public with AWS credentials that can push to that repo +# (AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY[/AWS_SESSION_TOKEN]). +# Most people do NOT have these — the nullplatform registry owner runs this. +# 2. buildx a multi-arch image (linux/amd64 + linux/arm64) from the Dockerfile +# (compiles the worker inside the build), tag it and push. +# 3. Print the immutable reference registry/repo@sha256: and write +# release.json next to this script for the platform registration step. +# +# Usage +# scripts/release.sh # version from package.json (e.g. 0.0.1) +# scripts/release.sh 0.0.2 # explicit version tag +# NP_PUSH_REGISTRY= scripts/release.sh # override the target +# scripts/release.sh --dry-run # build for the local arch only, no login, no push +# scripts/release.sh 0.0.2 --latest # also move the :latest tag to this version +# +# Then register the version on the platform (needs NP_API_KEY with publish grants): +# np-preview package publish --nrn "$NRN" --image "$(jq -r .image scripts/release.json)" +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +REGISTRY_HOST="public.ecr.aws" +DEFAULT_REPO="public.ecr.aws/nullplatform/agent-plugins/workflows/aws-cost-explorer" +NP_PUSH_REGISTRY="${NP_PUSH_REGISTRY:-$DEFAULT_REPO}" +DRY_RUN=0 +LATEST=0 +VERSION="" +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + --latest) LATEST=1 ;; + -h|--help) sed -n '2,22p' "$0"; exit 0 ;; + *) VERSION="$arg" ;; + esac +done +if [[ -z "$VERSION" ]]; then + VERSION=$(sed -nE 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' package.json | head -1) +fi +VERSION="${VERSION#v}" +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][A-Za-z0-9.]+)?$ ]] || { echo "release: version '$VERSION' is not semver" >&2; exit 1; } + +for tool in docker; do command -v "$tool" >/dev/null || { echo "release: $tool is required" >&2; exit 1; }; done +docker buildx version >/dev/null 2>&1 || { echo "release: docker buildx is required" >&2; exit 1; } + +echo "release: cloud-query ${VERSION} → ${NP_PUSH_REGISTRY}" >&2 + +if [[ "$DRY_RUN" == "1" ]]; then + # Local-arch build only (multi-platform images cannot be --load'ed). Validates the Dockerfile. + docker buildx build --load -t "cloud-query-release:${VERSION}" . >&2 + echo "release: dry run OK — image cloud-query-release:${VERSION} built for $(docker version --format '{{.Server.Os}}/{{.Server.Arch}}')" >&2 + exit 0 +fi + +# 1) login (ECR Public tokens are always minted in us-east-1, regardless of the repo's region) +command -v aws >/dev/null || { echo "release: aws CLI is required for the ECR Public login" >&2; exit 1; } +aws sts get-caller-identity --query Arn --output text >&2 || { echo "release: AWS credentials missing/invalid (AWS_PROFILE or AWS_ACCESS_KEY_ID/SECRET)" >&2; exit 1; } +aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin "$REGISTRY_HOST" >&2 + +# 2) multi-arch build + push (a dedicated builder guarantees multi-platform support) +BUILDER="cloud-query-release" +docker buildx inspect "$BUILDER" >/dev/null 2>&1 || docker buildx create --name "$BUILDER" --driver docker-container --bootstrap >/dev/null +META="$(mktemp)" +TAGS=(-t "${NP_PUSH_REGISTRY}:${VERSION}") +[[ "$LATEST" == "1" ]] && TAGS+=(-t "${NP_PUSH_REGISTRY}:latest") +docker buildx build --builder "$BUILDER" \ + --platform linux/amd64,linux/arm64 \ + "${TAGS[@]}" \ + --push --metadata-file "$META" . >&2 + +# 3) immutable reference +DIGEST=$(sed -nE 's/.*"containerimage.digest"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$META" | head -1) +[[ -n "$DIGEST" ]] || { echo "release: could not read the image digest from buildx metadata" >&2; exit 1; } +IMAGE="${NP_PUSH_REGISTRY}@${DIGEST}" +printf '{\n "package": "cloud-query",\n "version": "%s",\n "tag": "%s:%s",\n "image": "%s",\n "platforms": ["linux/amd64", "linux/arm64"],\n "released_at": "%s"\n}\n' \ + "$VERSION" "$NP_PUSH_REGISTRY" "$VERSION" "$IMAGE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > scripts/release.json +echo "release: pushed ${NP_PUSH_REGISTRY}:${VERSION}$([[ "$LATEST" == "1" ]] && echo ' (+ :latest)')" >&2 +echo "$IMAGE" diff --git a/finops/packages/cloud-query/src/aws-factory.ts b/finops/packages/cloud-query/src/aws-factory.ts new file mode 100644 index 0000000..824f6b4 --- /dev/null +++ b/finops/packages/cloud-query/src/aws-factory.ts @@ -0,0 +1,83 @@ +/** + * AWS SDK v3 client factory. Services are a fixed allow-list (the worker is a + * compiled binary, so nothing can be required dynamically). Credentials come + * from the default provider chain — IRSA / pod identity in a cluster, + * NP_WORKER_ENV or an explicit AssumeRole locally. + */ +import * as ce from "@aws-sdk/client-cost-explorer"; +import * as cloudwatch from "@aws-sdk/client-cloudwatch"; +import * as ec2 from "@aws-sdk/client-ec2"; +import * as sts from "@aws-sdk/client-sts"; +import * as tagging from "@aws-sdk/client-resource-groups-tagging-api"; +import * as elbv2 from "@aws-sdk/client-elastic-load-balancing-v2"; +import * as rds from "@aws-sdk/client-rds"; +import * as pi from "@aws-sdk/client-pi"; +import * as logs from "@aws-sdk/client-cloudwatch-logs"; +import type { ClientFactory, SdkClient } from "./runner"; + +type Ctor = new (cfg: Record) => SdkClient; +type Module = Record; + +const SERVICES: Record = { + "cost-explorer": { module: ce as Module, client: ce.CostExplorerClient as unknown as Ctor, region: "us-east-1" }, + ce: { module: ce as Module, client: ce.CostExplorerClient as unknown as Ctor, region: "us-east-1" }, + cloudwatch: { module: cloudwatch as Module, client: cloudwatch.CloudWatchClient as unknown as Ctor }, + ec2: { module: ec2 as Module, client: ec2.EC2Client as unknown as Ctor }, + sts: { module: sts as Module, client: sts.STSClient as unknown as Ctor }, + tagging: { module: tagging as Module, client: tagging.ResourceGroupsTaggingAPIClient as unknown as Ctor }, + elbv2: { module: elbv2 as Module, client: elbv2.ElasticLoadBalancingV2Client as unknown as Ctor }, + rds: { module: rds as Module, client: rds.RDSClient as unknown as Ctor }, + // RDS Performance Insights: DB load per database / user / SQL — the per-database split of a shared cluster. + pi: { module: pi as Module, client: pi.PIClient as unknown as Ctor }, + // CloudWatch Logs: log groups (names carry the app) for the CloudWatch ingestion/storage split. + logs: { module: logs as Module, client: logs.CloudWatchLogsClient as unknown as Ctor }, +}; + +export const SUPPORTED_SERVICES = Object.keys(SERVICES); + +export interface AwsCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; +} + +export function createAwsFactory(credentials?: AwsCredentials): ClientFactory { + const cache = new Map(); + return { + client(service, region) { + const def = SERVICES[service]; + if (!def) throw Object.assign(new Error(`unsupported service "${service}" (supported: ${SUPPORTED_SERVICES.join(", ")})`), { name: "UNSUPPORTED_SERVICE" }); + const key = `${service}:${region}`; + let c = cache.get(key); + if (!c) { + const cfg: Record = { region: def.region ?? region }; + if (credentials) cfg.credentials = credentials; + c = new def.client(cfg); + cache.set(key, c); + } + return c; + }, + command(service, operation, input) { + const def = SERVICES[service]; + if (!def) throw Object.assign(new Error(`unsupported service "${service}"`), { name: "UNSUPPORTED_SERVICE" }); + const Cmd = def.module[`${operation}Command`] as (new (i: Record) => unknown) | undefined; + if (typeof Cmd !== "function") throw Object.assign(new Error(`unknown operation ${service}.${operation}`), { name: "UNKNOWN_OPERATION" }); + return new Cmd(input); + }, + }; +} + +/** Exchange ambient credentials for a role session (optional per request). */ +export async function assumeRole(roleArn: string, sessionName = "np-cloud-query", externalId?: string, region = "us-east-1"): Promise { + const client = new sts.STSClient({ region }); + const out = await client.send(new sts.AssumeRoleCommand({ RoleArn: roleArn, RoleSessionName: sessionName, ...(externalId ? { ExternalId: externalId } : {}) })); + const c = out.Credentials; + if (!c?.AccessKeyId || !c.SecretAccessKey) throw new Error(`AssumeRole ${roleArn} returned no credentials`); + return { accessKeyId: c.AccessKeyId, secretAccessKey: c.SecretAccessKey, ...(c.SessionToken ? { sessionToken: c.SessionToken } : {}) }; +} + +export async function callerIdentity(credentials?: AwsCredentials, region = "us-east-1"): Promise<{ account?: string; arn?: string }> { + const client = new sts.STSClient({ region, ...(credentials ? { credentials } : {}) }); + const out = await client.send(new sts.GetCallerIdentityCommand({})); + return { ...(out.Account ? { account: out.Account } : {}), ...(out.Arn ? { arn: out.Arn } : {}) }; +} diff --git a/finops/packages/cloud-query/src/callback.ts b/finops/packages/cloud-query/src/callback.ts new file mode 100644 index 0000000..8157039 --- /dev/null +++ b/finops/packages/cloud-query/src/callback.ts @@ -0,0 +1,80 @@ +/** + * Push the runner response to the workflow engine's per-execution callback. + * The URL is an unguessable capability minted by the engine; the body carries + * the `token` the plugin issued so a forged POST cannot be mistaken for ours. + * + * SSRF guard: the callback URL comes from the caller's action context, and the + * worker runs INSIDE the customer's network with an IAM role. It may only POST + * to hosts the operator allow-listed (`NP_CALLBACK_ALLOWED_HOSTS`, comma + * separated, default `api.nullplatform.com`). Anything else — internal + * services, the instance metadata endpoint, arbitrary internet hosts — is + * refused before any request is made. + */ +export interface PostCallbackOptions { + fetchImpl?: typeof fetch; + attempts?: number; + timeoutMs?: number; + sleep?: (ms: number) => Promise; + /** Allowed callback hostnames (exact, case-insensitive). Default from env. */ + allowedHosts?: string[]; +} + +export type PostCallbackResult = { ok: true; status: number } | { ok: false; error: string }; + +export const DEFAULT_CALLBACK_ALLOWED_HOSTS = ["api.nullplatform.com"]; + +/** Resolve the allow-list: explicit option → NP_CALLBACK_ALLOWED_HOSTS env → default. */ +export function callbackAllowedHosts(explicit?: string[], env: Record = process.env): string[] { + if (explicit && explicit.length > 0) return explicit.map((h) => h.trim().toLowerCase()).filter(Boolean); + const fromEnv = (env.NP_CALLBACK_ALLOWED_HOSTS ?? "") + .split(",") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean); + return fromEnv.length > 0 ? fromEnv : DEFAULT_CALLBACK_ALLOWED_HOSTS; +} + +/** Returns an error string when the URL must not be called, else undefined. */ +export function checkCallbackUrl(url: string, allowedHosts: string[]): string | undefined { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return `callback url is not a valid URL: ${url}`; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return `callback url must be http(s), got ${parsed.protocol}`; + } + if (parsed.username || parsed.password) return "callback url must not carry credentials"; + const host = parsed.hostname.toLowerCase(); + if (!allowedHosts.includes(host)) { + return `callback host "${host}" is not allowed (NP_CALLBACK_ALLOWED_HOSTS: ${allowedHosts.join(", ")})`; + } + return undefined; +} + +export async function postCallback(url: string, body: unknown, opts: PostCallbackOptions = {}): Promise { + const denied = checkCallbackUrl(url, callbackAllowedHosts(opts.allowedHosts)); + if (denied) return { ok: false, error: denied }; + const fetchImpl = opts.fetchImpl ?? fetch; + const attempts = opts.attempts ?? 3; + const timeoutMs = opts.timeoutMs ?? 10_000; + const sleep = opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const payload = JSON.stringify(body); + let lastError = ""; + for (let i = 0; i < attempts; i++) { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + const res = await fetchImpl(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: payload, signal: ac.signal }); + if (res.ok) return { ok: true, status: res.status }; + lastError = `callback returned HTTP ${res.status}`; + if (res.status < 500) return { ok: false, error: lastError }; + } catch (err) { + lastError = `callback request failed: ${(err as Error).message}`; + } finally { + clearTimeout(timer); + } + if (i < attempts - 1) await sleep(1000 * (i + 1)); + } + return { ok: false, error: lastError }; +} diff --git a/finops/packages/cloud-query/src/index.ts b/finops/packages/cloud-query/src/index.ts new file mode 100644 index 0000000..d6b2762 --- /dev/null +++ b/finops/packages/cloud-query/src/index.ts @@ -0,0 +1,103 @@ +import pkg from "../package.json"; +import { createPlugin, registerManifest } from "@nullplatform/plugin"; +import { runRequest, validateRequest, type CloudQueryRequest } from "./runner"; +import { assumeRole, callerIdentity, createAwsFactory } from "./aws-factory"; +import { postCallback } from "./callback"; +import { buildReceipt } from "./receipt"; + +// The manifest declares identity + channel routing. This package is dispatched +// directly by workflows through `package-exec` (agent_command), so the channel +// sources are only a fallback for notification-driven use. +const manifest = { + name: "cloud-query", + version: pkg.version, + command_types: ["custom"], + agent: { + selector: { package: "cloud-query" }, + sources: ["service"], + }, +}; +registerManifest(manifest); + +// `np package publish` runs the binary with --describe to read the manifest. +if (process.argv.includes("--describe")) { + process.stdout.write(JSON.stringify(manifest)); + process.exit(0); +} + +/** + * The action context arrives as the gRPC payload. Two shapes are accepted: + * { cloud_query: { ...CloudQueryRequest } } — what workflows send via package-exec + * { ...CloudQueryRequest } — bare (tests / direct dispatch) + */ +function extractRequest(payload: unknown): unknown { + if (payload && typeof payload === "object" && "cloud_query" in (payload as Record)) { + return (payload as Record).cloud_query; + } + return payload; +} + +createPlugin({ + async execute(req) { + let payload: unknown; + try { + payload = JSON.parse(req.payload.toString("utf-8")); + } catch (err) { + return { success: false, errorCode: "BAD_PAYLOAD", error: `payload is not JSON: ${(err as Error).message}` }; + } + const request = extractRequest(payload); + const invalid = validateRequest(request); + if (invalid) return { success: false, errorCode: "INVALID_REQUEST", error: invalid }; + const cq = request as CloudQueryRequest; + + // Output contract: progress goes to STDERR, the final response JSON is the + // ONLY thing on STDOUT. The control plane exposes a command's stdOut/stdErr + // to callers but drops the gRPC `data` payload, so stdout IS the result + // channel for workflows (`JSON.parse(results.stdOut)`). + const log = (line: string) => { + console.error(line); + req.emit({ stderr: `${line}\n` }); + }; + + log(`[cloud-query] request: ${cq.calls.length} call(s), region ${cq.region ?? "default"}, assumeRole ${cq.assumeRole?.roleArn ?? "no"}`); + try { + const creds = cq.assumeRole ? await assumeRole(cq.assumeRole.roleArn, cq.assumeRole.sessionName, cq.assumeRole.externalId) : undefined; + // Identity is informational: a missing credential chain surfaces per call + // with the SDK's own error instead of wedging the whole request. + const identity = await Promise.race([ + callerIdentity(creds).catch((err: Error) => ({ error: `${err.name}: ${err.message}` })), + new Promise<{ error: string }>((resolve) => setTimeout(() => resolve({ error: "GetCallerIdentity timed out after 15s" }), 15_000)), + ]); + log(`[cloud-query] identity ${"arn" in identity ? identity.arn : `unavailable (${identity.error})`}`); + const factory = createAwsFactory(creds); + const response = await runRequest(cq, factory, log); + if ("arn" in identity) response.identity = identity; + if (cq.callback?.token) response.token = cq.callback.token; + if (cq.callback) { + // Callback mode: the RESULTS travel to the engine through the callback + // and the command completion carries a receipt only (see receipt.ts — + // completions above ~400 KB are dropped by the platform and re-delivered). + const body = JSON.stringify(response); + const cb = await postCallback(cq.callback.url, response); + log(`[cloud-query] callback ${cb.ok ? `delivered (HTTP ${cb.status}, ${body.length} bytes)` : `FAILED: ${cb.error}`}`); + const receipt = buildReceipt(response, cb, body.length); + req.emit({ stdout: JSON.stringify(receipt) }); + if (!cb.ok) return { success: false, errorCode: "CALLBACK_FAILED", error: cb.error, data: receipt }; + const failedCalls = response.calls.filter((c) => !c.ok); + if (failedCalls.length > 0) { + return { success: false, errorCode: "CALLS_FAILED", error: failedCalls.map((c) => `${c.id}: ${c.errorCode} ${c.error}`).join("; "), data: receipt }; + } + return { success: true, data: receipt }; + } + req.emit({ stdout: JSON.stringify(response) }); + const failed = response.calls.filter((c) => !c.ok); + if (failed.length > 0) { + return { success: false, errorCode: "CALLS_FAILED", error: failed.map((c) => `${c.id}: ${c.errorCode} ${c.error}`).join("; "), data: response }; + } + return { success: true, data: response }; + } catch (err) { + const e = err as { name?: string; message?: string }; + return { success: false, errorCode: e.name ?? "RUNNER_FAILED", error: e.message ?? String(err) }; + } + }, +}).start(); diff --git a/finops/packages/cloud-query/src/receipt.ts b/finops/packages/cloud-query/src/receipt.ts new file mode 100644 index 0000000..21024c6 --- /dev/null +++ b/finops/packages/cloud-query/src/receipt.ts @@ -0,0 +1,45 @@ +/** + * What the worker returns to the AGENT in callback mode. + * + * The full response goes to the engine through the callback. The command + * completion the agent posts to the control plane must stay SMALL: completions + * above ~400 KB are dropped and the command is re-delivered (measured + * 2026-09-10/11 — a 1.1 MB completion wedged the agent for minutes and every + * other command on it timed out). So the completion carries a receipt only: + * per-call status, sizes, identity and the callback outcome — never results. + */ +import type { CloudQueryResponse } from "./runner"; + +export interface CallbackReceipt { + provider: string; + region?: string; + identity?: CloudQueryResponse["identity"]; + token?: string; + callbackDelivered: boolean; + callbackError?: string; + callbackBytes: number; + calls: Array<{ id: string; ok: boolean; pages?: number; durationMs?: number; errorCode?: string; error?: string }>; +} + +export function buildReceipt( + response: CloudQueryResponse, + cb: { ok: true; status: number } | { ok: false; error: string }, + callbackBytes: number, +): CallbackReceipt { + return { + provider: response.provider, + ...(response.region ? { region: response.region } : {}), + ...(response.identity ? { identity: response.identity } : {}), + ...(response.token ? { token: response.token } : {}), + callbackDelivered: cb.ok, + ...(cb.ok ? {} : { callbackError: cb.error }), + callbackBytes, + calls: response.calls.map((c) => ({ + id: c.id, + ok: c.ok, + ...(c.pages !== undefined ? { pages: c.pages } : {}), + ...(c.durationMs !== undefined ? { durationMs: c.durationMs } : {}), + ...(c.ok ? {} : { errorCode: c.errorCode, error: c.error }), + })), + }; +} diff --git a/finops/packages/cloud-query/src/runner.ts b/finops/packages/cloud-query/src/runner.ts new file mode 100644 index 0000000..6b9622b --- /dev/null +++ b/finops/packages/cloud-query/src/runner.ts @@ -0,0 +1,176 @@ +/** + * cloud-query runner — generic cloud SDK call executor. + * + * The package carries NO cost semantics. A request is a list of SDK calls + * (`service` + `operation` + `params`); the runner executes each one with the + * worker's ambient credentials (IRSA / pod identity in a cluster, NP_WORKER_ENV + * locally), follows pagination, merges pages and returns the raw responses. + * What to ask, and what the numbers mean, lives in the workflows. + */ + +export interface CloudCall { + /** Caller-chosen id echoed back in the result. */ + id: string; + /** SDK service alias: ce | cost-explorer | cloudwatch | ec2 | sts | tagging | elbv2 | rds */ + service: string; + /** SDK operation name in PascalCase, e.g. GetCostAndUsage. */ + operation: string; + params?: Record; + /** Follow NextToken/NextPageToken and merge pages (default true). */ + paginate?: boolean; + /** Safety cap on pages per call (default 20). */ + maxPages?: number; +} + +export interface CloudQueryRequest { + provider?: "aws"; + region?: string; + /** Optional STS AssumeRole before running the calls. */ + assumeRole?: { roleArn: string; sessionName?: string; externalId?: string }; + calls: CloudCall[]; + /** Per-call serialized result cap in bytes (default 300 KB, see DEFAULT_MAX_RESULT_BYTES). */ + maxResultBytes?: number; + /** Where to POST the response (engine per-execution callback). Optional. */ + callback?: { url: string; token?: string }; +} + +export interface CloudCallResult { + id: string; + ok: boolean; + pages?: number; + durationMs: number; + result?: Record; + error?: string; + errorCode?: string; +} + +export interface CloudQueryResponse { + provider: "aws"; + region: string; + identity?: { account?: string; arn?: string }; + /** `callback.token` echoed back so the caller can verify provenance. */ + token?: string; + callbackDelivered?: boolean; + calls: CloudCallResult[]; +} + +/** Minimal shape of an AWS SDK v3 client. */ +export interface SdkClient { + send(command: unknown): Promise>; +} + +/** Builds a client + command for a call. Injected so the runner is testable. */ +export interface ClientFactory { + client(service: string, region: string): SdkClient; + command(service: string, operation: string, input: Record): unknown; +} + +const PAGE_TOKEN_KEYS = ["NextPageToken", "NextToken", "NextContinuationToken", "PaginationToken", "Marker"] as const; + +/** Default per-call result cap: 300 KB (see runRequest for the measurement). */ +export const DEFAULT_MAX_RESULT_BYTES = 300 * 1024; + +/** Merge a page into the accumulator: arrays concat, scalars/objects take the last page. */ +export function mergePage(acc: Record, page: Record): Record { + for (const [k, v] of Object.entries(page)) { + if (k === "$metadata" || (PAGE_TOKEN_KEYS as readonly string[]).includes(k)) continue; + const prev = acc[k]; + if (Array.isArray(v) && Array.isArray(prev)) acc[k] = prev.concat(v); + else acc[k] = v; + } + return acc; +} + +function nextToken(page: Record): { key: string; value: string } | undefined { + for (const key of PAGE_TOKEN_KEYS) { + const v = page[key]; + if (typeof v === "string" && v.length > 0) return { key, value: v }; + } + return undefined; +} + +export async function runCall(call: CloudCall, region: string, factory: ClientFactory, maxResultBytes: number): Promise { + const started = Date.now(); + try { + const client = factory.client(call.service, region); + const paginate = call.paginate ?? true; + const maxPages = call.maxPages ?? 20; + let input: Record = coerceDates({ ...(call.params ?? {}) }) as Record; + const merged: Record = {}; + let pages = 0; + for (;;) { + const page = await client.send(factory.command(call.service, call.operation, input)); + pages += 1; + mergePage(merged, page); + const tok = paginate ? nextToken(page) : undefined; + if (!tok || pages >= maxPages) break; + input = { ...input, [tok.key]: tok.value }; + } + const bytes = Buffer.byteLength(JSON.stringify(merged)); + if (bytes > maxResultBytes) { + return { id: call.id, ok: false, pages, durationMs: Date.now() - started, errorCode: "RESULT_TOO_LARGE", error: `result is ${bytes} bytes, cap is ${maxResultBytes}; narrow the query or lower maxPages` }; + } + return { id: call.id, ok: true, pages, durationMs: Date.now() - started, result: merged }; + } catch (err) { + const e = err as { name?: string; message?: string }; + return { id: call.id, ok: false, durationMs: Date.now() - started, errorCode: e.name ?? "CALL_FAILED", error: e.message ?? String(err) }; + } +} + +/** + * JSON has no Date: SDK inputs that are timestamps (Performance Insights StartTime/EndTime, + * CloudWatch GetMetricData StartTime/EndTime, …) arrive as ISO-8601 strings. Convert strings + * under keys ending in `Time`/`Timestamp` that parse as a date. Cost Explorer's + * TimePeriod.Start/End are plain YYYY-MM-DD strings under other keys and stay untouched. + */ +export function coerceDates(value: unknown, key = ""): unknown { + if (Array.isArray(value)) return value.map((v) => coerceDates(v, key)); + if (value && typeof value === "object" && !(value instanceof Date)) { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) out[k] = coerceDates(v, k); + return out; + } + if (typeof value === "string" && /(Time|Timestamp)$/.test(key) && /^\d{4}-\d{2}-\d{2}T[\d:.]+(Z|[+-]\d{2}:\d{2})$/.test(value)) { + const d = new Date(value); + if (!Number.isNaN(d.getTime())) return d; + } + return value; +} + +export async function runRequest(req: CloudQueryRequest, factory: ClientFactory, onProgress?: (line: string) => void): Promise { + const region = req.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1"; + // Measured 2026-09-10 against api.nullplatform.com: a ~360 KB stdout round-trips, + // ~800 KB never returns (504 after 60 s) and the platform RE-DELIVERS the + // command to the agent every ~60 s — an oversized answer is a poison pill, + // not just a failure. Keep the default well under the observed ceiling. + const maxResultBytes = req.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES; + const results: CloudCallResult[] = []; + for (const call of req.calls) { + onProgress?.(`[cloud-query] ${call.id}: ${call.service}.${call.operation}`); + const r = await runCall(call, region, factory, maxResultBytes); + onProgress?.(`[cloud-query] ${call.id}: ${r.ok ? `ok (${r.pages} page(s), ${r.durationMs} ms)` : `FAILED ${r.errorCode}: ${r.error}`}`); + results.push(r); + } + return { provider: "aws", region, calls: results }; +} + +/** Validate the inbound request shape; returns an error string or undefined. */ +export function validateRequest(x: unknown): string | undefined { + if (!x || typeof x !== "object") return "request must be an object"; + const r = x as Partial; + if (r.provider && r.provider !== "aws") return `unsupported provider ${String(r.provider)} (only aws for now)`; + if (!Array.isArray(r.calls) || r.calls.length === 0) return "calls must be a non-empty array"; + for (const [i, c] of r.calls.entries()) { + if (!c || typeof c !== "object") return `calls[${i}] must be an object`; + if (typeof c.id !== "string" || !c.id) return `calls[${i}].id is required`; + if (typeof c.service !== "string" || !c.service) return `calls[${i}].service is required`; + if (typeof c.operation !== "string" || !/^[A-Z][A-Za-z0-9]+$/.test(c.operation)) return `calls[${i}].operation must be PascalCase (e.g. GetCostAndUsage)`; + } + if (r.callback !== undefined) { + const url = (r.callback as { url?: unknown } | null)?.url; + if (typeof r.callback !== "object" || r.callback === null || typeof url !== "string" || !/^https?:\/\//.test(url)) { + return "callback.url must be an http(s) URL"; + } + } + return undefined; +} diff --git a/finops/packages/cloud-query/test/callback.test.ts b/finops/packages/cloud-query/test/callback.test.ts new file mode 100644 index 0000000..399208a --- /dev/null +++ b/finops/packages/cloud-query/test/callback.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { callbackAllowedHosts, checkCallbackUrl, postCallback } from "../src/callback"; + +function fakeFetch(responses: Array) { + const calls: Array<{ url: string; init: RequestInit }> = []; + const fetchImpl = (async (url: string, init: RequestInit) => { + calls.push({ url, init }); + const next = responses.shift(); + if (next instanceof Error) throw next; + return new Response("ok", { status: next ?? 200 }); + }) as unknown as typeof fetch; + return { fetchImpl, calls }; +} +const noSleep = async () => {}; +const CB = ["cb"]; + +describe("postCallback", () => { + test("POSTs JSON once on 2xx", async () => { + const { fetchImpl, calls } = fakeFetch([200]); + const r = await postCallback("http://cb/x", { a: 1 }, { fetchImpl, sleep: noSleep, allowedHosts: CB }); + expect(r).toEqual({ ok: true, status: 200 }); + expect(calls.length).toBe(1); + expect(calls[0]!.init.method).toBe("POST"); + expect((calls[0]!.init.headers as Record)["Content-Type"]).toBe("application/json"); + expect(calls[0]!.init.body).toBe(JSON.stringify({ a: 1 })); + }); + test("retries on 5xx and network errors, then succeeds", async () => { + const { fetchImpl, calls } = fakeFetch([503, new Error("ECONNRESET"), 200]); + const r = await postCallback("http://cb/x", {}, { fetchImpl, sleep: noSleep, attempts: 3, allowedHosts: CB }); + expect(r.ok).toBe(true); + expect(calls.length).toBe(3); + }); + test("does not retry on 4xx", async () => { + const { fetchImpl, calls } = fakeFetch([404]); + const r = await postCallback("http://cb/x", {}, { fetchImpl, sleep: noSleep, allowedHosts: CB }); + expect(r).toEqual({ ok: false, error: "callback returned HTTP 404" }); + expect(calls.length).toBe(1); + }); + test("gives up after attempts", async () => { + const { fetchImpl, calls } = fakeFetch([500, 500, 500]); + const r = await postCallback("http://cb/x", {}, { fetchImpl, sleep: noSleep, attempts: 3, allowedHosts: CB }); + expect(r.ok).toBe(false); + expect(calls.length).toBe(3); + }); + test("refuses hosts outside the allow-list without making a request", async () => { + const { fetchImpl, calls } = fakeFetch([200]); + const r = await postCallback("http://169.254.169.254/latest/meta-data", {}, { fetchImpl, sleep: noSleep, allowedHosts: CB }); + expect(r.ok).toBe(false); + expect((r as { error: string }).error).toContain("not allowed"); + expect(calls.length).toBe(0); + }); +}); + +describe("checkCallbackUrl", () => { + const allowed = ["api.nullplatform.com", "host.docker.internal"]; + test("accepts allow-listed hosts case-insensitively", () => { + expect(checkCallbackUrl("https://API.nullplatform.com/workflows/webhooks/callback/e/s", allowed)).toBeUndefined(); + expect(checkCallbackUrl("http://host.docker.internal:3000/x", allowed)).toBeUndefined(); + }); + test("rejects other hosts, non-http schemes, credentials and garbage", () => { + expect(checkCallbackUrl("https://evil.example/x", allowed)).toContain("not allowed"); + expect(checkCallbackUrl("http://10.0.0.5/x", allowed)).toContain("not allowed"); + expect(checkCallbackUrl("ftp://api.nullplatform.com/x", allowed)).toContain("http(s)"); + expect(checkCallbackUrl("https://u:p@api.nullplatform.com/x", allowed)).toContain("credentials"); + expect(checkCallbackUrl("not a url", allowed)).toContain("not a valid URL"); + }); +}); + +describe("callbackAllowedHosts", () => { + test("defaults to api.nullplatform.com", () => { + expect(callbackAllowedHosts(undefined, {})).toEqual(["api.nullplatform.com"]); + }); + test("reads NP_CALLBACK_ALLOWED_HOSTS", () => { + expect(callbackAllowedHosts(undefined, { NP_CALLBACK_ALLOWED_HOSTS: " Host.Docker.Internal, api.nullplatform.com ,, " })).toEqual([ + "host.docker.internal", + "api.nullplatform.com", + ]); + }); + test("explicit list wins", () => { + expect(callbackAllowedHosts(["cb"], { NP_CALLBACK_ALLOWED_HOSTS: "x" })).toEqual(["cb"]); + }); +}); diff --git a/finops/packages/cloud-query/test/receipt.test.ts b/finops/packages/cloud-query/test/receipt.test.ts new file mode 100644 index 0000000..2c3b4e4 --- /dev/null +++ b/finops/packages/cloud-query/test/receipt.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { buildReceipt } from "../src/receipt"; +import type { CloudQueryResponse } from "../src/runner"; + +const big = "x".repeat(500_000); +const response = { + provider: "aws", + region: "us-east-1", + identity: { account: "283477532906", arn: "arn:aws:sts::283477532906:assumed-role/k8s-np-finops-worker/s" }, + token: "tok", + calls: [ + { id: "by_service", ok: true, pages: 1, durationMs: 400, result: { big } }, + { id: "lbs", ok: false, errorCode: "AccessDenied", error: "nope" }, + ], +} as unknown as CloudQueryResponse; + +describe("callback receipt", () => { + test("never carries results, keeps per-call status, identity, token and callback outcome", () => { + const r = buildReceipt(response, { ok: true, status: 200 }, 512_000); + expect(JSON.stringify(r)).not.toContain(big.slice(0, 50)); + expect(JSON.stringify(r).length).toBeLessThan(1_000); + expect(r).toMatchObject({ provider: "aws", region: "us-east-1", token: "tok", callbackDelivered: true, callbackBytes: 512_000 }); + expect(r.identity?.account).toBe("283477532906"); + expect(r.calls).toEqual([ + { id: "by_service", ok: true, pages: 1, durationMs: 400 }, + { id: "lbs", ok: false, errorCode: "AccessDenied", error: "nope" }, + ]); + }); + test("records a failed callback", () => { + const r = buildReceipt(response, { ok: false, error: "host not allowed" }, 10); + expect(r.callbackDelivered).toBe(false); + expect(r.callbackError).toBe("host not allowed"); + }); +}); diff --git a/finops/packages/cloud-query/test/runner.test.ts b/finops/packages/cloud-query/test/runner.test.ts new file mode 100644 index 0000000..3794281 --- /dev/null +++ b/finops/packages/cloud-query/test/runner.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test"; +import { coerceDates, mergePage, runCall, runRequest, validateRequest, type ClientFactory } from "../src/runner"; + +/** Fake factory: pages keyed by the token the caller passes back. */ +function fakeFactory(pages: Record>, seen: Record[] = []): ClientFactory { + return { + client() { + return { + async send(cmd: unknown) { + const input = cmd as Record; + seen.push(input); + const tok = (input.NextPageToken ?? input.NextToken ?? "") as string; + const page = pages[tok]; + if (!page) throw Object.assign(new Error(`no page for token "${tok}"`), { name: "NoSuchPage" }); + return page; + }, + }; + }, + command(_s, _op, input) { + return input; + }, + }; +} + +describe("mergePage", () => { + test("concats arrays, keeps last scalar, drops tokens and metadata", () => { + const acc = mergePage({}, { ResultsByTime: [1], Total: "a", NextPageToken: "x", $metadata: {} }); + mergePage(acc, { ResultsByTime: [2, 3], Total: "b" }); + expect(acc).toEqual({ ResultsByTime: [1, 2, 3], Total: "b" }); + }); +}); + +describe("runCall", () => { + test("follows NextPageToken and merges pages", async () => { + const seen: Record[] = []; + const f = fakeFactory( + { + "": { ResultsByTime: [{ d: 1 }], NextPageToken: "p2" }, + p2: { ResultsByTime: [{ d: 2 }], NextPageToken: "p3" }, + p3: { ResultsByTime: [{ d: 3 }] }, + }, + seen, + ); + const r = await runCall({ id: "c", service: "ce", operation: "GetCostAndUsage", params: { Granularity: "DAILY" } }, "us-east-1", f, 1 << 20); + expect(r.ok).toBe(true); + expect(r.pages).toBe(3); + expect(r.result).toEqual({ ResultsByTime: [{ d: 1 }, { d: 2 }, { d: 3 }] }); + expect(seen[1]).toEqual({ Granularity: "DAILY", NextPageToken: "p2" }); + }); + + test("respects maxPages and paginate=false", async () => { + const f = fakeFactory({ "": { Items: [1], NextToken: "n" }, n: { Items: [2], NextToken: "n" } }); + const capped = await runCall({ id: "c", service: "ec2", operation: "DescribeInstances", maxPages: 2 }, "us-east-1", f, 1 << 20); + expect(capped.pages).toBe(2); + expect(capped.result?.Items).toEqual([1, 2]); + const single = await runCall({ id: "c", service: "ec2", operation: "DescribeInstances", paginate: false }, "us-east-1", f, 1 << 20); + expect(single.pages).toBe(1); + }); + + test("reports SDK errors per call without throwing", async () => { + const f = fakeFactory({}); + const r = await runCall({ id: "bad", service: "ce", operation: "GetCostAndUsage" }, "us-east-1", f, 1 << 20); + expect(r.ok).toBe(false); + expect(r.errorCode).toBe("NoSuchPage"); + }); + + test("caps oversized results", async () => { + const f = fakeFactory({ "": { Blob: "x".repeat(2000) } }); + const r = await runCall({ id: "big", service: "ce", operation: "GetCostAndUsage" }, "us-east-1", f, 1000); + expect(r.ok).toBe(false); + expect(r.errorCode).toBe("RESULT_TOO_LARGE"); + }); +}); + +describe("runCall pagination keys", () => { + test("follows PaginationToken (tagging API) and stops on the empty last-page token", async () => { + const seen: Record[] = []; + const f: ClientFactory = { + client() { + return { + async send(cmd: unknown) { + const input = cmd as Record; seen.push(input); + const tok = (input.PaginationToken ?? "") as string; + return tok === "" ? { ResourceTagMappingList: [1], PaginationToken: "p2" } : { ResourceTagMappingList: [2], PaginationToken: "" }; + }, + }; + }, + command(_s, _op, input) { return input; }, + }; + const r = await runCall({ id: "t", service: "tagging", operation: "GetResources" }, "us-east-1", f, 1 << 20); + expect(r.pages).toBe(2); + expect(r.result?.ResourceTagMappingList).toEqual([1, 2]); + expect(seen[1]).toEqual({ PaginationToken: "p2" }); + }); +}); + +describe("runRequest", () => { + test("runs calls in order and keeps going after a failure", async () => { + const f = fakeFactory({ "": { Ok: true } }); + const lines: string[] = []; + const res = await runRequest( + { region: "sa-east-1", calls: [{ id: "a", service: "ce", operation: "GetCostAndUsage" }, { id: "b", service: "ce", operation: "GetCostAndUsage", params: { NextPageToken: "missing" } }] }, + f, + (l) => lines.push(l), + ); + expect(res.region).toBe("sa-east-1"); + expect(res.calls.map((c) => [c.id, c.ok])).toEqual([["a", true], ["b", false]]); + expect(lines.length).toBe(4); + }); +}); + +describe("validateRequest", () => { + test("rejects malformed requests", () => { + expect(validateRequest(null)).toContain("object"); + expect(validateRequest({ calls: [] })).toContain("non-empty"); + expect(validateRequest({ provider: "gcp", calls: [{ id: "a", service: "ce", operation: "X" }] })).toContain("unsupported provider"); + expect(validateRequest({ calls: [{ id: "a", service: "ce", operation: "getCostAndUsage" }] })).toContain("PascalCase"); + expect(validateRequest({ calls: [{ id: "a", service: "ce", operation: "GetCostAndUsage" }] })).toBeUndefined(); + }); + test("validates callback url", () => { + const calls = [{ id: "a", service: "ce", operation: "GetCostAndUsage" }]; + expect(validateRequest({ calls, callback: { url: "ftp://x" } })).toContain("callback.url"); + expect(validateRequest({ calls, callback: { url: "http://host.docker.internal:3000/cb", token: "t" } })).toBeUndefined(); + }); +}); + +describe("coerceDates", () => { + test("turns ISO strings under *Time keys into Dates, leaves Cost Explorer periods alone", () => { + const out = coerceDates({ StartTime: "2026-09-09T00:00:00Z", EndTime: "2026-09-10T00:00:00.000Z", TimePeriod: { Start: "2026-09-09", End: "2026-09-10" }, MetricQueries: [{ Metric: "db.load.avg" }], Name: "x" }) as Record; + expect(out.StartTime).toBeInstanceOf(Date); + expect((out.EndTime as Date).toISOString()).toBe("2026-09-10T00:00:00.000Z"); + expect(out.TimePeriod).toEqual({ Start: "2026-09-09", End: "2026-09-10" }); + expect(out.MetricQueries).toEqual([{ Metric: "db.load.avg" }]); + expect(out.Name).toBe("x"); + }); +}); diff --git a/finops/packages/cloud-query/tsconfig.json b/finops/packages/cloud-query/tsconfig.json new file mode 100644 index 0000000..3d2664b --- /dev/null +++ b/finops/packages/cloud-query/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["bun"], + "outDir": "dist", + "rootDir": "." + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/finops/setup/01-catalog-spec.sh b/finops/setup/01-catalog-spec.sh new file mode 100755 index 0000000..0e2fc62 --- /dev/null +++ b/finops/setup/01-catalog-spec.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Creates (or updates in place) the two catalog entity specifications of the +# cost-finout suite: `infrastructure_cost` and `blended_rate`. +# +# NOTE: creating catalog SPECIFICATIONS requires an org-admin principal. +# The suite's API key (workflow-deployment-analyzer) can write INSTANCES once +# the specs exist (entities grants allow create/write to *), but not create +# the specs themselves — run this once with a user session token: +# +# NP_TOKEN= ./01-catalog-specs.sh +# (or NP_API_KEY= ./01-catalog-specs.sh) +# +# Idempotent: an existing spec (same slug) is PATCHed with the current schema. +set -euo pipefail + +API="https://api.nullplatform.com" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SPECS_DIR="$SCRIPT_DIR/../specs" + +if [[ -z "${NP_TOKEN:-}" ]]; then + [[ -n "${NP_API_KEY:-}" ]] || { echo "ERROR: set NP_TOKEN (session bearer) or NP_API_KEY"; exit 1; } + NP_TOKEN=$(curl -s -X POST "$API/token" -H 'Content-Type: application/json' \ + -d "{\"apikey\":\"$NP_API_KEY\"}" | jq -r '.access_token // empty') + [[ -n "$NP_TOKEN" ]] || { echo "ERROR: could not mint token from NP_API_KEY"; exit 1; } +fi + +# Resolve organization from the token (falabella = 987889794) +ORG_ID=$(python3 - "$NP_TOKEN" <<'EOF' +import base64, json, sys +p = sys.argv[1].split('.')[1]; p += '=' * (-len(p) % 4) +c = json.loads(base64.urlsafe_b64decode(p)) +grp = c.get('cognito:groups') or [] +org = next((g.split('=')[1] for g in grp if g.startswith('@nullplatform/organization=')), '') +print(org or c.get('organization_id', '')) +EOF +) +[[ -n "$ORG_ID" ]] || ORG_ID=1255165411 +echo "organization: $ORG_ID" +TOKEN_USER_ID=$(python3 - "$NP_TOKEN" <<'PY' +import base64, json, sys, re +p = sys.argv[1].split('.')[1]; p += '=' * (-len(p) % 4) +g = json.loads(base64.urlsafe_b64decode(p)).get('cognito:groups', []) +m = [re.sub(r'.*user=', '', x) for x in g if 'user=' in x] +print(m[0] if m else 0) +PY +) +echo "admin user for grants: ${ADMIN_USER_ID:-$TOKEN_USER_ID}" + +api() { # method path [body-file] + local m="$1" p="$2" b="${3:-}" + if [[ -n "$b" ]]; then + curl -s -w '\n%{http_code}' -X "$m" "$API$p" -H "Authorization: Bearer $NP_TOKEN" \ + -H 'Content-Type: application/json' --data-binary "@$b" + else + curl -s -w '\n%{http_code}' -X "$m" "$API$p" -H "Authorization: Bearer $NP_TOKEN" + fi +} + +for slug in cost_daily scope_usage_daily cost_mapping_rule cost_mapping_suggestion application_cost_daily; do + f="$SPECS_DIR/$slug.spec.json" + body=$(mktemp) + # Spec grants must name a user of THIS org: rewrite the admin principal in the JSON + # (every `type: user` principal, placeholder 732189543) to ADMIN_USER_ID, default = the token's user. + jq --arg nrn "organization=$ORG_ID" --argjson admin "${ADMIN_USER_ID:-$TOKEN_USER_ID}" \ + '. + {nrn: $nrn} | (.. | objects | select(.type? == "user") | .id) |= $admin' "$f" > "$body" + + out=$(api POST "/catalog/specifications" "$body") + st=$(tail -n1 <<<"$out"); res=$(sed '$d' <<<"$out") + if [[ "$st" =~ ^2 ]]; then + echo "created $slug: $(jq -r '.id // "ok"' <<<"$res")" + elif [[ "$st" == "409" || ( "$st" == "400" && "$res" == *exist* ) ]]; then + sid=$(api GET "/catalog/specifications?nrn=organization=$ORG_ID&limit=100" | sed '$d' \ + | jq -r --arg s "$slug" '[.. | objects | select(.slug? == $s)] | .[0].id // empty') + [[ -n "$sid" ]] || { echo "FAILED: $slug exists but id not resolvable: $res"; exit 1; } + # NOTA: el PATCH rechaza la key `relations` dentro de schema (400) — se quita + patch=$(mktemp); jq '{schema: (.schema | del(.relations)), description: .description, name: .name}' "$body" > "$patch" + out=$(api PATCH "/catalog/specifications/$sid" "$patch") + st=$(tail -n1 <<<"$out") + [[ "$st" =~ ^2 ]] && echo "updated $slug ($sid)" || { echo "FAILED patch $slug ($st): $(sed '$d' <<<"$out")"; exit 1; } + else + echo "FAILED $slug ($st): $res"; exit 1 + fi +done diff --git a/finops/setup/02-aws-worker-identity.sh b/finops/setup/02-aws-worker-identity.sh new file mode 100755 index 0000000..b4b9bcf --- /dev/null +++ b/finops/setup/02-aws-worker-identity.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Gives the cloud-query WORKER POD its own read-only AWS identity (EKS Pod +# Identity) so the billing collector runs with NO credentials in workflows. +# +# What it does (idempotent, single account): +# 1. IAM role trusted by pods.eks.amazonaws.com, scoped to THIS cluster +# (docs/iam/trust-pod-identity.json) + inline read-only policy +# (docs/iam/np-finops-worker-policy.json). +# 2. ServiceAccount in the agent's worker namespace (NP_WORKER_NAMESPACE). +# 3. Pod Identity association cluster × namespace × SA → role. +# 4. Prints the NP_WORKER_RULES entry that makes the controlplane-agent spawn +# the cloud-query image with that ServiceAccount (the agent env change is +# NOT applied here — it is the operator's deployment; see the output). +# +# Requirements: aws CLI with admin-ish creds on the account (iam:CreateRole, +# iam:PutRolePolicy, eks:CreatePodIdentityAssociation), kubectl on the cluster, +# the eks-pod-identity-agent addon installed (`aws eks list-addons`). +# +# Usage: +# ./02-aws-worker-identity.sh --cluster runtime [--region us-east-1] \ +# [--namespace np-workers] [--service-account np-finops-worker] \ +# [--role np-finops-worker] [--image ] [--package cloud-query] +# +# Multi-account (the worker assumes a per-account role): run this ONCE on the +# cluster account with --assume-only, then create `np-finops` in each account +# with docs/iam/trust-cross-account.json + np-finops-worker-policy.json and put +# the ARNs in docs/iam/np-finops-worker-assume-policy.json. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +IAM_DIR="$SCRIPT_DIR/../docs/iam" + +CLUSTER=""; REGION="${AWS_REGION:-us-east-1}"; NAMESPACE="np-workers" +SA="np-finops-worker"; ROLE="np-finops-worker" +IMAGE="public.ecr.aws/nullplatform/agent-plugins/workflows/aws-cost-explorer"; PACKAGE="cloud-query"; ASSUME_ONLY=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --cluster) CLUSTER="$2"; shift 2 ;; + --region) REGION="$2"; shift 2 ;; + --namespace) NAMESPACE="$2"; shift 2 ;; + --service-account) SA="$2"; shift 2 ;; + --role) ROLE="$2"; shift 2 ;; + --image) IMAGE="$2"; shift 2 ;; + --package) PACKAGE="$2"; shift 2 ;; + --assume-only) ASSUME_ONLY=true; shift ;; + -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + *) echo "unknown arg: $1" >&2; exit 1 ;; + esac +done +[[ -n "$CLUSTER" ]] || { echo "ERROR: --cluster is required" >&2; exit 1; } +command -v jq >/dev/null || { echo "ERROR: jq is required" >&2; exit 1; } + +ACCOUNT=$(aws sts get-caller-identity --query Account --output text) +CLUSTER_ARN=$(aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query cluster.arn --output text) +echo "account=$ACCOUNT cluster=$CLUSTER_ARN namespace=$NAMESPACE sa=$SA role=$ROLE" + +if ! aws eks list-addons --cluster-name "$CLUSTER" --region "$REGION" --output text | grep -q eks-pod-identity-agent; then + echo "ERROR: addon eks-pod-identity-agent is not installed on $CLUSTER (install it, or use docs/iam/trust-irsa.json instead)" >&2 + exit 1 +fi + +# 1. Role (trust scoped to this cluster) + inline policy +TRUST=$(sed -e "s##$ACCOUNT#g" -e "s##$REGION#g" -e "s##$CLUSTER#g" "$IAM_DIR/trust-pod-identity.json") +if aws iam get-role --role-name "$ROLE" >/dev/null 2>&1; then + echo "role $ROLE exists → updating trust policy" + aws iam update-assume-role-policy --role-name "$ROLE" --policy-document "$TRUST" +else + aws iam create-role --role-name "$ROLE" --assume-role-policy-document "$TRUST" \ + --description "nullplatform finops: read-only identity of the cloud-query worker pod" \ + --tags Key=managed-by,Value=nullplatform-finops >/dev/null + echo "role $ROLE created" +fi +if [[ "$ASSUME_ONLY" == true ]]; then + aws iam put-role-policy --role-name "$ROLE" --policy-name np-finops-assume \ + --policy-document "file://$IAM_DIR/np-finops-worker-assume-policy.json" + echo "inline policy np-finops-assume attached (edit the account list in np-finops-worker-assume-policy.json first)" +else + aws iam put-role-policy --role-name "$ROLE" --policy-name np-finops-read \ + --policy-document "file://$IAM_DIR/np-finops-worker-policy.json" + echo "inline policy np-finops-read attached" +fi +ROLE_ARN=$(aws iam get-role --role-name "$ROLE" --query Role.Arn --output text) + +# 2. ServiceAccount in the worker namespace +kubectl get ns "$NAMESPACE" >/dev/null 2>&1 || kubectl create ns "$NAMESPACE" +if ! kubectl -n "$NAMESPACE" get sa "$SA" >/dev/null 2>&1; then + kubectl -n "$NAMESPACE" create sa "$SA" + echo "serviceaccount $NAMESPACE/$SA created" +else + echo "serviceaccount $NAMESPACE/$SA exists" +fi + +# 3. Pod Identity association (one per cluster × ns × sa) +EXISTING=$(aws eks list-pod-identity-associations --cluster-name "$CLUSTER" --region "$REGION" \ + --namespace "$NAMESPACE" --service-account "$SA" --query 'associations[0].associationId' --output text) +if [[ -n "$EXISTING" && "$EXISTING" != "None" ]]; then + aws eks update-pod-identity-association --cluster-name "$CLUSTER" --region "$REGION" \ + --association-id "$EXISTING" --role-arn "$ROLE_ARN" >/dev/null + echo "pod identity association $EXISTING updated → $ROLE_ARN" +else + aws eks create-pod-identity-association --cluster-name "$CLUSTER" --region "$REGION" \ + --namespace "$NAMESPACE" --service-account "$SA" --role-arn "$ROLE_ARN" >/dev/null + echo "pod identity association created → $ROLE_ARN" +fi + +# 4. Agent side: first matching rule wins. Match the EXACT image repository (the +# worker is always referenced by digest, so no tag is part of the string) and +# the package slug: no other image gets this identity. +RULES=$(jq -cn --arg img "$IMAGE" --arg pkg "$PACKAGE" --arg sa "$SA" '[{match:{registry:$img,package:$pkg},serviceAccount:$sa}]') +cat < patch secret --type=merge \\ + -p '{"data":{"NP_WORKER_RULES":"$(printf '%s' "$RULES" | base64)"}}' + kubectl -n rollout restart deploy/ +Then run the identity probe: the worker must report arn:aws:sts::$ACCOUNT:assumed-role/$ROLE/... +EOF diff --git a/finops/setup/03-mapping-rules.sh b/finops/setup/03-mapping-rules.sh new file mode 100755 index 0000000..b18f639 --- /dev/null +++ b/finops/setup/03-mapping-rules.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Upserts cost_mapping_rule rows from a JSON array (e.g. setup/rules..json). +# Needs NP_API_KEY (org API key with catalog entities write) or NP_TOKEN. +# NP_API_KEY=… ./03-mapping-rules.sh setup/rules.nullplatform.json +set -euo pipefail +API="https://api.nullplatform.com" +FILE="${1:?usage: 03-mapping-rules.sh }" +if [[ -z "${NP_TOKEN:-}" ]]; then + [[ -n "${NP_API_KEY:-}" ]] || { echo "ERROR: set NP_TOKEN or NP_API_KEY"; exit 1; } + NP_TOKEN=$(curl -s -X POST "$API/token" -H 'Content-Type: application/json' -d "{\"apikey\":\"$NP_API_KEY\"}" | jq -r '.access_token // empty') + [[ -n "$NP_TOKEN" ]] || { echo "ERROR: could not mint token"; exit 1; } +fi +now=$(date -u +%Y-%m-%dT%H:%M:%SZ) +jq -c '.[]' "$FILE" | while read -r rule; do + id=$(jq -r '.id' <<<"$rule") + body=$(jq --arg now "$now" '. + {updated_at: $now} | .created_at //= $now' <<<"$rule") + st=$(curl -s -o /tmp/np_rule_out -w '%{http_code}' -X PATCH "$API/catalog/instances/cost_mapping_rule/$id?upsert=true" \ + -H "Authorization: Bearer $NP_TOKEN" -H 'Content-Type: application/json' -d "$body") + if [[ "$st" =~ ^2 ]]; then echo "upserted $id"; else echo "FAILED $id ($st): $(head -c 300 /tmp/np_rule_out)"; exit 1; fi +done diff --git a/finops/setup/backfill.sh b/finops/setup/backfill.sh new file mode 100755 index 0000000..f7e8fee --- /dev/null +++ b/finops/setup/backfill.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# One day at a time: collection + Kubernetes through the dispatcher (no allocation), then the allocator +# alone (see docs/customer-onboarding.md §5.1 for why). Re-mints the token per day (60-minute tokens). +# NP_API_KEY=… ./backfill.sh 2026-09-10 2026-09-09 … +set -euo pipefail +API="https://api.nullplatform.com"; WF0="$1"; WF2="$2"; shift 2 +tok() { curl -s -X POST "$API/token" -H 'Content-Type: application/json' -d "{\"apikey\":\"${NP_API_KEY:?}\"}" | python3 -c "import sys,json;print(json.load(sys.stdin).get('access_token',''))"; } +run() { curl -s -X POST "$API/workflows/definitions/$1/execute" -H "Authorization: Bearer $T" -H 'Content-Type: application/json' -d "$2" | python3 -c "import sys,json;print(json.load(sys.stdin)['execution']['id'])"; } +wait_for() { for _ in $(seq 1 90); do sleep 20; s=$(curl -s -H "Authorization: Bearer $T" "$API/workflows/executions/$1" | python3 -c "import json,sys;d=json.load(sys.stdin);e=d.get('data',d);print(e.get('status'))"); case "$s" in completed|failed|cancelled) echo "$s"; return;; esac; done; echo timeout; } +summary() { curl -s -H "Authorization: Bearer $T" "$API/workflows/executions/$1/steps/summary" | python3 -c " +import json,sys;d=json.load(sys.stdin);e=d.get('data',d);s=(e.get('invocations') or [{}])[-1].get('outputs') or {} +tot=s.get('total_usd') or 0;apps=[a for a in s.get('applications',[]) if a.get('application_id')];ap=sum(a['cost_usd'] for a in apps) +print('total %.2f apps %.2f (%.1f%%) unallocated %.2f invoices %s'%(tot,ap,100*ap/tot if tot else 0,s.get('unallocated_usd') or 0,s.get('invoices')))"; } +for D in "$@"; do + T=$(tok); E=$(run "$WF0" "{\"inputs\":{\"date\":\"$D\",\"allocate\":false}}"); echo "$(date -u +%T) $D collect+k8s $E" + s=$(wait_for "$E"); echo "$(date -u +%T) $D collect+k8s -> $s"; [[ "$s" == completed ]] || continue + T=$(tok); E2=$(run "$WF2" "{\"inputs\":{\"date\":\"$D\"}}"); echo "$(date -u +%T) $D allocate $E2" + s=$(wait_for "$E2"); echo "$(date -u +%T) $D allocate -> $s | $(summary "$E2")" +done diff --git a/finops/setup/publish.ts b/finops/setup/publish.ts new file mode 100644 index 0000000..96e874f --- /dev/null +++ b/finops/setup/publish.ts @@ -0,0 +1,138 @@ +/** + * Publish the finops workflows to an engine (local dev-server OR the platform) + * in dependency order, patching the sub-workflow placeholders with the ids the + * engine assigns. Needed because `npx np-workflow publish` validates against a + * plugin catalog that may not know `np-package-call` yet. + * + * Run from the ENGINE worktree so the DSL parser resolves: + * cd ~/workspace/null/workflow-system-demo + * NP_TOKEN= pnpm tsx ~/workspace/null/workflows/finops/setup/publish.ts \ + * ~/workspace/null/workflows/finops \ + * --base https://api.nullplatform.com \ + * --vars ~/workspace/null/workflows/finops/setup/vars.nullplatform.json \ + * [--alias live] [--no-activate] [--update =,...] + * + * --vars JSON {"": {"": , …}} — per-org + * values (agent tags/NRN, org NRN, expected account, dispatcher + * targets). Definitions in git keep neutral defaults. + * --update re-publish as a NEW REVISION of an existing definition (PUT) instead + * of creating one; the alias is re-pointed to the new revision. + * --no-activate create the alias but do not activate it (crons stay off). + * Prints one line per workflow: . + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const args = process.argv.slice(2); +const dir = args[0]; +if (!dir || dir.startsWith('--')) throw new Error('usage: publish.ts [--base url] [--alias name] [--vars file] [--no-activate] [--update file=id,...]'); +function opt(name: string): string | undefined { + const i = args.indexOf(name); + return i >= 0 ? args[i + 1] : undefined; +} +const base = opt('--base') ?? 'http://127.0.0.1:3210'; +const alias = opt('--alias') ?? 'live'; +const activate = !args.includes('--no-activate'); +const varsFile = opt('--vars'); +const vars: Record> = varsFile ? (JSON.parse(readFileSync(resolve(varsFile), 'utf8')) as Record>) : {}; +const updates: Record = {}; +for (const pair of (opt('--update') ?? '').split(',').filter(Boolean)) { + const [file, id] = pair.split('='); + if (file && id) updates[file] = id; +} +const token = process.env.NP_TOKEN; + +// The engine's DSL parser, resolved from the CURRENT directory (the engine worktree). +const dsl = (await import(pathToFileURL(resolve(process.cwd(), 'packages/dsl/src/yaml/parser.ts')).href)) as { + parseYamlWorkflow: (input: string) => unknown; +}; +const { parseYamlWorkflow } = dsl; + +// file → placeholder its id fills in dependents +const ORDER: Array<{ file: string; placeholder?: string }> = [ + { file: 'tool-cloud-query.yaml', placeholder: 'FINOPS_CLOUD_QUERY_ID' }, + { file: 'wf-cost-fact-upsert.yaml', placeholder: 'FINOPS_COST_FACT_UPSERT_ID' }, + { file: 'wf1-aws-billing-daily.yaml', placeholder: 'FINOPS_AWS_BILLING_DAILY_ID' }, + { file: 'wf-suggest-mappings.yaml', placeholder: 'FINOPS_SUGGEST_MAPPINGS_ID' }, + { file: 'wf3-k8s-consumption-daily.yaml', placeholder: 'FINOPS_K8S_CONSUMPTION_ID' }, + { file: 'wf2-allocate-daily.yaml', placeholder: 'FINOPS_ALLOCATE_DAILY_ID' }, + { file: 'wf0-aws-billing-dispatch.yaml' }, +]; +// A PARTIAL --update creates brand-new definitions (with live crons) for the files it omits — that +// happened once in prod. Either update every file or none (pass --allow-create to mix on purpose). +if (Object.keys(updates).length && !args.includes('--allow-create')) { + const missing = ORDER.map((o) => o.file).filter((f) => !updates[f]); + if (missing.length) throw new Error(`--update covers ${Object.keys(updates).length}/${ORDER.length} files; missing ${missing.join(', ')} (add them, or pass --allow-create to publish them as NEW definitions)`); +} + +async function api(method: string, path: string, body?: unknown): Promise> { + const r = await fetch(base + path, { + method, + headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await r.text(); + if (!r.ok) throw new Error(`${method} ${path} → ${r.status}: ${text.slice(0, 600)}`); + return text ? (JSON.parse(text) as Record) : {}; +} + +/** Apply per-org variable values: definition.variables[name].initialValue. */ +function applyVars(def: Record): void { + const overrides = vars[String(def.id)] ?? vars[String(def.clientKey ?? '')]; + if (!overrides) return; + const variables = (def.variables ?? {}) as Record>; + for (const [name, value] of Object.entries(overrides)) { + if (!variables[name]) throw new Error(`${def.id}: --vars sets unknown variable "${name}"`); + variables[name] = { ...variables[name], initialValue: value }; + } + def.variables = variables; +} + +const ids: Record = {}; +for (const { file, placeholder } of ORDER) { + let yaml = readFileSync(resolve(dir, file), 'utf8'); + for (const [ph, id] of Object.entries(ids)) yaml = yaml.split(ph).join(id); + let parsed = parseYamlWorkflow(yaml) as Record; + // Decider ports (`source_port: "true"|"false"` on a `conditional`) are only known with a plugin + // registry, which the standalone parser lacks: it reports CONNECTION_SOURCE_PORT_UNKNOWN for every + // such edge. The server re-validates with the registry, so parse a copy without those ports and + // put them back on the normalized connections (matched by edge id). + const portErrs = (parsed.errors as Array<{ code?: string }> | undefined) ?? []; + if (portErrs.length && portErrs.every((e) => e.code === 'graph/CONNECTION_SOURCE_PORT_UNKNOWN')) { + const ports: Record = {}; + const stripped = yaml.replace(/^(\s*-\s*\{[^}]*\bid:\s*([A-Za-z0-9_-]+)[^}]*),\s*source_port:\s*"([^"]+)"([^}]*\})/gm, (_m, head: string, id: string, port: string, tail: string) => { ports[id] = port; return head + tail; }); + parsed = parseYamlWorkflow(stripped) as Record; + const w = (parsed.workflow ?? parsed.definition) as { connections?: Array<{ id?: string; sourcePort?: string }> } | undefined; + for (const c of w?.connections ?? []) if (c.id && ports[c.id]) c.sourcePort = ports[c.id]; + if (Object.keys(ports).length === 0) throw new Error(`${file}: decider port errors but no \`- { id, …, source_port }\` edges found to restore`); + } + if (Array.isArray(parsed.errors) && parsed.errors.length) throw new Error(`${file}: ${JSON.stringify(parsed.errors).slice(0, 600)}`); + const def = (parsed.definition ?? parsed.workflow ?? parsed.value ?? parsed) as Record; + applyVars(def); + // The normalizer turns duration strings ("150s") into milliseconds, but the http-request plugin's + // configSchema wants the STRING form: put it back as "ms". + for (const st of Object.values((def.steps ?? {}) as Record }>)) { + if (st.pluginType === 'http-request' && st.config && typeof st.config.timeout === 'number') st.config.timeout = `${st.config.timeout}ms`; + } + const existing = updates[file]; + const created = existing ? await api('PUT', `/workflows/definitions/${existing}`, def) : await api('POST', '/workflows/definitions', def); + const wf = (created.workflow ?? created) as Record; + const revObj = created.revision as Record | number | undefined; + const id = String(existing ?? wf.id); + const revision = typeof revObj === 'number' ? revObj : Number((revObj as Record | undefined)?.revision ?? wf.revision ?? 1); + if (existing) { + // Re-point the alias to the new revision (PUT /aliases/:alias); create it if the definition had none. + try { + await api('PUT', `/workflows/definitions/${id}/aliases/${alias}`, { revision }); + } catch (err) { + if (!/404/.test(String(err))) throw err; + await api('POST', `/workflows/definitions/${id}/aliases`, { name: alias, revision }); + } + } else { + await api('POST', `/workflows/definitions/${id}/aliases`, { name: alias, revision }); + } + if (activate) await api('POST', `/workflows/definitions/${id}/aliases/${alias}/activate`, {}); + if (placeholder) ids[placeholder] = id; + console.log(`${file} ${id} ${revision}`); +} diff --git a/finops/setup/report-finops.py b/finops/setup/report-finops.py new file mode 100644 index 0000000..177093b --- /dev/null +++ b/finops/setup/report-finops.py @@ -0,0 +1,213 @@ +"""Generate the "FinOps — Costos por aplicación" report definition (nullplatform dynamic report). + +Usage: python3 finops/setup/report-finops.py --spec-id [--out file.json] +Then: fetch_np_api_url.sh --method POST --data @file.json /report (or PATCH /report/) + +Rules that matter (learned on nullplatform, org 4): +- catalog DELETEs never reach the Lake → every query keeps only the LATEST allocator run per day + (`QUALIFY collected_at = max(collected_at) OVER (PARTITION BY day)`); +- `argMax(data, _version)` over the spec's rows instead of `FINAL` on the whole table (6–8 s → ~1 s); +- filter `params` keys MUST equal the schema property names or the frontend never re-runs the query; +- an area chart with a single day renders nothing → stacked bars; +- stacked bars with many series + `borderRadius` render as 1px outlines: no borderRadius, explicit `colors`; +- `rule` (SHOW/HIDE) is ignored, tabs (Categorization) render hidden charts with zero width, nested + query targets are never written → one chart per grouping in a 2-column grid; +- KPIs add up: total = applications + shared platform + Kubernetes overhead + unallocated. +""" +import argparse, json, sys +ap = argparse.ArgumentParser(); ap.add_argument('--spec-id', required=True, help='uuid of the cost_daily spec in the organization'); ap.add_argument('--out', default='np-report-finops.json'); ap.add_argument('--usage-spec-id', default='', help='uuid of the scope_usage_daily spec (adds the Kubernetes usage / right-sizing section)'); ap.add_argument('--top', type=int, default=8, help='series per grouping in the daily explorer chart (top-N of the last 30 days + otros)'); ap.add_argument('--no-lake', action='store_true', help='do not query the Lake for the top-N (placeholder series)') +ARGS = ap.parse_args() +J="JSONExtractString(assumeNotNull(c.data),'%s')" +F="JSONExtractFloat(assumeNotNull(c.data),'%s')" +DATE="toDateOrNull(" + (J % 'day') + ") >= toDate(coalesce(parseDateTimeBestEffortOrNull({startDate:String}), now() - INTERVAL 30 DAY)) AND (parseDateTimeBestEffortOrNull({endDate:String}) IS NULL OR toDateOrNull(" + (J % 'day') + ") <= toDate(parseDateTimeBestEffortOrNull({endDate:String})))" +SPEC_ID=ARGS.spec_id # literal uuid: the Lake has no index on the spec slug +INNER=f"SELECT id, argMax(data, _version) AS data FROM catalog_entities WHERE entity_specification_id = '{SPEC_ID}' GROUP BY id HAVING argMax(_deleted, _version) = 0" +# leaves = allocated rows that are not rollups (one row per source fact x owner) +BASE=f"""SELECT {J % 'day'} AS day, {J % 'application_id'} AS application_id, {J % 'application_slug'} AS application_slug, {J % 'charge_type'} AS charge_type, {J % 'scope_id'} AS scope_id, {J % 'scope_name'} AS scope_name, {J % 'scope_type'} AS scope_type, {J % 'service_id'} AS service_id, {J % 'service_name'} AS service_name, {J % 'environment'} AS environment, {J % 'category'} AS category, {J % 'cloud_service'} AS cloud_service, {J % 'subject_type'} AS subject_type, {J % 'subject_name'} AS subject_name, {J % 'allocation_method'} AS allocation_method, {J % 'rule_id'} AS rule_id, {J % 'bucket'} AS bucket, {J % 'cluster'} AS cluster, {F % 'cost_usd'} AS cost_usd, {J % 'collected_at'} AS collected_at FROM ({INNER}) AS c WHERE {J % 'stage'} = 'allocated' AND {J % 'allocation_method'} != 'rollup' AND {J % 'subject_type'} != 'unallocated' AND {DATE} QUALIFY collected_at = max(collected_at) OVER (PARTITION BY day)""" +# app join: application → namespace → account (names + account filter) +APPJ="LEFT JOIN core_entities_application AS a FINAL ON toString(a.app_id) = f.application_id AND a._deleted = 0 LEFT JOIN core_entities_namespace AS n FINAL ON n.namespace_id = a.namespace_id AND n._deleted = 0 LEFT JOIN core_entities_account AS ac FINAL ON ac.account_id = n.account_id AND ac._deleted = 0" +APPW="f.application_id != '' AND ({scopeId:String} = '' OR f.scope_id = {scopeId:String}) AND ({accountId:String} = '' OR toString(n.account_id) = {accountId:String}) AND ({applicationId:String} = '' OR f.application_id = {applicationId:String}) AND ({environment:String} = '' OR f.environment = {environment:String}) AND ({chargeType:String} = '' OR f.charge_type = {chargeType:String}) AND ({serviceId:String} = '' OR f.service_id = {serviceId:String}) AND ({scopeType:String} = '' OR f.scope_type = {scopeType:String})" +P_ALL={"startDate":{"scope":"#/properties/startDate"},"endDate":{"scope":"#/properties/endDate"},"scopeId":{"scope":"#/properties/scopeId"},"accountId":{"scope":"#/properties/accountId"},"applicationId":{"scope":"#/properties/applicationId"},"environment":{"scope":"#/properties/environment"},"chargeType":{"scope":"#/properties/chargeType"},"serviceId":{"scope":"#/properties/serviceId"},"scopeType":{"scope":"#/properties/scopeType"}} +P_DATE={"startDate":{"scope":"#/properties/startDate"},"endDate":{"scope":"#/properties/endDate"}} +APPS=f"WITH f AS ({BASE}) SELECT round(sum(f.cost_usd), 2) AS appsUsd FROM f {APPJ} WHERE {APPW} FORMAT JSON" +TOTAL=f"WITH f AS ({BASE}) SELECT round(sum(f.cost_usd), 2) AS totalUsd, round(sumIf(f.cost_usd, f.allocation_method = 'kubernetes_overhead'), 2) AS overheadUsd, round(sumIf(f.cost_usd, f.allocation_method = 'unallocated'), 2) AS unallocatedUsd, round(sumIf(f.cost_usd, f.application_id = '' AND f.allocation_method NOT IN ('unallocated', 'kubernetes_overhead')), 2) AS sharedUsd, round(sumIf(f.cost_usd, f.application_id != '') * 100.0 / greatest(sum(f.cost_usd), 0.000001), 1) AS attributedPct FROM f FORMAT JSON" +TREND=f"WITH f AS ({BASE}) SELECT f.day AS day, round(sumIf(f.cost_usd, f.charge_type = 'scope'), 2) AS scopes, round(sumIf(f.cost_usd, f.charge_type = 'service'), 2) AS services, round(sumIf(f.cost_usd, f.charge_type = 'application'), 2) AS applications, round(sumIf(f.cost_usd, f.application_id = '' AND f.allocation_method != 'unallocated'), 2) AS shared, round(sumIf(f.cost_usd, f.allocation_method = 'unallocated'), 2) AS unallocated FROM f GROUP BY day ORDER BY day FORMAT JSON" +BYAPP=f"WITH f AS ({BASE}) SELECT coalesce(nullIf(a.application_slug, ''), f.application_slug, f.application_id) AS app, round(sum(f.cost_usd), 2) AS usd FROM f {APPJ} WHERE {APPW} GROUP BY app ORDER BY usd DESC LIMIT 15 FORMAT JSON" +BYENV=f"WITH f AS ({BASE}) SELECT if(f.environment = '', 'sin dimensión', f.environment) AS label, round(sum(f.cost_usd), 2) AS value FROM f {APPJ} WHERE {APPW} GROUP BY label ORDER BY value DESC FORMAT JSON" +BYSVC=f"WITH f AS ({BASE}) SELECT f.cloud_service AS label, round(sum(f.cost_usd), 2) AS value FROM f {APPJ} WHERE {APPW} GROUP BY label ORDER BY value DESC LIMIT 12 FORMAT JSON" +BYCAT=f"WITH f AS ({BASE}) SELECT if(f.category = '', 'other', f.category) AS label, round(sum(f.cost_usd), 2) AS value FROM f {APPJ} WHERE {APPW} GROUP BY label ORDER BY value DESC FORMAT JSON" +APPTABLE=f"WITH f AS ({BASE}) SELECT coalesce(nullIf(a.application_slug, ''), f.application_slug, f.application_id) AS app, coalesce(n.namespace_name, '') AS namespace, coalesce(ac.account_name, '') AS account, round(sum(f.cost_usd), 2) AS total, round(sumIf(f.cost_usd, f.charge_type = 'scope'), 2) AS scopes, round(sumIf(f.cost_usd, f.charge_type = 'service'), 2) AS services, round(sumIf(f.cost_usd, f.charge_type = 'application'), 2) AS applications, round(sumIf(f.cost_usd, f.environment = 'production'), 2) AS production, round(sumIf(f.cost_usd, f.environment != 'production'), 2) AS nonProduction, uniqExact(f.day) AS days, round(sum(f.cost_usd) / greatest(uniqExact(f.day), 1), 2) AS perDay FROM f {APPJ} WHERE {APPW} GROUP BY app, namespace, account ORDER BY total DESC FORMAT JSON" +OBJTABLE=f"WITH f AS ({BASE}) SELECT f.day AS day, coalesce(nullIf(a.application_slug, ''), f.application_slug, f.application_id) AS app, f.charge_type AS chargeType, multiIf(f.charge_type = 'scope', f.scope_name, f.charge_type = 'service', f.service_name, f.subject_name) AS object, if(f.charge_type = 'scope', f.scope_type, '') AS scopeType, if(f.environment = '', '-', f.environment) AS environment, f.category AS category, f.cloud_service AS cloudService, f.allocation_method AS method, f.rule_id AS rule, round(sum(f.cost_usd), 2) AS usd FROM f {APPJ} WHERE {APPW} GROUP BY day, app, chargeType, object, scopeType, environment, category, cloudService, method, rule ORDER BY day DESC, usd DESC LIMIT 1000 FORMAT JSON" +SHARED=f"WITH f AS ({BASE}) SELECT multiIf(f.allocation_method = 'unallocated', 'sin atribuir', f.allocation_method = 'kubernetes_overhead', concat('overhead k8s ', f.cluster), f.allocation_method = 'cluster_pending_consumption', concat('cluster pendiente ', f.cluster), f.bucket != '', concat('bucket ', f.bucket), f.allocation_method) AS kind, f.cloud_service AS cloudService, round(sum(f.cost_usd), 2) AS usd FROM f WHERE f.application_id = '' GROUP BY kind, cloudService ORDER BY usd DESC LIMIT 100 FORMAT JSON" +ENUM_ACC="SELECT toString(account_id) AS id, account_name AS name FROM core_entities_account FINAL WHERE _deleted = 0 AND status = 'active' ORDER BY name FORMAT JSON" +ENUM_APP="SELECT toString(a.app_id) AS id, a.application_slug AS name FROM core_entities_application AS a FINAL JOIN core_entities_namespace AS n FINAL ON n.namespace_id = a.namespace_id AND n._deleted = 0 WHERE a._deleted = 0 AND a.status = 'active' AND ({accountId:String} = '' OR toString(n.account_id) = {accountId:String}) ORDER BY name FORMAT JSON" +ENUM_SVC=f"WITH f AS ({BASE}) SELECT f.service_id AS id, anyLast(f.service_name) AS name FROM f WHERE f.charge_type = 'service' AND f.service_id != '' GROUP BY id ORDER BY name FORMAT JSON" +ENUM_ST=f"WITH f AS ({BASE}) SELECT DISTINCT f.scope_type AS id, f.scope_type AS name FROM f WHERE f.scope_type != '' ORDER BY name FORMAT JSON" +ENUM_ENV="SELECT dv.slug AS id, coalesce(dv.name, dv.slug) AS name FROM core_entities_runtime_configuration_dimension_value AS dv FINAL JOIN core_entities_runtime_configuration_dimension AS d FINAL ON dv.dimension_id = d.id AND d._deleted = 0 AND d.status = 'active' WHERE dv._deleted = 0 AND dv.status = 'active' AND d.slug = 'environment' ORDER BY name FORMAT JSON" +num=lambda: {"type":"number"}; st=lambda: {"type":"string"} +def arr(props): return {"type":"array","items":{"type":"object","properties":props}} +schema={"type":"object","properties":{ + "startDate":{"type":"string","format":"date-time","default":""},"endDate":{"type":"string","format":"date-time","default":""}, + "accountId":{"type":"string","default":""},"applicationId":{"type":"string","default":""},"environment":{"type":"string","default":""}, + "chargeType":{"type":"string","oneOf":[{"const":"","title":"Todos"},{"const":"scope","title":"Scope"},{"const":"service","title":"Servicio"},{"const":"application","title":"Aplicación"}],"default":""}, + "serviceId":{"type":"string","default":""},"scopeType":{"type":"string","default":""}, + "totalUsd":{"type":"number","title":"Costo total (USD)"},"appsUsd":{"type":"number","title":"Atribuido a aplicaciones (USD)"},"sharedUsd":{"type":"number","title":"Plataforma compartida (USD)"},"unallocatedUsd":{"type":"number","title":"Sin atribuir (USD)"},"attributedPct":{"type":"number","title":"% atribuido a apps"}, + "trend":arr({"day":st(),"scopes":num(),"services":num(),"applications":num(),"shared":num(),"unallocated":num()}), + "byApp":arr({"app":st(),"usd":num()}),"byEnvironment":arr({"label":st(),"value":num()}),"byCloudService":arr({"label":st(),"value":num()}),"byCategory":arr({"label":st(),"value":num()}), + "appsTable":arr({"app":st(),"namespace":st(),"account":st(),"total":num(),"scopes":num(),"services":num(),"applications":num(),"production":num(),"nonProduction":num(),"days":num(),"perDay":num()}), + "objectsTable":arr({"day":st(),"app":st(),"chargeType":st(),"object":st(),"scopeType":st(),"environment":st(),"category":st(),"cloudService":st(),"method":st(),"rule":st(),"usd":num()}), + "sharedTable":arr({"kind":st(),"cloudService":st(),"usd":num()})}} +kpi=lambda s,unit="USD",prec=2: {"type":"Control","scope":"#/properties/"+s,"options":{"widget":"kpi","showBackground":true,"unit":unit,"precision":prec}} +true=True +ui={"type":"VerticalLayout","elements":[ + {"type":"HorizontalLayout","elements":[ + {"type":"Control","scope":"#/properties/startDate","label":"Período","options":{"format":"date-range","endDateScope":"#/properties/endDate","initialPreset":"last30Days","allowedRanges":["yesterday","last7Days","last30Days","thisMonth","lastMonth"],"disableFuture":true}}, + {"type":"Control","scope":"#/properties/accountId","label":"Cuenta"},{"type":"Control","scope":"#/properties/applicationId","label":"Aplicación"}, + {"type":"Control","scope":"#/properties/environment","label":"Environment"},{"type":"Control","scope":"#/properties/chargeType","label":"Tipo de cargo"}]}, + {"type":"HorizontalLayout","elements":[{"type":"Control","scope":"#/properties/serviceId","label":"Servicio (null)"},{"type":"Control","scope":"#/properties/scopeType","label":"Tipo de scope"}]}, + {"type":"Label","text":"##### Resumen\nTotal = aplicaciones + plataforma compartida (reglas a buckets: seguridad, observabilidad) + sin atribuir (incluye la capacidad del cluster que ningún scope consumió). Total, compartido, overhead y sin atribuir son de toda la organización en el período; el resto de los widgets responde a los filtros de cuenta, aplicación, environment, tipo de cargo, servicio null y tipo de scope (por ejemplo `lambda` para ver solo las funciones).","options":{"format":"markdown"}}, + {"type":"HorizontalLayout","options":{"columns":[2.4,2.4,2.4,2.4,2.4]},"elements":[kpi("totalUsd"),kpi("appsUsd"),kpi("sharedUsd"),kpi("unallocatedUsd"), + {"type":"Control","scope":"#/properties/attributedPct","options":{"widget":"kpi","showBackground":true,"unit":"%","precision":1,"thresholds":[{"value":80,"color":"success"},{"value":50,"color":"warning"},{"value":0,"color":"error"}]}}]}, + {"type":"Label","text":"##### Evolución diaria\nCosto por día según por dónde llegó a la aplicación: **scopes** (cualquier tipo, k8s o lambda), **servicios** null, o **aplicación** (recursos no modelados en null pero atribuidos por regla). *Compartido* = overhead del cluster y buckets de plataforma.","options":{"format":"markdown"}}, + {"type":"Control","scope":"#/properties/trend","label":"Costo diario por tipo de cargo","options":{"widget":"bar-chart","showBackground":true,"categoryKey":"day","series":[{"dataKey":"scopes","name":"Scopes"},{"dataKey":"services","name":"Servicios"},{"dataKey":"applications","name":"Aplicación"},{"dataKey":"shared","name":"Compartido"},{"dataKey":"unallocated","name":"Sin atribuir"}],"xAxisLabel":"Día","yAxisLabel":"USD","height":320,"stacked":true,"borderRadius":4,"showLegend":true,"colors":["#3b82f6","#8b5cf6","#f59e0b","#94a3b8","#ef4444"]}}, + {"type":"Label","text":"##### Por aplicación y por dimensión","options":{"format":"markdown"}}, + {"type":"HorizontalLayout","options":{"columns":[8,4]},"elements":[ + {"type":"Control","scope":"#/properties/byApp","label":"Top aplicaciones","options":{"widget":"bar-chart","showBackground":true,"categoryKey":"app","series":[{"dataKey":"usd","name":"USD"}],"xAxisLabel":"Aplicación","yAxisLabel":"USD","height":320,"borderRadius":4,"colors":["#3b82f6"]}}, + {"type":"Control","scope":"#/properties/byEnvironment","label":"Por environment","options":{"widget":"donut-chart","showBackground":true,"labelKey":"label","valueKey":"value","donutSize":"55%","showTotal":true,"totalLabel":"USD","height":320}}]}, + {"type":"HorizontalLayout","options":{"columns":[6,6]},"elements":[ + {"type":"Control","scope":"#/properties/byCloudService","label":"Por servicio de nube","options":{"widget":"donut-chart","showBackground":true,"labelKey":"label","valueKey":"value","donutSize":"55%","showTotal":true,"totalLabel":"USD","height":320}}, + {"type":"Control","scope":"#/properties/byCategory","label":"Por categoría","options":{"widget":"donut-chart","showBackground":true,"labelKey":"label","valueKey":"value","donutSize":"55%","showTotal":true,"totalLabel":"USD","height":320}}]}, + {"type":"Label","text":"##### Detalle por aplicación\nUna fila por aplicación con el desglose por tipo de cargo y por environment.","options":{"format":"markdown"}}, + {"type":"Control","scope":"#/properties/appsTable","label":"Aplicaciones","options":{"widget":"data-table","features":["sorting","pagination"],"pagination":{"pageSize":25,"pageSizeOptions":[10,25,50]},"emptyState":{"title":"Sin costos atribuidos","description":"Probá ampliar el período o quitar filtros."},"columns":[ + {"id":"app","header":"Aplicación","accessor":"app","fixed":{"position":"left"}},{"id":"namespace","header":"Namespace","accessor":"namespace"},{"id":"account","header":"Cuenta","accessor":"account"}, + {"id":"total","header":"Total (USD)","accessor":"total"},{"id":"scopes","header":"Scopes (USD)","accessor":"scopes"},{"id":"services","header":"Servicios (USD)","accessor":"services"},{"id":"applications","header":"Aplicación (USD)","accessor":"applications"}, + {"id":"production","header":"Production (USD)","accessor":"production"},{"id":"nonProduction","header":"No production (USD)","accessor":"nonProduction"},{"id":"days","header":"Días","accessor":"days"},{"id":"perDay","header":"USD/día","accessor":"perDay"}]}}, + {"type":"Label","text":"##### Detalle por scope y servicio\nLos ítems de la factura de cada aplicación: qué objeto null (scope o servicio) generó el cargo, con su environment, categoría, servicio de nube y la regla que lo atribuyó.","options":{"format":"markdown"}}, + {"type":"Control","scope":"#/properties/objectsTable","label":"Ítems","options":{"widget":"data-table","features":["sorting","pagination"],"pagination":{"pageSize":25,"pageSizeOptions":[25,50,100,250]},"emptyState":{"title":"Sin ítems","description":"Probá ampliar el período o quitar filtros."},"columns":[ + {"id":"day","header":"Día","accessor":"day"},{"id":"app","header":"Aplicación","accessor":"app","fixed":{"position":"left"}},{"id":"chargeType","header":"Tipo","accessor":"chargeType","formatter":{"type":"chip","config":{"size":"small","color":"info"}}},{"id":"object","header":"Scope / servicio","accessor":"object"},{"id":"scopeType","header":"Tipo de scope","accessor":"scopeType"}, + {"id":"environment","header":"Environment","accessor":"environment","formatter":{"type":"chip","config":{"size":"small"}}},{"id":"category","header":"Categoría","accessor":"category"},{"id":"cloudService","header":"Servicio de nube","accessor":"cloudService"},{"id":"method","header":"Método","accessor":"method"},{"id":"rule","header":"Regla","accessor":"rule","formatter":{"type":"text","typography":{"maxLines":1}}},{"id":"usd","header":"USD","accessor":"usd"}]}}, + {"type":"Label","text":"##### Compartido y sin atribuir\nLo que no llegó a ninguna aplicación (toda la organización): overhead del cluster, buckets de plataforma y servicios de nube sin regla. Es la lista de trabajo para nuevas reglas de mapeo.","options":{"format":"markdown"}}, + {"type":"Control","scope":"#/properties/sharedTable","label":"Compartido / sin atribuir","options":{"widget":"data-table","features":["sorting","pagination"],"pagination":{"pageSize":10,"pageSizeOptions":[10,25,50]},"emptyState":{"title":"Todo atribuido","description":"No hay costo compartido ni sin atribuir en el período."},"columns":[ + {"id":"kind","header":"Tipo","accessor":"kind","fixed":{"position":"left"}},{"id":"cloudService","header":"Servicio de nube","accessor":"cloudService"},{"id":"usd","header":"USD","accessor":"usd"}]}} +]} +queries={ + "enum-accounts":{"source":ENUM_ACC,"target":"#/properties/accountId","mapping":"enum"}, + "enum-applications":{"source":ENUM_APP,"params":{"accountId":{"scope":"#/properties/accountId"}},"target":"#/properties/applicationId","mapping":"enum"}, + "enum-environments":{"source":ENUM_ENV,"target":"#/properties/environment","mapping":"enum"}, + "enum-services":{"source":ENUM_SVC,"params":P_DATE,"target":"#/properties/serviceId","mapping":"enum"}, + "enum-scope-types":{"source":ENUM_ST,"params":P_DATE,"target":"#/properties/scopeType","mapping":"enum"}, + "kpi-total":{"source":TOTAL,"params":P_DATE,"target":"#/properties/totalUsd"}, + "kpi-shared":{"source":TOTAL,"params":P_DATE,"target":"#/properties/sharedUsd"}, + "kpi-unallocated":{"source":TOTAL,"params":P_DATE,"target":"#/properties/unallocatedUsd"}, + "kpi-attributed-pct":{"source":TOTAL,"params":P_DATE,"target":"#/properties/attributedPct"}, + "kpi-apps":{"source":APPS,"params":P_ALL,"target":"#/properties/appsUsd"}, + "trend":{"source":TREND,"params":P_DATE,"target":"#/properties/trend"}, + "by-app":{"source":BYAPP,"params":P_ALL,"target":"#/properties/byApp"}, + "by-environment":{"source":BYENV,"params":P_ALL,"target":"#/properties/byEnvironment"}, + "by-cloud-service":{"source":BYSVC,"params":P_ALL,"target":"#/properties/byCloudService"}, + "by-category":{"source":BYCAT,"params":P_ALL,"target":"#/properties/byCategory"}, + "apps-table":{"source":APPTABLE,"params":P_ALL,"target":"#/properties/appsTable"}, + "objects-table":{"source":OBJTABLE,"params":P_ALL,"target":"#/properties/objectsTable"}, + "shared-table":{"source":SHARED,"params":P_DATE,"target":"#/properties/sharedTable"}} +# ── Cost explorer: one grouping selector drives the daily stacked chart, the breakdown donut and the +# group table. Charts need FIXED series, so the daily chart of each grouping pivots the top-N values +# of the last 30 days (read from the Lake at generation time; regenerate when the top changes) + otros. +import os, subprocess +GROUPINGS=[("application","Aplicación","coalesce(nullIf(a.application_slug, ''), f.application_slug, f.application_id)"), + ("cloud_service","Servicio de nube","f.cloud_service"),("category","Categoría","if(f.category = '', 'other', f.category)"), + ("environment","Environment","if(f.environment = '', 'sin dimensión', f.environment)"),("charge_type","Tipo de cargo","f.charge_type"), + ("scope_type","Tipo de scope","if(f.scope_type = '', '-', f.scope_type)"),("namespace","Namespace","coalesce(nullIf(n.namespace_name, ''), 'sin namespace')"), + ("allocation_method","Método de atribución","f.allocation_method"),("rule","Regla","if(f.rule_id = '', '-', f.rule_id)"), + ("scope","Scope","if(f.scope_name = '', '-', concat(coalesce(nullIf(a.application_slug, ''), f.application_slug, f.application_id), ' / ', f.scope_name))")] +GEXPR="multiIf(" + ", ".join("{groupBy:String} = '%s', %s" % (k, e) for k,_,e in GROUPINGS) + ", " + GROUPINGS[0][2] + ")" +P_EXP=dict(P_ALL, groupBy={"scope":"#/properties/groupBy"}) +GTOTAL="(SELECT sum(cost_usd) FROM (SELECT f.cost_usd AS cost_usd FROM f " + APPJ + " WHERE " + APPW + "))" +GTABLE="WITH f AS (%s) SELECT %s AS grupo, round(sum(f.cost_usd), 2) AS usd, round(sum(f.cost_usd) * 100.0 / greatest(%s, 0.000001), 1) AS pct, uniqExact(f.day) AS days, round(sum(f.cost_usd) / greatest(uniqExact(f.day), 1), 2) AS perDay, uniqExact(f.application_id) AS apps FROM f %s WHERE %s GROUP BY grupo ORDER BY usd DESC LIMIT 100 FORMAT JSON" % (BASE, GEXPR, GTOTAL, APPJ, APPW) +GDONUT="WITH f AS (%s) SELECT %s AS label, round(sum(f.cost_usd), 2) AS value FROM f %s WHERE %s GROUP BY label ORDER BY value DESC LIMIT 12 FORMAT JSON" % (BASE, GEXPR, APPJ, APPW) +def top_values(expr): + if ARGS.no_lake or not (os.environ.get('NP_TOKEN') or os.environ.get('NP_API_KEY')): return None + CH=os.path.expanduser('~/.claude/plugins/cache/nullplatform-internal/np-governance/1.3.1/skills/np-lake/scripts/ch_query.sh') + sql="WITH f AS (%s) SELECT %s AS g, sum(f.cost_usd) AS usd FROM f %s WHERE f.application_id != '' GROUP BY g ORDER BY usd DESC LIMIT %d" % (BASE, expr, APPJ, ARGS.top) + try: + out=subprocess.run([CH,'--format','tsv','--param','startDate=','--param','endDate=',sql],capture_output=True,text=True,timeout=120).stdout.strip().split('\n') + vals=[l.split('\t')[0] for l in out[1:] if l.strip() and 'Exception' not in l and 'Querying' not in l] + return [v for v in vals if v] or None + except Exception: return None +def sq(v): return v.replace("'", "''") +DAILY_CHARTS=[]; DAILY_TABS=[] +PALETTE=["#3b82f6","#f59e0b","#10b981","#ef4444","#8b5cf6","#06b6d4","#f97316","#84cc16","#ec4899","#64748b","#a3a3a3"] +for key,label,expr in GROUPINGS: + if key in ('scope','rule'): continue + vals=top_values(expr) or ["%s-%d" % (key, i+1) for i in range(ARGS.top)] + cols=", ".join("round(sumIf(f.cost_usd, %s = '%s'), 2) AS `%s`" % (expr, sq(v), v.replace('`','')) for v in vals) + inlist=", ".join("'%s'" % sq(v) for v in vals) + q="WITH f AS (%s) SELECT f.day AS day, %s, round(sumIf(f.cost_usd, %s NOT IN (%s)), 2) AS otros FROM f %s WHERE %s GROUP BY day ORDER BY day FORMAT JSON" % (BASE, cols, expr, inlist, APPJ, APPW) + prop="dailyBy"+"".join(w.capitalize() for w in key.split("_")) + pr={"day":st()}; pr.update({v.replace('`',''):num() for v in vals}); pr["otros"]=num() + schema["properties"][prop]=arr(pr) + queries["daily-by-"+key.replace("_","-")]={"source":q,"params":P_ALL,"target":"#/properties/"+prop} + # Tabs (Categorization) render the hidden charts with zero width → 1px "lines". A 2-column grid of + # charts instead; the table and the donut follow the "Agrupar por" selector. + DAILY_TABS.append({"type":"Control","scope":"#/properties/"+prop,"label":"Gasto diario por "+label.lower(), + "options":{"widget":"bar-chart","showBackground":true,"categoryKey":"day","series":[{"dataKey":v.replace('`',''),"name":v} for v in vals]+[{"dataKey":"otros","name":"otros"}],"stacked":true,"colors":PALETTE[:len(vals)+1],"xAxisLabel":"Día","yAxisLabel":"USD","height":300,"showLegend":true}}) +DAILY_CHARTS=[{"type":"HorizontalLayout","options":{"columns":[6,6]},"elements":DAILY_TABS[i:i+2]} for i in range(0,len(DAILY_TABS),2)] +schema["properties"].update({ + "groupBy":{"type":"string","oneOf":[{"const":k,"title":l} for k,l,_ in GROUPINGS],"default":"application"}, + "scopeId":{"type":"string","default":""}, + "groupTable":arr({"grupo":st(),"usd":num(),"pct":num(),"days":num(),"perDay":num(),"apps":num()}), + "groupDonut":arr({"label":st(),"value":num()})}) +ENUM_SCOPE="WITH f AS (%s) SELECT f.scope_id AS id, anyLast(concat(f.scope_name, ' (', coalesce(nullIf(f.application_slug, ''), f.application_id), ')')) AS name FROM f WHERE f.charge_type = 'scope' AND f.scope_id != '' GROUP BY id ORDER BY name FORMAT JSON" % BASE +queries.update({"enum-scopes":{"source":ENUM_SCOPE,"params":P_DATE,"target":"#/properties/scopeId","mapping":"enum"}, + "group-table":{"source":GTABLE,"params":P_EXP,"target":"#/properties/groupTable"},"group-donut":{"source":GDONUT,"params":P_EXP,"target":"#/properties/groupDonut"}}) +EXPLORER_UI=[ + {"type":"Label","text":"##### Explorador de costos\nGasto diario según la agrupación elegida (apilado por las principales categorías del período; el resto en *otros*), la participación de cada grupo y su tabla. Todos los filtros de arriba aplican; **Scope** acota a un scope puntual.","options":{"format":"markdown"}}, + {"type":"HorizontalLayout","elements":[{"type":"Control","scope":"#/properties/groupBy","label":"Agrupar por"},{"type":"Control","scope":"#/properties/scopeId","label":"Scope"}]}, +]+DAILY_CHARTS+[ + {"type":"HorizontalLayout","options":{"columns":[7,5]},"elements":[ + {"type":"Control","scope":"#/properties/groupTable","label":"Gasto por grupo","options":{"widget":"data-table","features":["sorting","pagination"],"pagination":{"pageSize":15,"pageSizeOptions":[15,50,100]},"emptyState":{"title":"Sin costos","description":"No hay costos atribuidos para estos filtros."},"columns":[ + {"id":"grupo","header":"Grupo","accessor":"grupo","fixed":{"position":"left"}},{"id":"usd","header":"USD","accessor":"usd"},{"id":"pct","header":"% del total","accessor":"pct"},{"id":"perDay","header":"USD/día","accessor":"perDay"},{"id":"days","header":"Días","accessor":"days"},{"id":"apps","header":"Apps","accessor":"apps"}]}}, + {"type":"Control","scope":"#/properties/groupDonut","label":"Participación","options":{"widget":"donut-chart","showBackground":true,"labelKey":"label","valueKey":"value","donutSize":"55%","showTotal":true,"totalLabel":"USD","height":360}}]}] +_i=[i for i,e in enumerate(ui["elements"]) if e.get("type")=="Label" and str(e.get("text","")).startswith("##### Evolución diaria")][0] +ui["elements"][_i:_i]=EXPLORER_UI + +# ── Kubernetes usage per scope (scope_usage_daily): the right-sizing view next to the cost ── +if ARGS.usage_spec_id: + UI_=f"SELECT id, argMax(data, _version) AS data FROM catalog_entities WHERE entity_specification_id = '{ARGS.usage_spec_id}' GROUP BY id HAVING argMax(_deleted, _version) = 0" + UBASE=f"""SELECT {J % 'day'} AS day, {J % 'scope_id'} AS scope_id, {J % 'scope_name'} AS scope_name, {J % 'scope_type'} AS scope_type, {J % 'application_id'} AS application_id, {J % 'application_slug'} AS application_slug, {J % 'environment'} AS environment, {J % 'cluster'} AS cluster, {J % 'source'} AS source, {F % 'core_h_used'} AS core_h_used, {F % 'core_h_requested'} AS core_h_requested, {F % 'core_h_chargeable'} AS core_h_chargeable, {F % 'gb_h_used'} AS gb_h_used, {F % 'gb_h_requested'} AS gb_h_requested, {F % 'gb_h_chargeable'} AS gb_h_chargeable, {F % 'cpu_waste_core_h'} AS cpu_waste_core_h, {F % 'mem_waste_gb_h'} AS mem_waste_gb_h, {F % 'pods_avg'} AS pods_avg, {J % 'collected_at'} AS collected_at FROM ({UI_}) AS c WHERE {DATE} QUALIFY collected_at = max(collected_at) OVER (PARTITION BY day, scope_id)""" + UW="({accountId:String} = '' OR toString(n.account_id) = {accountId:String}) AND ({applicationId:String} = '' OR u.application_id = {applicationId:String}) AND ({environment:String} = '' OR u.environment = {environment:String}) AND ({scopeType:String} = '' OR u.scope_type = {scopeType:String})" + UJ="LEFT JOIN core_entities_application AS a FINAL ON toString(a.app_id) = u.application_id AND a._deleted = 0 LEFT JOIN core_entities_namespace AS n FINAL ON n.namespace_id = a.namespace_id AND n._deleted = 0" + P_USAGE={"startDate":{"scope":"#/properties/startDate"},"endDate":{"scope":"#/properties/endDate"},"accountId":{"scope":"#/properties/accountId"},"applicationId":{"scope":"#/properties/applicationId"},"environment":{"scope":"#/properties/environment"},"scopeType":{"scope":"#/properties/scopeType"}} + UKPI=f"WITH u AS ({UBASE}) SELECT round(sum(u.core_h_used) * 100.0 / greatest(sum(u.core_h_requested), 0.000001), 1) AS cpuUtilPct, round(sum(u.gb_h_used) * 100.0 / greatest(sum(u.gb_h_requested), 0.000001), 1) AS memUtilPct, round(sum(u.cpu_waste_core_h), 1) AS cpuWasteCoreH, round(sum(u.mem_waste_gb_h), 1) AS memWasteGbH, uniqExact(u.scope_id) AS scopesWithUsage FROM u {UJ} WHERE {UW} FORMAT JSON" + UTREND=f"WITH u AS ({UBASE}) SELECT u.day AS day, round(sum(u.core_h_used), 1) AS coreHUsed, round(sum(u.core_h_requested), 1) AS coreHRequested, round(sum(u.gb_h_used), 1) AS gbHUsed, round(sum(u.gb_h_requested), 1) AS gbHRequested FROM u {UJ} WHERE {UW} GROUP BY day ORDER BY day FORMAT JSON" + UTABLE=f"WITH u AS ({UBASE}) SELECT coalesce(nullIf(a.application_slug, ''), u.application_slug, u.application_id) AS app, u.scope_name AS scope, u.scope_type AS scopeType, if(u.environment = '', '-', u.environment) AS environment, u.cluster AS cluster, uniqExact(u.day) AS days, round(avg(u.pods_avg), 1) AS pods, round(sum(u.core_h_used) / greatest(uniqExact(u.day), 1), 2) AS coreHUsedPerDay, round(sum(u.core_h_requested) / greatest(uniqExact(u.day), 1), 2) AS coreHRequestedPerDay, round(sum(u.core_h_used) * 100.0 / greatest(sum(u.core_h_requested), 0.000001), 1) AS cpuUtilPct, round(sum(u.gb_h_used) / greatest(uniqExact(u.day), 1), 2) AS gbHUsedPerDay, round(sum(u.gb_h_requested) / greatest(uniqExact(u.day), 1), 2) AS gbHRequestedPerDay, round(sum(u.gb_h_used) * 100.0 / greatest(sum(u.gb_h_requested), 0.000001), 1) AS memUtilPct, round(sum(u.cpu_waste_core_h), 1) AS cpuWasteCoreH, round(sum(u.mem_waste_gb_h), 1) AS memWasteGbH FROM u {UJ} WHERE {UW} GROUP BY app, scope, scopeType, environment, cluster ORDER BY cpuWasteCoreH DESC LIMIT 500 FORMAT JSON" + schema["properties"].update({ + "cpuUtilPct":{"type":"number","title":"Utilización CPU (usado/pedido)"},"memUtilPct":{"type":"number","title":"Utilización memoria (usado/pedido)"}, + "cpuWasteCoreH":{"type":"number","title":"CPU pedida y no usada (core-h)"},"memWasteGbH":{"type":"number","title":"Memoria pedida y no usada (GiB-h)"},"scopesWithUsage":{"type":"number","title":"Scopes con consumo"}, + "usageTrend":arr({"day":st(),"coreHUsed":num(),"coreHRequested":num(),"gbHUsed":num(),"gbHRequested":num()}), + "usageTable":arr({"app":st(),"scope":st(),"scopeType":st(),"environment":st(),"cluster":st(),"days":num(),"pods":num(),"coreHUsedPerDay":num(),"coreHRequestedPerDay":num(),"cpuUtilPct":num(),"gbHUsedPerDay":num(),"gbHRequestedPerDay":num(),"memUtilPct":num(),"cpuWasteCoreH":num(),"memWasteGbH":num()})}) + ui["elements"] += [ + {"type":"Label","text":"##### Uso de Kubernetes por scope (right-sizing)\nConsumo real vs pedido (requests) de cada scope en el cluster, la base con la que se reparte el costo del cluster. **Utilización** = usado / pedido; lo pedido y no usado es capacidad que la aplicación bloquea en los nodos sin usarla. Filtros: período, cuenta, aplicación, environment y tipo de scope.","options":{"format":"markdown"}}, + {"type":"HorizontalLayout","options":{"columns":[2.4,2.4,2.4,2.4,2.4]},"elements":[ + {"type":"Control","scope":"#/properties/cpuUtilPct","options":{"widget":"kpi","showBackground":true,"unit":"%","precision":1,"thresholds":[{"value":60,"color":"success"},{"value":30,"color":"warning"},{"value":0,"color":"error"}]}}, + {"type":"Control","scope":"#/properties/memUtilPct","options":{"widget":"kpi","showBackground":true,"unit":"%","precision":1,"thresholds":[{"value":60,"color":"success"},{"value":30,"color":"warning"},{"value":0,"color":"error"}]}}, + kpi("cpuWasteCoreH","core-h",1),kpi("memWasteGbH","GiB-h",1),kpi("scopesWithUsage","",0)]}, + {"type":"Control","scope":"#/properties/usageTrend","label":"CPU y memoria: usado vs pedido por día","options":{"widget":"bar-chart","showBackground":true,"categoryKey":"day","series":[{"dataKey":"coreHUsed","name":"CPU usada (core-h)"},{"dataKey":"coreHRequested","name":"CPU pedida (core-h)"},{"dataKey":"gbHUsed","name":"Memoria usada (GiB-h)"},{"dataKey":"gbHRequested","name":"Memoria pedida (GiB-h)"}],"xAxisLabel":"Día","yAxisLabel":"core-h / GiB-h","height":300,"borderRadius":4}}, + {"type":"Control","scope":"#/properties/usageTable","label":"Scopes: uso vs pedido","options":{"widget":"data-table","features":["sorting","pagination"],"pagination":{"pageSize":25,"pageSizeOptions":[10,25,50,100]},"emptyState":{"title":"Sin datos de uso","description":"Todavía no hay filas de scope_usage_daily para el período."},"columns":[ + {"id":"app","header":"Aplicación","accessor":"app","fixed":{"position":"left"}},{"id":"scope","header":"Scope","accessor":"scope"},{"id":"scopeType","header":"Tipo","accessor":"scopeType"},{"id":"environment","header":"Environment","accessor":"environment","formatter":{"type":"chip","config":{"size":"small"}}},{"id":"cluster","header":"Cluster","accessor":"cluster"}, + {"id":"days","header":"Días","accessor":"days"},{"id":"pods","header":"Pods (prom.)","accessor":"pods"}, + {"id":"coreHUsedPerDay","header":"CPU usada (core-h/día)","accessor":"coreHUsedPerDay"},{"id":"coreHRequestedPerDay","header":"CPU pedida (core-h/día)","accessor":"coreHRequestedPerDay"},{"id":"cpuUtilPct","header":"Util. CPU %","accessor":"cpuUtilPct"}, + {"id":"gbHUsedPerDay","header":"Mem usada (GiB-h/día)","accessor":"gbHUsedPerDay"},{"id":"gbHRequestedPerDay","header":"Mem pedida (GiB-h/día)","accessor":"gbHRequestedPerDay"},{"id":"memUtilPct","header":"Util. mem %","accessor":"memUtilPct"}, + {"id":"cpuWasteCoreH","header":"CPU no usada (core-h)","accessor":"cpuWasteCoreH"},{"id":"memWasteGbH","header":"Mem no usada (GiB-h)","accessor":"memWasteGbH"}]}}] + queries.update({ + "usage-kpi-cpu":{"source":UKPI,"params":P_USAGE,"target":"#/properties/cpuUtilPct"},"usage-kpi-mem":{"source":UKPI,"params":P_USAGE,"target":"#/properties/memUtilPct"}, + "usage-kpi-cpu-waste":{"source":UKPI,"params":P_USAGE,"target":"#/properties/cpuWasteCoreH"},"usage-kpi-mem-waste":{"source":UKPI,"params":P_USAGE,"target":"#/properties/memWasteGbH"},"usage-kpi-scopes":{"source":UKPI,"params":P_USAGE,"target":"#/properties/scopesWithUsage"}, + "usage-trend":{"source":UTREND,"params":P_USAGE,"target":"#/properties/usageTrend"},"usage-table":{"source":UTABLE,"params":P_USAGE,"target":"#/properties/usageTable"}}) +report={"name":"FinOps — Costos por aplicación","slug":"finops-costos-por-aplicacion","description":"Costo diario de la nube atribuido a cada aplicación null: por scope, servicio y aplicación, con dimensiones (environment), cuenta, categoría y servicio de nube. Fuente: catálogo cost_daily (hechos alocados) en el Lake.","schema":schema,"ui_schema":ui,"queries":queries,"visibility":"user","category_id":None,"nrn_level":"organization"} +json.dump(report,open(ARGS.out,'w'),ensure_ascii=False,indent=1) +# binding check +props=schema['properties'] +for k,q in queries.items(): + t=q['target'].split('/')[-1]; assert t in props, (k,t) +for el in json.dumps(ui).split('"scope": "#/properties/')[1:]: + p=el.split('"')[0]; assert p in props, p +print("report written to", ARGS.out, "queries:", len(queries)) diff --git a/finops/setup/rules.itti.json b/finops/setup/rules.itti.json new file mode 100644 index 0000000..6eeb19a --- /dev/null +++ b/finops/setup/rules.itti.json @@ -0,0 +1,395 @@ +[ + { + "id": "itti-cloudwatch-logs-by-log-group", + "name": "CloudWatch Logs ingestion/storage → application of the log group", + "enabled": true, + "status": "active", + "priority": 100, + "source": "seed", + "confidence": 0.95, + "description": "Log groups follow .[.http_agg|.sys_agg]; wf1 attaches IncomingBytes (ingestion) / storedBytes (storage) shares per group and the owner it resolved from the null applications (metric_owners). Groups nobody owns (/aws/eks/…, /aws/lambda/…, RDSOSMetrics) stay visibly unallocated with their metric_key.", + "scope": { + "cloud_service": { + "regex": "CloudWatch" + }, + "usage_type": { + "regex": "DataProcessing-Bytes|VendedLog-Bytes|TimedStorage-ByteHrs" + } + }, + "match": [ + { + "field": "metric_shares", + "exists": true + } + ], + "method": "by_metric", + "category": "observability", + "target": { + "map": { + "key": "log_group", + "entries": {} + } + } + }, + { + "id": "itti-cloudwatch-emf-metrics-by-app", + "name": "CloudWatch custom metrics (EMF) → application by *_agg log bytes", + "enabled": true, + "status": "active", + "priority": 110, + "source": "seed", + "confidence": 0.8, + "description": "MetricStorage:AWS/Logs-EMF is the custom metrics the platform derives from the ..http_agg / .sys_agg log groups; each application's share is its *_agg IncomingBytes. Metric stream usage (USE1-CW:MetricStreamUsage) is left to the platform spread until the stream's filter is confirmed to carry only application metrics — flip its usage_type into this rule's scope to attribute it the same way.", + "scope": { + "cloud_service": { + "regex": "CloudWatch" + }, + "usage_type": { + "regex": "MetricMonitorUsage" + } + }, + "match": [ + { + "field": "metric", + "equals": "cloudwatch.EmfBytes" + } + ], + "method": "by_metric", + "category": "observability", + "target": { + "map": { + "key": "application", + "entries": {} + } + } + }, + { + "id": "itti-security-compliance-platform-spread", + "name": "Security & compliance services → every application (platform cost)", + "enabled": true, + "status": "active", + "priority": 900, + "source": "seed", + "confidence": 1, + "description": "AWS Config, GuardDuty, KMS, Inspector, Secrets Manager, Macie: nobody owns them, every application of the account shares them in proportion to the cost it already carries.", + "scope": { + "cloud_service": { + "regex": "AWS Config|GuardDuty|Key Management|Inspector|Secrets Manager|Macie" + } + }, + "method": "spread", + "category": "platform", + "target": { + "spread": { + "weights": "attributed" + } + } + }, + { + "id": "itti-cloudwatch-remainder-platform-spread", + "name": "CloudWatch not attributable by log group → every application (platform cost)", + "enabled": true, + "status": "active", + "priority": 910, + "source": "seed", + "confidence": 1, + "description": "Metrics, alarms, dashboards and the log groups nobody owns. Log ingestion/storage buckets carry metric_shares by log group and are handled by the by_metric rule first (lower priority number wins).", + "scope": { + "cloud_service": { + "regex": "CloudWatch" + } + }, + "method": "spread", + "category": "observability", + "target": { + "spread": { + "weights": "attributed" + } + } + }, + { + "id": "itti-network-platform-spread", + "name": "VPC / networking → every application (platform cost)", + "enabled": true, + "status": "active", + "priority": 920, + "source": "seed", + "confidence": 1, + "description": "VPC endpoints, public IPs, NAT: shared network plumbing of the account.", + "scope": { + "cloud_service": { + "regex": "Virtual Private Cloud" + } + }, + "method": "spread", + "category": "network", + "target": { + "spread": { + "weights": "attributed" + } + } + }, + { + "id": "itti-aurora-sdlc-by-database-load", + "name": "Aurora sdlc-tuti by Performance Insights load per database", + "enabled": true, + "status": "active", + "priority": 90, + "source": "inferred", + "confidence": 0.95, + "description": "Each application's DB_HOST parameter points at this cluster and its DB_NAME says which database is its own; wf1 attaches the PI db.load share per database. rdsadmin (maintenance) → shared-platform.", + "scope": { + "cloud_service": "Amazon Relational Database Service" + }, + "match": [ + { + "field": "host", + "equals": "sdlc-tuti-aurora-pgsql-use1-cluster.cluster-chq6geios9rh.us-east-1.rds.amazonaws.com" + }, + { + "field": "metric", + "equals": "pi.db.load" + } + ], + "method": "by_metric", + "category": "database", + "target": { + "map": { + "key": "db.name", + "entries": { + "conmebol_service_dev": { + "application_id": "1185887166", + "namespace_id": "221971421", + "application_slug": "conmebol-service" + }, + "report_service_dev": { + "application_id": "2083727971", + "namespace_id": "221971421", + "application_slug": "report-service" + }, + "access_ctrl_service_dev": { + "application_id": "1299653508", + "namespace_id": "221971421", + "application_slug": "access-ctrl-service" + }, + "ticket_service_dev": { + "application_id": "1415257472", + "namespace_id": "221971421", + "application_slug": "ticket-service" + }, + "pricing_service_dev": { + "application_id": "1390615930", + "namespace_id": "221971421", + "application_slug": "pricing-service" + }, + "smart_pass_service_dev": { + "application_id": "778444005", + "namespace_id": "221971421", + "application_slug": "smart-pass-service" + }, + "rdsadmin": { + "bucket": "shared-platform" + } + } + } + } + }, + { + "id": "itti-docdb-dev-by-database-load", + "name": "DocumentDB dev-tuti by Performance Insights load per database", + "enabled": true, + "status": "active", + "priority": 90, + "source": "inferred", + "confidence": 0.9, + "description": "Every consumer has DBM_HOST = this cluster; database names follow db (cartdb → cart-service). admin → shared-platform.", + "scope": { + "cloud_service": "Amazon DocumentDB (with MongoDB compatibility)" + }, + "match": [ + { + "field": "host", + "equals": "dev-tuti-null-docdb-main.cluster-chq6geios9rh.us-east-1.docdb.amazonaws.com" + }, + { + "field": "metric", + "equals": "pi.db.load" + } + ], + "method": "by_metric", + "category": "database", + "target": { + "map": { + "key": "db.name", + "entries": { + "cartdb": { + "application_id": "1460411787", + "namespace_id": "221971421", + "application_slug": "cart-service" + }, + "ticketdb": { + "application_id": "1415257472", + "namespace_id": "221971421", + "application_slug": "ticket-service" + }, + "accountdb": { + "application_id": "1423449474", + "namespace_id": "221971421", + "application_slug": "account-service" + }, + "catalogdb": { + "application_id": "1435770245", + "namespace_id": "221971421", + "application_slug": "catalog-service" + }, + "paymentdb": { + "application_id": "54170274", + "namespace_id": "221971421", + "application_slug": "payment-service" + }, + "financialdb": { + "application_id": "1452219785", + "namespace_id": "221971421", + "application_slug": "financial-service" + }, + "accessctrldb": { + "application_id": "1299653508", + "namespace_id": "221971421", + "application_slug": "access-ctrl-service" + }, + "notificationdb": { + "application_id": "1444027783", + "namespace_id": "221971421", + "application_slug": "notification-service" + }, + "subscriptiondb": { + "application_id": "1883577842", + "namespace_id": "221971421", + "application_slug": "user-subscriptions-service" + }, + "businessrulesdb": { + "application_id": "521168424", + "namespace_id": "221971421", + "application_slug": "business-rules-service" + }, + "bulkoperationsdb": { + "application_id": "1444169832", + "namespace_id": "221971421", + "application_slug": "bulk-operations-service" + }, + "admin": { + "bucket": "shared-platform" + } + } + } + } + }, + { + "id": "itti-elasticache-dev-split-consumers", + "name": "ElastiCache dev-tuti split equally among its consumers (REDIS_HOST)", + "enabled": true, + "status": "active", + "priority": 95, + "source": "inferred", + "confidence": 0.7, + "description": "Applications whose REDIS_HOST parameter points at dev-tuti-null-elasticache-main; equal split until a per-client metric exists.", + "scope": { + "cloud_service": "Amazon ElastiCache" + }, + "match": [], + "method": "split", + "category": "database", + "target": { + "split": [ + { + "weight": 1, + "target": { + "application_id": "1444169832", + "namespace_id": "221971421", + "application_slug": "bulk-operations-service" + } + }, + { + "weight": 1, + "target": { + "application_id": "1299653508", + "namespace_id": "221971421", + "application_slug": "access-ctrl-service" + } + }, + { + "weight": 1, + "target": { + "application_id": "1450568", + "namespace_id": "221971421", + "application_slug": "render-service" + } + }, + { + "weight": 1, + "target": { + "application_id": "1472732558", + "namespace_id": "221971421", + "application_slug": "composite-service" + } + }, + { + "weight": 1, + "target": { + "application_id": "1460411787", + "namespace_id": "221971421", + "application_slug": "cart-service" + } + }, + { + "weight": 1, + "target": { + "application_id": "1452219785", + "namespace_id": "221971421", + "application_slug": "financial-service" + } + }, + { + "weight": 1, + "target": { + "application_id": "1431707012", + "namespace_id": "221971421", + "application_slug": "checkout-service" + } + }, + { + "weight": 1, + "target": { + "application_id": "1419320705", + "namespace_id": "221971421", + "application_slug": "partner-membership-service" + } + }, + { + "weight": 1, + "target": { + "application_id": "1415257472", + "namespace_id": "221971421", + "application_slug": "ticket-service" + } + }, + { + "weight": 1, + "target": { + "application_id": "1386487161", + "namespace_id": "221971421", + "application_slug": "bff-service" + } + }, + { + "weight": 1, + "target": { + "application_id": "778444005", + "namespace_id": "221971421", + "application_slug": "smart-pass-service" + } + } + ] + } + } +] \ No newline at end of file diff --git a/finops/setup/rules.nullplatform.json b/finops/setup/rules.nullplatform.json new file mode 100644 index 0000000..ce86b2f --- /dev/null +++ b/finops/setup/rules.nullplatform.json @@ -0,0 +1,532 @@ +[ + { + "id": "security-compliance-platform", + "name": "Security & compliance services \u2192 shared platform bucket", + "enabled": true, + "status": "active", + "priority": 10, + "source": "manual", + "scope": { + "cloud_service": { + "regex": "GuardDuty|Security Hub|Inspector|AWS Config|WAF|Cognito|Secrets Manager|Key Management" + } + }, + "match": [], + "target": { + "bucket": "shared-platform" + }, + "category": "security", + "description": "Account-wide security tooling is platform cost, not attributable to an application." + }, + { + "id": "observability-platform-remainder", + "name": "CloudWatch remainder \u2192 shared platform (until log-group collection lands)", + "enabled": true, + "status": "active", + "priority": 20, + "source": "manual", + "scope": { + "cloud_service": "AmazonCloudWatch", + "subject_type": "cloud_service" + }, + "match": [], + "target": { + "bucket": "shared-platform" + }, + "category": "observability" + }, + { + "id": "rds-shared-apis-by-consumers", + "name": "postgres-shared-apis split among its consumers (parameters)", + "description": "Equal split among the applications whose parameters reference the instance host.", + "enabled": true, + "status": "active", + "priority": 100, + "source": "inferred", + "confidence": 0.6, + "scope": { + "cloud_service": "Amazon Relational Database Service", + "resource_type": "rds:db" + }, + "match": [ + { + "field": "host", + "equals": "postgres-shared-apis.cyxdwo6asfl3.us-east-1.rds.amazonaws.com" + } + ], + "method": "split", + "target": { + "split": [ + { + "weight": 1, + "target": { + "application_id": "897065762", + "application_slug": "migrations-api" + } + }, + { + "weight": 1, + "target": { + "application_id": "1302811219", + "application_slug": "parameters-api" + } + }, + { + "weight": 1, + "target": { + "application_id": "1593752815", + "application_slug": "notifications-center-api" + } + }, + { + "weight": 1, + "target": { + "application_id": "1939881900", + "application_slug": "auth-z-api" + } + }, + { + "weight": 1, + "target": { + "application_id": "2132349945", + "application_slug": "github-dumper" + } + } + ] + }, + "evidence": [ + { + "kind": "parameter", + "detail": "main.notifications-center-api parameter DB_HOSTNAME references postgres-shared-apis.cyxdwo6asfl3.us-east-1.rds.amazonaws.com", + "application_id": "1593752815" + }, + { + "kind": "parameter", + "detail": "main.migrations-api parameter DATABASE_HOST references postgres-shared-apis.cyxdwo6asfl3.us-east-1.rds.amazonaws.com", + "application_id": "897065762" + }, + { + "kind": "parameter", + "detail": "finance.github-dumper parameter DB_HOST references postgres-shared-apis.cyxdwo6asfl3.us-east-1.rds.amazonaws.com", + "application_id": "2132349945" + }, + { + "kind": "parameter", + "detail": "main.auth-z-api parameter DB_HOSTNAME references postgres-shared-apis.cyxdwo6asfl3.us-east-1.rds.amazonaws.com", + "application_id": "1939881900" + }, + { + "kind": "parameter", + "detail": "main.auth-z-api parameter DB_HOSTNAME references postgres-shared-apis.cyxdwo6asfl3.us-east-1.rds.amazonaws.com", + "application_id": "1939881900" + }, + { + "kind": "parameter", + "detail": "main.parameters-api parameter DB_HOSTNAME references postgres-shared-apis.cyxdwo6asfl3.us-east-1.rds.amazonaws.com", + "application_id": "1302811219" + } + ] + }, + { + "id": "rds-approvals-by-database-load", + "name": "postgres-approvals-api-db: split by Performance Insights load per database", + "enabled": true, + "status": "active", + "priority": 90, + "source": "inferred", + "confidence": 0.9, + "description": "The cluster hosts 15 databases. Each day wf1 attaches the DB load share per database (PI db.load.avg by db.name on the writer); this map says which application owns each database (DB name \u2192 app, from parameters + PI users). Databases not in the map stay visibly unallocated.", + "scope": { + "cloud_service": "Amazon Relational Database Service", + "resource_type": "rds:cluster" + }, + "match": [ + { + "field": "host", + "equals": "postgres-approvals-api-db.cluster-cyxdwo6asfl3.us-east-1.rds.amazonaws.com" + }, + { + "field": "metric", + "equals": "pi.db.load" + } + ], + "method": "by_metric", + "target": { + "map": { + "key": "db.name", + "entries": { + "core_entities_api": { + "application_id": "1182532716", + "application_slug": "core-entities-api" + }, + "tracing_api_production": { + "application_id": "518811741", + "application_slug": "tracing-api" + }, + "notifications": { + "application_id": "1593752815", + "application_slug": "notifications-center-api" + }, + "services": { + "application_id": "691537498", + "application_slug": "service-api" + }, + "catalog_entities_api_production": { + "application_id": "2044993572", + "application_slug": "entities-api" + }, + "oltp_database": { + "application_id": "2044993572", + "application_slug": "entities-api" + }, + "approvals": { + "application_id": "1372325109", + "application_slug": "approvals-api" + }, + "temporal": { + "application_id": "918966291", + "application_slug": "workflow-system" + }, + "temporal_visibility": { + "application_id": "918966291", + "application_slug": "workflow-system" + }, + "workflow_system": { + "application_id": "918966291", + "application_slug": "workflow-system" + }, + "params_api": { + "application_id": "1302811219", + "application_slug": "parameters-api" + }, + "users_api": { + "application_id": "1311001341", + "application_slug": "users-api" + }, + "scm_data": { + "application_id": "897065762", + "application_slug": "migrations-api" + }, + "providers": { + "application_id": "278401067", + "application_slug": "providers-api" + }, + "governance_action_items": { + "application_id": "1793933981", + "application_slug": "action-items-api" + } + } + } + }, + "evidence": [ + { + "kind": "performance_insights", + "detail": "2026-09-09 writer db.load.avg by db.name: core_entities_api 28%, tracing_api_production 18%, notifications 15%, services 11%, catalog_entities_api_production 6%, approvals 5%, temporal 5%, params_api 3%, workflow_system 2%, users_api 1.5%, scm_data 1.3%, providers 1.2%, governance_action_items 0.8%, temporal_visibility 0.5%, oltp_database 0.4%" + } + ] + }, + { + "id": "rds-approvals-shared-by-consumers-fallback", + "name": "postgres-approvals-api-db: equal split among consumers (fallback when PI shares are missing)", + "enabled": true, + "status": "active", + "priority": 100, + "source": "inferred", + "confidence": 0.6, + "scope": { + "cloud_service": "Amazon Relational Database Service", + "resource_type": "rds:cluster" + }, + "match": [ + { + "field": "host", + "equals": "postgres-approvals-api-db.cluster-cyxdwo6asfl3.us-east-1.rds.amazonaws.com" + } + ], + "method": "split", + "target": { + "split": [ + { + "weight": 1, + "target": { + "application_id": "1182532716", + "application_slug": "core-entities-api" + } + }, + { + "weight": 1, + "target": { + "application_id": "1302811219", + "application_slug": "parameters-api" + } + }, + { + "weight": 1, + "target": { + "application_id": "1311001341", + "application_slug": "users-api" + } + }, + { + "weight": 1, + "target": { + "application_id": "1372325109", + "application_slug": "approvals-api" + } + }, + { + "weight": 1, + "target": { + "application_id": "1593752815", + "application_slug": "notifications-center-api" + } + }, + { + "weight": 1, + "target": { + "application_id": "1793933981", + "application_slug": "action-items-api" + } + }, + { + "weight": 1, + "target": { + "application_id": "2044993572", + "application_slug": "entities-api" + } + }, + { + "weight": 1, + "target": { + "application_id": "278401067", + "application_slug": "providers-api" + } + }, + { + "weight": 1, + "target": { + "application_id": "518811741", + "application_slug": "tracing-api" + } + }, + { + "weight": 1, + "target": { + "application_id": "691537498", + "application_slug": "service-api" + } + }, + { + "weight": 1, + "target": { + "application_id": "897065762", + "application_slug": "migrations-api" + } + }, + { + "weight": 1, + "target": { + "application_id": "918966291", + "application_slug": "workflow-system" + } + } + ] + } + }, + { + "id": "rds-controlplane-by-database-load", + "name": "postgres-controlplane-db: agents_api \u2192 agents-api, grafana \u2192 grafana (PI load)", + "enabled": true, + "status": "active", + "priority": 90, + "source": "inferred", + "confidence": 0.9, + "scope": { + "cloud_service": "Amazon Relational Database Service", + "resource_type": "rds:cluster" + }, + "match": [ + { + "field": "host", + "equals": "postgres-controlplane-db.cluster-cyxdwo6asfl3.us-east-1.rds.amazonaws.com" + }, + { + "field": "metric", + "equals": "pi.db.load" + } + ], + "method": "by_metric", + "target": { + "map": { + "key": "db.name", + "entries": { + "agents_api": { + "application_id": "1778976088", + "application_slug": "agents-api" + }, + "grafana": { + "application_id": "1841825650", + "application_slug": "grafana" + } + } + } + }, + "evidence": [ + { + "kind": "parameter", + "detail": "agents-api parameter references agents-api.db.nullservices.io (CNAME of the cluster); grafana \u2192 grafana.db.nullservices.io" + }, + { + "kind": "performance_insights", + "detail": "2026-09-09 db.load: agents_api 99%, grafana 1%" + } + ] + }, + { + "id": "rds-auth-db-authz-api", + "name": "postgres-auth-db-1 \u2192 auth-z-api", + "enabled": true, + "status": "active", + "priority": 95, + "source": "inferred", + "confidence": 0.9, + "scope": { + "cloud_service": "Amazon Relational Database Service", + "resource_type": "rds:cluster" + }, + "match": [ + { + "field": "host", + "equals": "postgres-auth-db-1.cluster-cyxdwo6asfl3.us-east-1.rds.amazonaws.com" + } + ], + "method": "direct", + "target": { + "application_id": "1939881900", + "application_slug": "auth-z-api" + }, + "evidence": [ + { + "kind": "parameter", + "detail": "auth-z-api parameters reference the cluster host" + }, + { + "kind": "performance_insights", + "detail": "db authz_api 100%" + } + ] + }, + { + "id": "rds-ai-agent-data-agent-data-api", + "name": "postgres-ai-agent-data \u2192 agent-data-api", + "enabled": true, + "status": "active", + "priority": 95, + "source": "inferred", + "confidence": 0.9, + "scope": { + "cloud_service": "Amazon Relational Database Service", + "resource_type": "rds:cluster" + }, + "match": [ + { + "field": "host", + "equals": "postgres-ai-agent-data.cluster-cyxdwo6asfl3.us-east-1.rds.amazonaws.com" + } + ], + "method": "direct", + "target": { + "application_id": "1646676644", + "application_slug": "agent-data-api" + }, + "evidence": [ + { + "kind": "parameter", + "detail": "agent-data-api parameters reference the cluster host" + }, + { + "kind": "performance_insights", + "detail": "db aiagentdata 93%" + } + ] + }, + { + "id": "rds-sonarqube", + "name": "postgres-sonarqube \u2192 sonarqube", + "enabled": true, + "status": "active", + "priority": 95, + "source": "inferred", + "confidence": 0.9, + "scope": { + "cloud_service": "Amazon Relational Database Service", + "resource_type": "rds:db" + }, + "match": [ + { + "field": "host", + "equals": "postgres-sonarqube.cyxdwo6asfl3.us-east-1.rds.amazonaws.com" + } + ], + "method": "direct", + "target": { + "application_id": "88872477", + "application_slug": "sonarqube" + }, + "evidence": [ + { + "kind": "parameter", + "detail": "sonarqube parameters reference sonarqube.db.nullservices.io" + } + ] + }, + { + "id": "rds-shared-apis-by-database-load", + "name": "postgres-shared-apis: split by PI load per database", + "enabled": true, + "status": "active", + "priority": 90, + "source": "inferred", + "confidence": 0.9, + "scope": { + "cloud_service": "Amazon Relational Database Service", + "resource_type": "rds:db" + }, + "match": [ + { + "field": "host", + "equals": "postgres-shared-apis.cyxdwo6asfl3.us-east-1.rds.amazonaws.com" + }, + { + "field": "metric", + "equals": "pi.db.load" + } + ], + "method": "by_metric", + "target": { + "map": { + "key": "db.name", + "entries": { + "core_entities_api": { + "application_id": "1182532716", + "application_slug": "core-entities-api" + }, + "insights": { + "application_id": "1376526732", + "application_slug": "reports-api" + }, + "params_api": { + "application_id": "1302811219", + "application_slug": "parameters-api" + } + } + } + }, + "evidence": [ + { + "kind": "performance_insights", + "detail": "2026-09-09 db.load: core_entities_api 81%, insights 4%, params_api 3%, rdsadmin 12% (AWS internal \u2192 stays unallocated)" + } + ] + } +] diff --git a/finops/setup/vars.itti.json b/finops/setup/vars.itti.json new file mode 100644 index 0000000..81cd6c0 --- /dev/null +++ b/finops/setup/vars.itti.json @@ -0,0 +1,49 @@ +{ + "finops_aws_billing_daily": { + "agent_tags": { + "stage": "sdlc" + }, + "agent_nrn": "organization=1049493649:account=1041301647", + "org_nrn": "organization=1049493649" + }, + "finops_aws_billing_dispatch": { + "targets": [ + { + "name": "itti-tuti-development", + "agent_tags": { + "stage": "sdlc" + }, + "agent_nrn": "organization=1049493649:account=1041301647", + "org_nrn": "organization=1049493649", + "region": "us-east-1", + "expected_account": "985539773184", + "dimensions": { + "environment": "development" + } + } + ], + "k8s_clusters": [ + { + "cluster": "eks-1-tuti-null-use1-dev" + } + ], + "allocate_in_chain": false + }, + "finops_suggest_mappings": { + "org_nrn": "organization=1049493649", + "account_id": "1041301647" + }, + "finops_k8s_consumption_daily": { + "cluster": "eks-1-tuti-null-use1-dev", + "agent_tags": { + "stage": "sdlc" + }, + "agent_nrn": "organization=1049493649:account=1041301647", + "collector_cmd": "nullplatform/platform-scopes-override/cost/collect_metrics --prom http://prometheus-server.default.svc.cluster.local", + "org_nrn": "organization=1049493649:account=1041301647", + "collector_mode": "newrelic" + }, + "finops_allocate_daily": { + "org_nrn": "organization=1049493649" + } +} \ No newline at end of file diff --git a/finops/setup/vars.nullplatform.json b/finops/setup/vars.nullplatform.json new file mode 100644 index 0000000..8e90f10 --- /dev/null +++ b/finops/setup/vars.nullplatform.json @@ -0,0 +1,46 @@ +{ + "finops_aws_billing_daily": { + "agent_tags": { + "cluster": "runtime" + }, + "agent_nrn": "organization=4:account=17", + "org_nrn": "organization=4" + }, + "finops_aws_billing_dispatch": { + "targets": [ + { + "name": "nullplatform", + "agent_tags": { + "cluster": "runtime" + }, + "agent_nrn": "organization=4:account=17", + "org_nrn": "organization=4", + "region": "us-east-1", + "expected_account": "283477532906" + } + ], + "k8s_clusters": [ + { + "cluster": "runtime" + } + ], + "allocate_in_chain": false + }, + "finops_suggest_mappings": { + "org_nrn": "organization=4", + "account_id": "17" + }, + "finops_k8s_consumption_daily": { + "cluster": "runtime", + "agent_tags": { + "cluster": "runtime" + }, + "agent_nrn": "organization=4:account=17", + "collector_cmd": "nullplatform/platform-scopes-override/cost/collect_metrics --prom http://prometheus-server.default.svc.cluster.local", + "org_nrn": "organization=4", + "collector_mode": "agent" + }, + "finops_allocate_daily": { + "org_nrn": "organization=4" + } +} \ No newline at end of file diff --git a/finops/specs/application_cost_daily.spec.json b/finops/specs/application_cost_daily.spec.json new file mode 100644 index 0000000..13cbdf1 --- /dev/null +++ b/finops/specs/application_cost_daily.spec.json @@ -0,0 +1,170 @@ +{ + "slug": "application_cost_daily", + "name": "Application Cost Daily", + "description": "One invoice per application per day: total USD, charge items (each charged through a null scope, a null service, or the application itself, with category, cloud service and the attributing rule) and totals. Written by the allocator.", + "schema": { + "type": "object", + "required": [ + "id", + "date", + "day", + "application_id", + "total_usd", + "charge_items", + "computed_at" + ], + "properties": { + "id": { + "type": "string", + "description": "-", + "alias": "id", + "primaryKey": true, + "autoGenerate": false + }, + "day": { + "type": "string", + "description": "Same as date; filterable copy", + "index": [ + "filter" + ] + }, + "date": { + "type": "string", + "description": "Invoice day, YYYY-MM-DD (UTC)" + }, + "application_id": { + "type": "string", + "description": "null application id", + "index": [ + "filter" + ] + }, + "application_slug": { + "type": [ + "string", + "null" + ], + "description": "null application slug", + "index": [ + "filter" + ] + }, + "namespace_id": { + "type": [ + "string", + "null" + ], + "description": "null namespace id", + "index": [ + "filter" + ] + }, + "account_id": { + "type": [ + "string", + "null" + ], + "description": "null account id", + "index": [ + "filter" + ] + }, + "total_usd": { + "type": "number", + "description": "Total attributed to the application on the day (amortized)" + }, + "cloud_accounts": { + "type": "array", + "description": "Cloud accounts the cost came from", + "items": { + "type": "string" + } + }, + "currency": { + "type": "string", + "description": "USD" + }, + "allocator": { + "type": "string", + "description": "finops_allocate_daily@" + }, + "computed_at": { + "type": "string", + "description": "ISO timestamp" + }, + "charge_items": { + "type": "array", + "description": "Invoice lines: [{charge_type: scope|service|application, scope_id, scope_name, scope_type, service_id, service_name, dimensions, environment, category, cloud_service, subject_type, subject_id, subject_name, cluster, component, cost_usd, share, allocation_method, rule_id, source_fact_id}] \u2014 every key present (null when it does not apply)", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "totals": { + "type": "object", + "description": "{by_charge_type: {scope, service, application}, by_environment: {production, stage, \u2026, none}, by_category: {\u2026}, by_cloud_service: {\u2026}}", + "additionalProperties": true + }, + "charge_items_count": { + "type": "integer", + "description": "Number of charge items" + } + }, + "authorization": { + "entities": { + "grants": [ + { + "actions": [ + "read", + "list", + "create", + "write", + "delete" + ], + "principals": [ + { + "type": "*" + } + ] + }, + { + "actions": [ + "*" + ], + "principals": [ + { + "id": 732189543, + "type": "user" + } + ] + } + ] + }, + "specification": { + "grants": [ + { + "actions": [ + "read" + ], + "principals": [ + { + "type": "*" + } + ] + }, + { + "actions": [ + "*" + ], + "principals": [ + { + "id": 732189543, + "type": "user" + } + ] + } + ] + } + } + } +} diff --git a/finops/specs/cost_daily.spec.json b/finops/specs/cost_daily.spec.json new file mode 100644 index 0000000..427a231 --- /dev/null +++ b/finops/specs/cost_daily.spec.json @@ -0,0 +1,699 @@ +{ + "name": "Cost Daily", + "slug": "cost_daily", + "description": "One cost fact per subject and day. stage=raw from collectors (cloud billing, k8s), stage=allocated from the allocator. Every fact declares its allocation_method.", + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "primaryKey": true, + "autoGenerate": false, + "description": "---; every part is also a field (logical id, not a UUID; no ':' or '/')" + }, + "date": { + "type": "string", + "description": "Day, YYYY-MM-DD (UTC)", + "index": [ + "filter" + ] + }, + "stage": { + "type": "string", + "enum": [ + "raw", + "allocated" + ], + "index": [ + "filter" + ] + }, + "subject_type": { + "type": "string", + "enum": [ + "cloud_service", + "resource", + "cluster", + "scope", + "service", + "application", + "bucket", + "unallocated" + ], + "index": [ + "filter" + ] + }, + "subject_id": { + "type": "string" + }, + "subject_name": { + "type": [ + "string", + "null" + ] + }, + "nrn": { + "type": [ + "string", + "null" + ] + }, + "application_id": { + "type": [ + "string", + "null" + ], + "index": [ + "filter" + ] + }, + "application_name": { + "type": [ + "string", + "null" + ] + }, + "namespace_id": { + "type": [ + "string", + "null" + ], + "index": [ + "filter" + ] + }, + "namespace_name": { + "type": [ + "string", + "null" + ] + }, + "account_id": { + "type": [ + "string", + "null" + ] + }, + "account_name": { + "type": [ + "string", + "null" + ] + }, + "environment": { + "type": [ + "string", + "null" + ], + "index": [ + "filter" + ] + }, + "scope_id": { + "type": [ + "string", + "null" + ], + "index": [ + "filter" + ] + }, + "scope_name": { + "type": [ + "string", + "null" + ] + }, + "service_id": { + "type": [ + "string", + "null" + ], + "index": [ + "filter" + ] + }, + "service_name": { + "type": [ + "string", + "null" + ] + }, + "cloud": { + "type": "string", + "enum": [ + "aws", + "azure", + "gcp", + "other" + ] + }, + "cloud_account": { + "type": [ + "string", + "null" + ] + }, + "region": { + "type": [ + "string", + "null" + ] + }, + "cloud_service": { + "type": [ + "string", + "null" + ], + "index": [ + "filter" + ] + }, + "usage_type": { + "type": [ + "string", + "null" + ] + }, + "resource_id": { + "type": [ + "string", + "null" + ] + }, + "resource_type": { + "type": [ + "string", + "null" + ] + }, + "cluster": { + "type": [ + "string", + "null" + ], + "index": [ + "filter" + ] + }, + "component": { + "type": [ + "string", + "null" + ], + "description": "Cluster component for cluster breakdown buckets: nodes | control_plane | load_balancers | networking | storage | other" + }, + "cost_usd": { + "type": "number", + "description": "Amortized cost (Savings Plans / RIs spread over covered usage) — the chargeback basis" + }, + "unblended_usd": { + "type": [ + "number", + "null" + ], + "description": "Unblended cost as billed on the day (on-demand equivalents + SP/RI negations)" + }, + "usage_usd": { + "type": [ + "number", + "null" + ] + }, + "waste_usd": { + "type": [ + "number", + "null" + ] + }, + "cpu_req_core_h": { + "type": [ + "number", + "null" + ] + }, + "cpu_used_core_h": { + "type": [ + "number", + "null" + ] + }, + "mem_req_gb_h": { + "type": [ + "number", + "null" + ] + }, + "mem_used_gb_h": { + "type": [ + "number", + "null" + ] + }, + "storage_gb": { + "type": [ + "number", + "null" + ] + }, + "requests_total": { + "type": [ + "number", + "null" + ] + }, + "quantity": { + "type": [ + "number", + "null" + ] + }, + "units": { + "type": [ + "string", + "null" + ] + }, + "cpu_capacity_core_h": { + "type": [ + "number", + "null" + ], + "description": "Cluster: reserved vCPU-hours of its nodes on the day" + }, + "mem_capacity_gb_h": { + "type": [ + "number", + "null" + ], + "description": "Cluster: reserved GiB-hours of its nodes on the day" + }, + "cpu_share": { + "type": [ + "number", + "null" + ], + "description": "Cluster: share of cost_usd attributed to CPU when deriving rates (rest → memory)" + }, + "rate_cpu_usd_core_h": { + "type": [ + "number", + "null" + ], + "description": "Cluster blended rate: USD per vCPU-hour (÷1000 for millicore-hour)" + }, + "rate_mem_usd_gb_h": { + "type": [ + "number", + "null" + ], + "description": "Cluster blended rate: USD per GiB-hour" + }, + "source": { + "type": "string", + "enum": [ + "aws_ce", + "aws_cur", + "k8s", + "manual", + "finops_allocator" + ] + }, + "allocation_method": { + "type": "string", + "enum": [ + "by_metric", + "cluster_pending_consumption", + "cluster_split_cpu_mem", + "direct", + "direct_resource", + "direct_tag", + "kubernetes_overhead", + "map", + "rollup", + "rule", + "spread", + "service_owner", + "split", + "unallocated" + ], + "index": [ + "filter" + ] + }, + "rule_id": { + "type": [ + "string", + "null" + ], + "description": "cost_mapping_rule that produced an allocated fact (or 'default:')", + "index": [ + "filter" + ] + }, + "parent_id": { + "type": [ + "string", + "null" + ], + "index": [ + "filter" + ] + }, + "share": { + "type": [ + "number", + "null" + ], + "description": "Fraction (0..1) of the source fact allocated to this target" + }, + "collected_at": { + "type": "string" + }, + "collector": { + "type": [ + "string", + "null" + ] + }, + "usage_hours": { + "type": [ + "number", + "null" + ], + "description": "Instance-hours summed into an aggregated bucket (e.g. ec2-instances-unattributed)" + }, + "instances_seen": { + "type": [ + "integer", + "null" + ], + "description": "How many of the aggregated instances still existed at collection time (the rest terminated during the day)" + }, + "instance_types": { + "type": [ + "object", + "null" + ], + "description": "Instance type → count for aggregated buckets (only for instances still present at collection)", + "additionalProperties": { + "type": "integer" + } + }, + "day": { + "type": "string", + "description": "Same value as date; filterable copy (the catalog list API ignores a `date` query param)", + "index": [ + "filter" + ] + }, + "tags": { + "type": [ + "object", + "null" + ], + "description": "Cloud tags of the subject (evidence for mapping rules)", + "additionalProperties": true + }, + "host": { + "type": [ + "string", + "null" + ], + "description": "Primary endpoint/host of the subject (databases, caches) — evidence for mapping rules" + }, + "category": { + "type": [ + "string", + "null" + ], + "description": "Cost category of an allocated fact: compute, kubernetes, database, storage, network, observability, messaging, security, cdn, other", + "index": [ + "filter" + ] + }, + "source_fact_id": { + "type": [ + "string", + "null" + ], + "description": "Raw fact an allocated fact was derived from" + }, + "application_slug": { + "type": [ + "string", + "null" + ], + "description": "Application slug when the target was resolved by name" + }, + "by_category": { + "type": [ + "object", + "null" + ], + "description": "Rollup rows: category → USD", + "additionalProperties": { + "type": "number" + } + }, + "by_cloud_service": { + "type": [ + "object", + "null" + ], + "description": "Rollup rows: cloud service → USD", + "additionalProperties": { + "type": "number" + } + }, + "metric": { + "type": [ + "string", + "null" + ], + "description": "Consumption metric attached to the fact (e.g. pi.db.load) whose shares split it under a by_metric rule" + }, + "metric_shares": { + "type": [ + "object", + "null" + ], + "description": "metric key (database name, log group, namespace…) → share 0..1 of the metric on the day", + "additionalProperties": { + "type": "number" + } + }, + "metric_key": { + "type": [ + "string", + "null" + ], + "description": "For by_metric allocations: the metric key this portion corresponds to" + }, + "cost_method": { + "type": [ + "string", + "null" + ], + "description": "How the subject's cost_usd was derived by the collector: direct_tag (Cost Explorer by owner tag), unallocated (even split / fallback)" + }, + "metric_owners": { + "type": [ + "object", + "null" + ], + "description": "metric key → owner dims {application_id, namespace_id, scope_id, account_id, application_slug} when the collector knows the owner (k8s scopes by label)", + "additionalProperties": true + }, + "core_h_chargeable": { + "type": [ + "number", + "null" + ], + "description": "Kubernetes: Σ hours max(usage, request) CPU cores" + }, + "gb_h_chargeable": { + "type": [ + "number", + "null" + ], + "description": "Kubernetes: Σ hours max(usage, request) memory GiB" + }, + "core_h_used": { + "type": [ + "number", + "null" + ], + "description": "Kubernetes: CPU core-hours used" + }, + "gb_h_used": { + "type": [ + "number", + "null" + ], + "description": "Kubernetes: memory GiB-hours used" + }, + "core_h_requested": { + "type": [ + "number", + "null" + ], + "description": "Kubernetes: CPU core-hours requested" + }, + "gb_h_requested": { + "type": [ + "number", + "null" + ], + "description": "Kubernetes: memory GiB-hours requested" + }, + "pods_avg": { + "type": [ + "number", + "null" + ], + "description": "Kubernetes: average pods over the day" + }, + "k8s_overhead_usd": { + "type": [ + "number", + "null" + ], + "description": "Cluster: cost not covered by any scope (system namespaces, idle headroom)" + }, + "scope_type": { + "type": [ + "string", + "null" + ], + "description": "null scope type (web_pool_k8s, custom, lambda, …) when known" + }, + "dimensions": { + "type": [ + "object", + "null" + ], + "description": "null dimensions of the scope/service the fact belongs to (e.g. {environment: production})", + "additionalProperties": true + }, + "charge_type": { + "type": [ + "string", + "null" + ], + "enum": [ + "scope", + "service", + "application", + null + ], + "description": "Invoice line type: through a null scope, a null service, or the application itself (unmapped resource attributed by rule)", + "index": [ + "filter" + ] + }, + "usage_id": { + "type": [ + "string", + "null" + ], + "description": "scope_usage_daily row this k8s scope fact was priced from (usage--)" + }, + "service_kind": { + "type": [ + "string", + "null" + ], + "enum": [ + "null", + "cloud", + null + ], + "description": "service charges: null = a registered null service; cloud = a cloud resource acting as a service (database, cache) with no null service registered", + "index": [ + "filter" + ] + }, + "bucket": { + "type": [ + "string", + "null" + ], + "description": "owner bucket when the cost goes to a shared pool instead of an application (e.g. shared-platform)", + "index": [ + "filter" + ] + } + }, + "required": [ + "id", + "date", + "stage", + "subject_type", + "subject_id", + "cloud", + "cost_usd", + "source", + "allocation_method", + "collected_at" + ], + "additionalProperties": false, + "authorization": { + "entities": { + "grants": [ + { + "actions": [ + "read", + "list", + "create", + "write", + "delete" + ], + "principals": [ + { + "type": "*" + } + ] + }, + { + "actions": [ + "*" + ], + "principals": [ + { + "id": 732189543, + "type": "user" + } + ] + } + ] + }, + "specification": { + "grants": [ + { + "actions": [ + "read" + ], + "principals": [ + { + "type": "*" + } + ] + }, + { + "actions": [ + "*" + ], + "principals": [ + { + "id": 732189543, + "type": "user" + } + ] + } + ] + } + } + } +} \ No newline at end of file diff --git a/finops/specs/cost_mapping_rule.spec.json b/finops/specs/cost_mapping_rule.spec.json new file mode 100644 index 0000000..edf6b97 --- /dev/null +++ b/finops/specs/cost_mapping_rule.spec.json @@ -0,0 +1,164 @@ +{ + "slug": "cost_mapping_rule", + "name": "Cost Mapping Rule", + "description": "Maps cloud cost to a null owner (application/scope/service), a cluster or a shared bucket. The allocator evaluates active rules by priority per raw fact; first match wins. See docs/mapping-rules-design.md.", + "schema": { + "type": "object", + "required": [ + "id", + "name", + "enabled", + "status", + "priority", + "target" + ], + "properties": { + "id": { + "type": "string", + "description": "Logical id (slug)", + "alias": "id", + "primaryKey": true, + "autoGenerate": false + }, + "name": { + "type": "string", + "description": "Human name" + }, + "description": { + "type": "string", + "description": "Why this rule exists" + }, + "enabled": { + "type": "boolean", + "description": "Disabled rules are ignored", + "index": [ + "filter" + ] + }, + "status": { + "type": "string", + "description": "active | proposed | rejected \u2014 only active rules allocate", + "index": [ + "filter" + ] + }, + "priority": { + "type": "integer", + "description": "Ascending; first matching rule wins (defaults ship with priority 1000+)" + }, + "source": { + "type": "string", + "description": "manual | inferred | default", + "index": [ + "filter" + ] + }, + "confidence": { + "type": "number", + "description": "0..1 for inferred rules" + }, + "scope": { + "type": "object", + "description": "Field \u2192 expected value or {regex} on the raw fact: cloud, cloud_service, subject_type, resource_type, cluster, region", + "additionalProperties": true + }, + "match": { + "type": "array", + "description": "Predicates, all must hold: {field, equals|regex|in|exists}. field is a dotted path on the raw fact (tags.application_id, host, subject_name, resource_id, usage_type, component). Regex named groups become $captures.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "target": { + "type": "object", + "description": "{application_id|scope_id|service_id|namespace_id|cluster|bucket} literal, or capture:{application_id:'tags.application_id', ...}, or split:[{weight,target}], or map:{key:'tags.db_name'|'$group', entries:{key:{target}}}", + "additionalProperties": true + }, + "method": { + "type": "string", + "description": "direct | split | map | by_metric (by_metric not implemented yet: falls back to unallocated)" + }, + "category": { + "type": "string", + "description": "Optional category override for the allocated cost (compute, kubernetes, database, storage, network, observability, messaging, security, cdn, other)" + }, + "evidence": { + "type": "array", + "description": "What produced the rule (for inferred rules)", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "created_by": { + "type": "string", + "description": "user or workflow" + }, + "created_at": { + "type": "string", + "description": "ISO timestamp" + }, + "updated_at": { + "type": "string", + "description": "ISO timestamp" + } + }, + "authorization": { + "entities": { + "grants": [ + { + "actions": [ + "read", + "list", + "create", + "write", + "delete" + ], + "principals": [ + { + "type": "*" + } + ] + }, + { + "actions": [ + "*" + ], + "principals": [ + { + "id": 732189543, + "type": "user" + } + ] + } + ] + }, + "specification": { + "grants": [ + { + "actions": [ + "read" + ], + "principals": [ + { + "type": "*" + } + ] + }, + { + "actions": [ + "*" + ], + "principals": [ + { + "id": 732189543, + "type": "user" + } + ] + } + ] + } + } + } +} diff --git a/finops/specs/cost_mapping_suggestion.spec.json b/finops/specs/cost_mapping_suggestion.spec.json new file mode 100644 index 0000000..6e23073 --- /dev/null +++ b/finops/specs/cost_mapping_suggestion.spec.json @@ -0,0 +1,144 @@ +{ + "slug": "cost_mapping_suggestion", + "name": "Cost Mapping Suggestion", + "description": "A rule proposed by the inference workflow from evidence (null services, application parameters, naming). A human accepts it into cost_mapping_rule or rejects it.", + "schema": { + "type": "object", + "required": [ + "id", + "name", + "status", + "rule" + ], + "properties": { + "id": { + "type": "string", + "description": "Logical id (slug)", + "alias": "id", + "primaryKey": true, + "autoGenerate": false + }, + "name": { + "type": "string", + "description": "Human name" + }, + "status": { + "type": "string", + "description": "proposed | accepted | rejected", + "index": [ + "filter" + ] + }, + "day": { + "type": "string", + "description": "Day (YYYY-MM-DD) the evidence was observed on", + "index": [ + "filter" + ] + }, + "cloud_service": { + "type": "string", + "description": "Cloud service of the unallocated cost", + "index": [ + "filter" + ] + }, + "resource_type": { + "type": "string", + "description": "Resource type of the subject", + "index": [ + "filter" + ] + }, + "subject_id": { + "type": "string", + "description": "Raw subject the suggestion is about (resource id, cluster, host\u2026)" + }, + "recovers_usd": { + "type": "number", + "description": "Unallocated USD/day this rule would attribute" + }, + "confidence": { + "type": "number", + "description": "0..1" + }, + "rule": { + "type": "object", + "description": "The proposed cost_mapping_rule body (scope, match, target, method)", + "additionalProperties": true + }, + "evidence": { + "type": "array", + "description": "[{kind: tag|null_service|parameter|naming, detail, application_id?, application_slug?, ...}]", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "created_at": { + "type": "string", + "description": "ISO timestamp" + }, + "updated_at": { + "type": "string", + "description": "ISO timestamp" + } + }, + "authorization": { + "entities": { + "grants": [ + { + "actions": [ + "read", + "list", + "create", + "write", + "delete" + ], + "principals": [ + { + "type": "*" + } + ] + }, + { + "actions": [ + "*" + ], + "principals": [ + { + "id": 732189543, + "type": "user" + } + ] + } + ] + }, + "specification": { + "grants": [ + { + "actions": [ + "read" + ], + "principals": [ + { + "type": "*" + } + ] + }, + { + "actions": [ + "*" + ], + "principals": [ + { + "id": 732189543, + "type": "user" + } + ] + } + ] + } + } + } +} diff --git a/finops/specs/scope_usage_daily.spec.json b/finops/specs/scope_usage_daily.spec.json new file mode 100644 index 0000000..44b4754 --- /dev/null +++ b/finops/specs/scope_usage_daily.spec.json @@ -0,0 +1,306 @@ +{ + "name": "Scope Usage Daily", + "slug": "scope_usage_daily", + "description": "Kubernetes consumption of one null scope for one day: CPU/memory used vs requested per hour (agent collector or New Relic), pods, utilization. Priced into cost_daily by the FinOps allocator; the right-sizing source.", + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "primaryKey": true, + "autoGenerate": false, + "description": "usage--" + }, + "date": { + "type": "string", + "description": "YYYY-MM-DD (UTC)", + "index": [ + "filter" + ] + }, + "day": { + "type": "string", + "description": "Same as date; filterable copy", + "index": [ + "filter" + ] + }, + "scope_id": { + "type": "string", + "index": [ + "filter" + ] + }, + "scope_name": { + "type": [ + "string", + "null" + ] + }, + "scope_slug": { + "type": [ + "string", + "null" + ] + }, + "scope_type": { + "type": [ + "string", + "null" + ], + "index": [ + "filter" + ] + }, + "application_id": { + "type": [ + "string", + "null" + ], + "index": [ + "filter" + ] + }, + "application_slug": { + "type": [ + "string", + "null" + ] + }, + "namespace_id": { + "type": [ + "string", + "null" + ] + }, + "account_id": { + "type": [ + "string", + "null" + ] + }, + "nrn": { + "type": [ + "string", + "null" + ] + }, + "cluster": { + "type": "string", + "index": [ + "filter" + ] + }, + "cloud": { + "type": [ + "string", + "null" + ] + }, + "cloud_account": { + "type": [ + "string", + "null" + ] + }, + "region": { + "type": [ + "string", + "null" + ] + }, + "dimensions": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "environment": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": "string", + "enum": [ + "agent", + "newrelic" + ], + "description": "where the pod metrics came from", + "index": [ + "filter" + ] + }, + "collector": { + "type": [ + "string", + "null" + ] + }, + "collected_at": { + "type": "string" + }, + "samples": { + "type": [ + "number", + "null" + ], + "description": "metric samples of the day (0 = the scope had no pods in this cluster)" + }, + "pods_avg": { + "type": [ + "number", + "null" + ] + }, + "hours_with_data": { + "type": [ + "number", + "null" + ] + }, + "core_h_used": { + "type": [ + "number", + "null" + ] + }, + "core_h_requested": { + "type": [ + "number", + "null" + ] + }, + "core_h_chargeable": { + "type": [ + "number", + "null" + ], + "description": "\u03a3_hours max(used, requested) \u2014 what the scope binds on the nodes" + }, + "gb_h_used": { + "type": [ + "number", + "null" + ] + }, + "gb_h_requested": { + "type": [ + "number", + "null" + ] + }, + "gb_h_chargeable": { + "type": [ + "number", + "null" + ] + }, + "cpu_utilization_pct": { + "type": [ + "number", + "null" + ], + "description": "100 \u00d7 used / requested (core-hours)" + }, + "mem_utilization_pct": { + "type": [ + "number", + "null" + ] + }, + "cpu_waste_core_h": { + "type": [ + "number", + "null" + ], + "description": "requested \u2212 used when positive" + }, + "mem_waste_gb_h": { + "type": [ + "number", + "null" + ] + }, + "hours": { + "type": [ + "array", + "null" + ], + "description": "per-hour detail [{hour, cpu_mc, mem_mb, cpu_req_mc, mem_req_mb, pods}]", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "required": [ + "id", + "date", + "day", + "scope_id", + "cluster", + "source", + "collected_at" + ], + "additionalProperties": true, + "authorization": { + "entities": { + "grants": [ + { + "actions": [ + "read", + "list", + "create", + "write", + "delete" + ], + "principals": [ + { + "type": "*" + } + ] + }, + { + "actions": [ + "*" + ], + "principals": [ + { + "id": 732189543, + "type": "user" + } + ] + } + ] + }, + "specification": { + "grants": [ + { + "actions": [ + "read" + ], + "principals": [ + { + "type": "*" + } + ] + }, + { + "actions": [ + "*" + ], + "principals": [ + { + "id": 732189543, + "type": "user" + } + ] + } + ] + } + } + } +} \ No newline at end of file diff --git a/finops/tool-cloud-query.yaml b/finops/tool-cloud-query.yaml new file mode 100644 index 0000000..2fd3bff --- /dev/null +++ b/finops/tool-cloud-query.yaml @@ -0,0 +1,224 @@ +# finops/tool-cloud-query.yaml +# +# Reusable child: run a list of cloud SDK calls through the `cloud-query` +# package on an agent selected by tags, and return results keyed by call id. +# Async by default (np-package-call two-phase wait + engine callback); sync +# only for small, fast calls (<60 s, <300 KB — platform limits measured +# 2026-09-10, see docs/superpowers/specs/2026-09-10-finops-cost-allocation-design.md §2). +id: finops_cloud_query +name: "FinOps — Cloud Query (tool)" +description: > + Runs cloud SDK calls (Cost Explorer, CloudWatch, EC2, STS) on a customer + agent through the cloud-query package and returns the raw responses keyed + by call id. Called by the FinOps collectors and usable as an agent tool. +path: "/finops" +semantic_version: 0.1.0 + +inputs: + agent_tags: + type: object + required: true + description: "Agent selector tags, e.g. {\"package\":\"cloud-query\"}" + agent_nrn: + type: string + required: false + description: "NRN subtree the agent belongs to. Required when the agent is registered under an account (e.g. organization=4:account=17): the control plane does not resolve those from the organization root." + calls: + type: array + required: true + description: "cloud-query calls: [{id, service, operation, params?, paginate?, maxPages?}]" + region: + type: string + required: false + description: "AWS region for regional services (Cost Explorer is global)" + assume_role_arn: + type: string + required: false + description: "Optional role the worker assumes before the calls (per customer/account; the worker's base identity must be trusted by it)" + assume_role_external_id: + type: string + required: false + description: "ExternalId for the AssumeRole trust policy (recommended for cross-account roles)" + mode: + type: string + required: false + description: "async (default) or sync" + package_version: + type: string + required: false + description: "Package version to run. Selects the agent's worker pin (package, version) — and with it the pod SERVICE ACCOUNT / IAM role that pin declares. Empty = default." + image: + type: string + required: false + description: "Worker image as an immutable reference registry/repository@sha256: (default: variables.image). The agent pulls it directly when its NP_ALLOWED_REGISTRIES allows the registry — no platform package registration needed." + # Security note: this input only CHOOSES where the worker posts its result; + # the WORKER enforces the destination against NP_CALLBACK_ALLOWED_HOSTS + # (default api.nullplatform.com; host.docker.internal only in `mise run run`). + # A caller cannot redirect billing data or reach internal hosts through it. + callback_base_url: + type: string + required: false + description: "Engine base URL the WORKER can reach for the async callback (default: variables.callback_base_url). Must be on the worker's NP_CALLBACK_ALLOWED_HOSTS. Local dev with a docker worker: http://host.docker.internal:" + +variables: + # Released worker image (see packages/cloud-query/scripts/release.json). Operator + # pins in the agent (NP_WORKERS) win over this when present. + image: + initialValue: "public.ecr.aws/nullplatform/agent-plugins/workflows/aws-cost-explorer@sha256:24fcd9b320880c549c910a43c32c91c8c7024d1e57aedfbc2c3ff3bd85447368" + # The engine base URL the WORKER must reach to deliver the callback. + # Locally (docker worker → dev-server on the laptop): http://host.docker.internal:3000 + callback_base_url: + initialValue: "https://api.nullplatform.com" + # Hosts a caller may point the callback to (comma separated). First layer of + # the SSRF guard; the worker enforces its own NP_CALLBACK_ALLOWED_HOSTS too. + callback_allowed_hosts: + initialValue: "api.nullplatform.com,host.docker.internal" + +steps: + - id: start + type: trigger + plugin_type: manual + name: "Query" + config: + description: "Run cloud SDK calls through the cloud-query package." + inputs: + agent_tags: + type: object + required: true + description: "Agent selector tags" + agent_nrn: + type: string + required: false + description: "NRN subtree of the agent (e.g. organization=4:account=17)" + calls: + type: array + required: true + description: "cloud-query calls" + region: + type: string + required: false + description: "AWS region" + assume_role_arn: + type: string + required: false + description: "Role ARN to assume" + assume_role_external_id: + type: string + required: false + description: "AssumeRole ExternalId" + mode: + type: string + required: false + description: "async | sync" + callback_base_url: + type: string + required: false + description: "Engine base URL for the worker callback" + image: + type: string + required: false + description: "Worker image (immutable reference)" + package_version: + type: string + required: false + description: "Package version → agent pin → service account" + + - id: build_request + type: module + plugin_type: code-exec + name: "Build cloud-query request" + inputs: + agent_tags: "${{ workflow.inputs.agent_tags }}" + agent_nrn: "${{ workflow.inputs.agent_nrn }}" + calls: "${{ workflow.inputs.calls }}" + region: "${{ workflow.inputs.region }}" + assume_role_arn: "${{ workflow.inputs.assume_role_arn }}" + assume_role_external_id: "${{ workflow.inputs.assume_role_external_id }}" + mode: "${{ workflow.inputs.mode }}" + callback_base_url: "${{ workflow.inputs.callback_base_url }}" + callback_default: "${{ variables.callback_base_url }}" + callback_allowed_hosts: "${{ variables.callback_allowed_hosts }}" + image: "${{ workflow.inputs.image }}" + image_default: "${{ variables.image }}" + package_version: "${{ workflow.inputs.package_version }}" + config: + language: javascript + code: | + var tags = (inputs.agent_tags && typeof inputs.agent_tags === "object") ? inputs.agent_tags : {}; + if (!Object.keys(tags).length) throw new Error("agent_tags must have at least one tag"); + var nrn = String(inputs.agent_nrn || "").trim(); + if (nrn && !/^organization=\d+(:account=\d+)?(:namespace=\d+)?(:application=\d+)?(:scope=\d+)?$/.test(nrn)) throw new Error("agent_nrn is not an NRN (got " + nrn + ")"); + // The control plane resolves the selector: tags subset match, scoped to the NRN subtree when given. + var selector = nrn ? { nrn: nrn, tags: tags } : tags; + var calls = Array.isArray(inputs.calls) ? inputs.calls : []; + if (calls.length === 0) throw new Error("calls must be a non-empty array"); + var cq = { provider: "aws", calls: calls }; + if (inputs.region) cq.region = String(inputs.region); + if (inputs.assume_role_arn) { + cq.assumeRole = { roleArn: String(inputs.assume_role_arn), sessionName: "np-finops" }; + if (inputs.assume_role_external_id) cq.assumeRole.externalId = String(inputs.assume_role_external_id); + } + var cb = String(inputs.callback_base_url || inputs.callback_default || "https://api.nullplatform.com").trim(); + // Strict shape: scheme://host[:port] with no credentials, path or query. + var m = /^(https?):\/\/([a-z0-9.-]+)(:\d{1,5})?$/i.exec(cb); + if (!m) throw new Error("callback_base_url must be http(s)://host[:port] (got " + cb + ")"); + var allowed = String(inputs.callback_allowed_hosts || "").toLowerCase().split(",").map(function (h) { return h.trim(); }).filter(Boolean); + if (allowed.indexOf(m[2].toLowerCase()) === -1) throw new Error("callback host not allowed: " + m[2] + " (allowed: " + allowed.join(", ") + ")"); + var img = String(inputs.image || inputs.image_default || "").trim(); + if (img && !/^[a-z0-9.-]+(:\d+)?\/[a-z0-9._\/-]+@sha256:[a-f0-9]{64}$/i.test(img)) throw new Error("image must be registry/repository@sha256: (got " + img + ")"); + var ver = String(inputs.package_version || "").trim(); + if (ver && !/^\d+\.\d+\.\d+([-.][A-Za-z0-9.]+)?$/.test(ver)) throw new Error("package_version must be semver (got " + ver + ")"); + // The image travels as np-package-call's `image` input → command.data.package = {slug, image} + // (agents-api #228/#230 lowers it into the worker's oci_image artifact). Nothing to embed here. + var ac = { cloud_query: cq }; + return { action_context: ac, agent_selector: selector, mode: inputs.mode === "sync" ? "sync" : "async", callback_base_url: cb, image: img || null, version: ver || null }; + + - id: call_package + type: module + plugin_type: np-package-call + name: "cloud-query (package-exec)" + inputs: + action_context: "${{ steps.build_request.outputs.action_context }}" + agent_selector: "${{ steps.build_request.outputs.agent_selector }}" + mode: "${{ steps.build_request.outputs.mode }}" + # Step INPUT, not config: `${{ steps.X.outputs.* }}` is not reliably + # resolved inside `config:` (see feedback in the repo's authoring notes). + callback_base_url: "${{ steps.build_request.outputs.callback_base_url }}" + image: "${{ steps.build_request.outputs.image }}" + version: "${{ steps.build_request.outputs.version }}" + config: + apikey: "${{ secrets.NP_API_KEY }}" + package: cloud-query + # Placeholders so validateConfig() passes; the inputs above replace them at run time. + agent_selector: + tags: + package: cloud-query + action_context: {} + callback_base_url: "https://api.nullplatform.com" + timeout: "30m" + retry_max_attempts: 1 + + - id: shape_results + type: module + plugin_type: code-exec + name: "Results by call id" + inputs: + response: "${{ steps.call_package.outputs.response }}" + failed: "${{ steps.call_package.outputs.failed }}" + config: + language: javascript + code: | + var r = inputs.response || {}; + var out = {}; + (r.calls || []).forEach(function (c) { out[c.id] = c.ok ? c.result : { error: c.error, errorCode: c.errorCode }; }); + return { results: out, identity: r.identity || null, failed: inputs.failed || [] }; + +connections: + - { from: start, to: build_request } + - { from: build_request, to: call_package } + - { from: call_package, to: shape_results } + +outputs: + results: "${{ steps.shape_results.outputs.results }}" + identity: "${{ steps.shape_results.outputs.identity }}" + failed: "${{ steps.shape_results.outputs.failed }}" diff --git a/finops/wf-cost-fact-upsert.yaml b/finops/wf-cost-fact-upsert.yaml new file mode 100644 index 0000000..e9f773f --- /dev/null +++ b/finops/wf-cost-fact-upsert.yaml @@ -0,0 +1,148 @@ +# finops/wf-cost-fact-upsert.yaml +# +# Child: upsert a BATCH of cost_daily facts (or one `fact`) into the catalog. Called by the +# collectors through `sub-workflow` + `forEach` (one child per batch, bounded parallelism). +# Batches keep the parent's Temporal history small: 752 children per day re-ran in a loop +# in prod (2026-09-11) once the parent state passed ~2 MB; 20 children of 40 facts do not. +# `PATCH /catalog/instances/cost_daily/{id}?upsert=true` merges at top level, +# so each writer owns its fields (cost-finout lesson). +id: finops_cost_fact_upsert +name: "FinOps — Upsert cost_daily fact" +description: > + Upserts a batch of cost_daily catalog instances (PATCH ?upsert=true, top-level merge). +path: "/finops" +semantic_version: 0.1.0 + +inputs: + fact: + type: object + required: false + description: "ONE cost_daily fact (must carry id, date, stage, subject_type, subject_id, cloud, cost_usd, source, allocation_method, collected_at); for other slugs only `id` is required" + facts: + type: array + required: false + description: "A BATCH of facts (same rules as `fact`); either `fact` or `facts` is required" + dry_run: + type: boolean + required: false + description: "Validate only, write nothing" + catalog_slug: + type: string + required: false + description: "Catalog spec slug (default: variables.catalog_slug)" + +variables: + # Catalog specification slug the facts are written to. + catalog_slug: + initialValue: "cost_daily" + +steps: + - id: start + type: trigger + plugin_type: manual + name: "Upsert" + config: + description: "Upsert one cost_daily fact." + inputs: + fact: + type: object + required: false + description: "one cost_daily fact" + facts: + type: array + required: false + description: "a batch of facts" + dry_run: + type: boolean + required: false + description: "validate only" + catalog_slug: + type: string + required: false + description: "Catalog spec slug (default: variables.catalog_slug)" + + - id: check + type: module + plugin_type: code-exec + name: "Validate facts" + inputs: + fact: "${{ workflow.inputs.fact }}" + facts: "${{ workflow.inputs.facts }}" + dry_run: "${{ workflow.inputs.dry_run }}" + catalog_slug: "${{ workflow.inputs.catalog_slug }}" + default_slug: "${{ variables.catalog_slug }}" + config: + language: javascript + code: | + var list = Array.isArray(inputs.facts) ? inputs.facts.slice() : []; + if (inputs.fact && typeof inputs.fact === "object") list.push(inputs.fact); + if (!list.length) throw new Error("fact or facts is required"); + var slug = String(inputs.catalog_slug || inputs.default_slug || "cost_daily"); + if (!/^[a-z0-9_]+$/.test(slug)) throw new Error("catalog_slug must be a spec slug (got " + slug + ")"); + var req = slug === "cost_daily" ? ["id","date","stage","subject_type","subject_id","cloud","cost_usd","source","allocation_method","collected_at"] : ["id"]; + var seen = {}; + var rows = list.map(function (f) { + f = f || {}; + var missing = req.filter(function (k) { return f[k] === undefined || f[k] === null || f[k] === ""; }); + if (missing.length) throw new Error("fact is missing " + missing.join(", ") + " (id=" + f.id + ")"); + if (slug === "cost_daily" && typeof f.cost_usd !== "number") throw new Error("cost_usd must be a number (id=" + f.id + ")"); + if (!/^[A-Za-z0-9_.-]+$/.test(String(f.id))) throw new Error("id must be a catalog-safe slug (got " + f.id + ")"); + if (seen[f.id]) throw new Error("duplicate id in batch: " + f.id); seen[f.id] = true; + return { path: "/catalog/instances/" + slug + "/" + String(f.id), body: f }; + }); + return { count: rows.length, ids: rows.map(function (r) { return r.body.id; }), slug: slug, rows: inputs.dry_run ? [] : rows, dry_run: !!inputs.dry_run }; + + - id: upsert + type: module + plugin_type: np-api-call + name: "PATCH catalog instances (upsert, fan-out)" + forEach: + expression: "${{ steps.check.outputs.rows }}" + itemVariable: row + spreadItem: true + parallel: true + maxConcurrency: 5 + error_handling: + retry_policy: + max_attempts: 3 + initial_interval: "2s" + backoff_strategy: exponential + jitter: 0.3 + config: + apiKey: "${{ secrets.NP_API_KEY }}" + method: PATCH + path: "/catalog/instances/placeholder/placeholder" + # 409 = a concurrent PATCH of the same deterministic id (the step's own retry, or a second allocator + # the engine spawned): the row exists with this run's content. Reported as an output, accepted below. + failOnHttpError: false + query: + upsert: "true" + body: {} + + - id: result + type: module + plugin_type: code-exec + name: "Batch result" + metadata: { fanOutPerItem: false } + inputs: + count: "${{ steps.check.outputs.count }}" + ids: "${{ steps.check.outputs.ids }}" + dry_run: "${{ steps.check.outputs.dry_run }}" + results: "${{ steps.upsert.outputs.items }}" + config: + language: javascript + code: | + var res = inputs.results || []; var failed = []; + res.forEach(function (r, i) { var o = (r || {}).outputs || r || {}; var st = Number(o.status || 0); if (!((st >= 200 && st < 300) || st === 409)) failed.push({ index: i, status: st }); }); + if (failed.length) throw new Error("upsert failed for " + failed.length + "/" + res.length + " rows (first: status " + failed[0].status + ")"); + return { count: Number(inputs.count) || 0, written: inputs.dry_run ? 0 : res.length, ids: inputs.ids || [] }; + +connections: + - { from: start, to: check } + - { from: check, to: upsert } + - { from: upsert, to: result } + +outputs: + count: "${{ steps.result.outputs.count }}" + written: "${{ steps.result.outputs.written }}" + ids: "${{ steps.result.outputs.ids }}" diff --git a/finops/wf-suggest-mappings.yaml b/finops/wf-suggest-mappings.yaml new file mode 100644 index 0000000..c9a02cb --- /dev/null +++ b/finops/wf-suggest-mappings.yaml @@ -0,0 +1,285 @@ +# finops/wf-suggest-mappings.yaml +# +# Inference: for the cost the allocator could not attribute, look for evidence of +# who owns it and write cost_mapping_suggestion rows (status=proposed) a human +# accepts into cost_mapping_rule. Evidence sources (design: docs/mapping-rules-design.md): +# 1. null services whose attributes.host/hostname/endpoint equals the leaf's host (confidence 0.95) +# 2. application PARAMETERS whose value contains the leaf's host or resource name (confidence 0.6; +# several consumers → an equal `split`, to be refined by a metric) +# Secrets are not readable: a consumer that only has a secret DATABASE_URL is missed. +# Called by wf2 (sub-workflow) with its `unallocated_leaves`, or manually with a day. +id: finops_suggest_mappings +name: "FinOps — suggest mapping rules from evidence" +description: > + Turns unallocated cost into proposed mapping rules using null services and + application parameters as evidence. +path: "/finops" +semantic_version: 0.1.0 + +inputs: + date: + type: string + required: true + description: "Day the evidence was observed on (YYYY-MM-DD)" + unallocated_leaves: + type: array + required: true + description: "wf2 output: [{id, cloud_service, subject_type, subject_id, resource_type, host, tags, cost_usd}]" + org_nrn: + type: string + required: false + description: "Root NRN of the organization (default: variables.org_nrn)" + account_id: + type: string + required: false + description: "Account id used to build application NRNs for the parameter scan (default: variables.account_id)" + dry_run: + type: boolean + required: false + +variables: + org_nrn: + initialValue: "organization=1255165411" + account_id: + initialValue: "" + min_usd: + initialValue: 0.05 + suggester: + initialValue: "finops_suggest_mappings@0.1.0" + +steps: + - id: start + type: trigger + plugin_type: manual + name: "Suggest" + config: + description: "Propose mapping rules for unallocated cost." + inputs: + date: { type: string, required: true, description: "YYYY-MM-DD" } + unallocated_leaves: { type: array, required: true, description: "wf2 unallocated_leaves" } + org_nrn: { type: string, required: false, description: "Organization NRN" } + account_id: { type: string, required: false, description: "Account id for application NRNs" } + dry_run: { type: boolean, required: false, description: "Compute without writing" } + + - id: prep + type: module + plugin_type: code-exec + name: "Candidates" + inputs: + leaves: "${{ workflow.inputs.unallocated_leaves }}" + org_nrn: "${{ workflow.inputs.org_nrn }}" + org_nrn_default: "${{ variables.org_nrn }}" + account_id: "${{ workflow.inputs.account_id }}" + account_id_default: "${{ variables.account_id }}" + min_usd: "${{ variables.min_usd }}" + config: + language: javascript + code: | + var org = String(inputs.org_nrn || inputs.org_nrn_default || "").trim(); + if (!/^organization=\d+$/.test(org)) throw new Error("org_nrn must be organization= (got " + org + ")"); + var acct = String(inputs.account_id || inputs.account_id_default || "").trim(); + var min = Number(inputs.min_usd) || 0; + // Only leaves with something to match on: a host or a resource name/id. + var cands = (inputs.leaves || []).filter(function (l) { return l && (Number(l.cost_usd) || 0) >= min && (l.host || l.resource_id || l.subject_id); }); + return { org_nrn: org, account_id: acct, candidates: cands, app_filters: { nrn: org, status: "active" }, service_query: { nrn: org, show_descendants: "true", limit: "500" } }; + + - id: np_services + type: module + plugin_type: np-api-call + name: "null services" + inputs: + query: "${{ steps.prep.outputs.service_query }}" + config: + apiKey: "${{ secrets.NP_API_KEY }}" + method: GET + path: "/service" + query: { show_descendants: "true", limit: "500" } + failOnHttpError: false + + - id: apps + type: module + plugin_type: np-entity-paginated-fetch + name: "Active applications" + inputs: + filters: "${{ steps.prep.outputs.app_filters }}" + config: + apiKey: "${{ secrets.NP_API_KEY }}" + entity: "application" + filters: { status: "active" } + limit: 100 + maxPages: 20 + + - id: app_nrns + type: module + plugin_type: code-exec + name: "Application NRNs" + inputs: + apps: "${{ steps.apps.outputs.items }}" + org_nrn: "${{ steps.prep.outputs.org_nrn }}" + account_id: "${{ steps.prep.outputs.account_id }}" + config: + language: javascript + code: | + var acct = String(inputs.account_id || ""); + var list = (inputs.apps || []).filter(function (a) { return a && a.id; }).map(function (a) { + var nrn = a.nrn || (acct && a.namespace_id ? inputs.org_nrn + ":account=" + acct + ":namespace=" + a.namespace_id + ":application=" + a.id : null); + // `query` is pre-built per item: nested `${{ app.nrn }}` inside an object input does not resolve in fan-out. + return { id: String(a.id), slug: a.slug || String(a.id), namespace_id: a.namespace_id != null ? String(a.namespace_id) : null, nrn: nrn, query: { nrn: nrn } }; + }).filter(function (a) { return a.nrn; }); + // Fan-out items are spread into the np-api-call inputs (spreadItem): keep them to the `query` key only. + return { apps: list, queries: list.map(function (a) { return { query: a.query }; }), count: list.length }; + + - id: params + type: module + plugin_type: np-api-call + name: "Parameters per application (fan-out)" + forEach: + expression: "${{ steps.app_nrns.outputs.queries }}" + itemVariable: app + spreadItem: true + parallel: true + maxConcurrency: 6 + config: + apiKey: "${{ secrets.NP_API_KEY }}" + method: GET + path: "/parameter" + query: { nrn: "placeholder" } + failOnHttpError: false + + - id: suggest + type: module + plugin_type: code-exec + name: "Evidence → suggestions" + metadata: { fanOutPerItem: false } + inputs: + day: "${{ workflow.inputs.date }}" + candidates: "${{ steps.prep.outputs.candidates }}" + services: "${{ steps.np_services.outputs.body }}" + apps: "${{ steps.app_nrns.outputs.apps }}" + params: "${{ steps.params.outputs.items }}" + suggester: "${{ variables.suggester }}" + dry_run: "${{ workflow.inputs.dry_run }}" + config: + language: javascript + code: | + var d = String(inputs.day); var now = new Date().toISOString(); + function num(x) { return Math.round((Number(x) || 0) * 1e6) / 1e6; } + function slug(x) { return String(x).toLowerCase().replace(/[^a-z0-9_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 100); } + function nrnDims(nrn) { var o = {}; String(nrn || "").split(":").forEach(function (p) { var kv = p.split("="); if (kv.length === 2) o[kv[0]] = kv[1]; }); return o; } + // 1) null services by host + var svcs = ((inputs.services || {}).results) || (Array.isArray(inputs.services) ? inputs.services : []); + var byHost = {}; + svcs.forEach(function (s) { + var at = s.attributes || {}; [at.host, at.hostname, at.endpoint, at.url].forEach(function (h) { + if (!h) return; var m = /^(?:[a-z]+:\/\/)?([^\/:]+)/i.exec(String(h)); if (m) byHost[m[1].toLowerCase()] = s; + }); + }); + // 2) parameters: app → set of values (hosts / names) + var apps = inputs.apps || []; var paramsByApp = inputs.params || []; + var consumers = {}; // key (host or name) → [{app, param}] + function addConsumer(key, app, pname) { key = String(key).toLowerCase(); (consumers[key] = consumers[key] || []).push({ app: app, param: pname }); } + paramsByApp.forEach(function (res, i) { + var app = apps[i]; if (!app) return; + var body = (res || {}).body || res || {}; var list = body.results || []; + list.forEach(function (p) { (p.values || []).forEach(function (v) { + var val = String((v || {}).value || ""); if (!val) return; + var hosts = val.match(/[a-z0-9.-]+\.(?:rds|cache|es|amazonaws)\.[a-z0-9.-]*amazonaws\.com/ig) || val.match(/[a-z0-9.-]+\.amazonaws\.com/ig) || []; + hosts.forEach(function (h) { addConsumer(h, app, p.name); }); + }); }); + }); + // DB_NAME-like parameter values → app (for by_metric keys = database names); app slugs normalized for name matching. + var dbNameOwners = {}; var slugIndex = {}; + apps.forEach(function (a) { slugIndex[String(a.slug).toLowerCase().replace(/[^a-z0-9]+/g, "")] = a; }); + paramsByApp.forEach(function (res, i) { + var app = apps[i]; if (!app) return; + var body = (res || {}).body || res || {}; (body.results || []).forEach(function (p) { + if (!/(DB_NAME|DATABASE|PG_?DB|DBNAME)/i.test(String(p.name))) return; + (p.values || []).forEach(function (v) { var val = String((v || {}).value || "").trim(); if (val && !/amazonaws|:/.test(val)) (dbNameOwners[val.toLowerCase()] = dbNameOwners[val.toLowerCase()] || []).push({ app: app, param: p.name }); }); + }); + }); + function normKey(k) { return String(k).toLowerCase().replace(/_(production|prod|stage|staging|db|database)$/g, "").replace(/[^a-z0-9]+/g, ""); } + var out = []; + (inputs.candidates || []).forEach(function (L) { + if (L.kind === "metric_key" && L.metric_key) { + var key = String(L.metric_key); var ev = []; var owner = null; + (dbNameOwners[key.toLowerCase()] || []).forEach(function (c) { if (!owner) owner = { application_id: c.app.id, namespace_id: c.app.namespace_id, application_slug: c.app.slug }; ev.push({ kind: "parameter", detail: c.app.slug + " parameter " + c.param + " = " + key, application_id: c.app.id, application_slug: c.app.slug }); }); + var nk = normKey(key); var bySlug = slugIndex[nk] || slugIndex[nk + "api"] || slugIndex[nk.replace(/api$/, "")]; + if (bySlug) { if (!owner) owner = { application_id: bySlug.id, namespace_id: bySlug.namespace_id, application_slug: bySlug.slug }; ev.push({ kind: "naming", detail: "database " + key + " ~ application slug " + bySlug.slug, application_id: bySlug.id, application_slug: bySlug.slug }); } + if (!owner) return; + var conf = ev.some(function (e) { return e.kind === "parameter"; }) ? 0.7 : 0.5; + out.push({ id: "sugg-" + slug(L.subject_id) + "-" + slug(key) + "-" + d, name: "map " + key + " → " + owner.application_slug + " on " + (L.subject_name || L.subject_id), + status: "proposed", day: d, cloud_service: L.cloud_service, resource_type: L.resource_type || null, subject_id: String(L.subject_id), recovers_usd: num(L.cost_usd), confidence: conf, + rule: { for_rule: L.rule_id || null, map_entry: { key: key, target: owner } }, evidence: ev, created_at: now, updated_at: now, source: String(inputs.suggester) }); + return; + } + var host = String(L.host || "").toLowerCase(); var cost = num(L.cost_usd); + var evidence = []; var owners = []; + if (host && byHost[host]) { + var s = byHost[host]; var dims = nrnDims(s.entity_nrn || s.nrn); + evidence.push({ kind: "null_service", detail: "attributes.host of service " + (s.name || s.slug) + " equals " + host, service_id: String(s.id), application_id: dims.application || null }); + if (dims.application) owners.push({ application_id: dims.application, namespace_id: dims.namespace || null, service_id: String(s.id) }); + } + var seen = {}; + [host, String(L.resource_id || "").toLowerCase(), String(L.subject_id || "").toLowerCase()].forEach(function (k) { + if (!k || !consumers[k]) return; + consumers[k].forEach(function (c) { if (seen[c.app.id]) return; seen[c.app.id] = true; + evidence.push({ kind: "parameter", detail: c.app.slug + " parameter " + c.param + " references " + k, application_id: c.app.id, application_slug: c.app.slug, namespace_id: c.app.namespace_id }); + if (!owners.some(function (o) { return o.application_id === c.app.id; })) owners.push({ application_id: c.app.id, namespace_id: c.app.namespace_id, application_slug: c.app.slug }); + }); + }); + if (!owners.length) return; + var conf = evidence.some(function (e) { return e.kind === "null_service"; }) ? 0.95 : (owners.length === 1 ? 0.7 : 0.6); + var match = host ? [{ field: "host", equals: host }] : [{ field: "resource_id", equals: String(L.resource_id || L.subject_id) }]; + var rule = { scope: { cloud_service: L.cloud_service }, match: match, method: owners.length === 1 ? "direct" : "split", + target: owners.length === 1 ? owners[0] : { split: owners.map(function (o) { return { weight: 1, target: o }; }) } }; + var id = "sugg-" + slug(L.subject_id) + "-" + d; + out.push({ id: id, name: (owners.length === 1 ? "→ " + (owners[0].application_slug || "application " + owners[0].application_id) : "split among " + owners.length + " consumers") + ": " + (L.subject_name || L.subject_id), + status: "proposed", day: d, cloud_service: L.cloud_service, resource_type: L.resource_type || null, subject_id: String(L.subject_id), recovers_usd: cost, confidence: conf, + rule: rule, evidence: evidence, created_at: now, updated_at: now, source: String(inputs.suggester) }); + }); + out.sort(function (a, b) { return b.recovers_usd - a.recovers_usd; }); + return { suggestions: out, to_write: inputs.dry_run ? [] : out.map(function (s) { return { fact: s, catalog_slug: "cost_mapping_suggestion" }; }), + summary: { day: d, candidates: (inputs.candidates || []).length, suggestions: out.length, recovers_usd: num(out.reduce(function (s, x) { return s + x.recovers_usd; }, 0)), apps_scanned: apps.length, services: svcs.length } }; + + - id: write + type: module + plugin_type: sub-workflow + name: "Upsert suggestions (fan-out)" + forEach: + expression: "${{ steps.suggest.outputs.to_write }}" + itemVariable: row + spreadItem: true + parallel: true + maxConcurrency: 5 + config: + workflowId: FINOPS_COST_FACT_UPSERT_ID + alias: live + waitForCompletion: true + + - id: summary + type: module + plugin_type: code-exec + name: "Summary" + metadata: { fanOutPerItem: false } + inputs: + summary: "${{ steps.suggest.outputs.summary }}" + results: "${{ steps.write.outputs.items }}" + config: + language: javascript + code: | + var s = Object.assign({}, inputs.summary || {}); s.written = (inputs.results || []).length; return s; + +connections: + - { from: start, to: prep } + - { from: prep, to: np_services } + - { from: prep, to: apps } + - { from: apps, to: app_nrns } + - { from: app_nrns, to: params } + - { from: params, to: suggest } + - { from: np_services, to: suggest } + - { from: suggest, to: write } + - { from: write, to: summary } + +outputs: + summary: "${{ steps.summary.outputs }}" + suggestions: "${{ steps.suggest.outputs.suggestions }}" diff --git a/finops/wf0-aws-billing-dispatch.yaml b/finops/wf0-aws-billing-dispatch.yaml new file mode 100644 index 0000000..7d89ec6 --- /dev/null +++ b/finops/wf0-aws-billing-dispatch.yaml @@ -0,0 +1,267 @@ +# finops/wf0-aws-billing-dispatch.yaml +# +# Daily loop: collect (wf1 per target) → k8s consumption per scope (wf3, per cluster) → +# allocate (wf2, rules + consumption shares) → suggest (evidence for the rest). Multi-account dispatcher: one `target` per AWS account to collect, each with +# ITS OWN agent (tags), role (AssumeRole ARN + ExternalId) and region. Fans out +# `wf1-aws-billing-daily` once per target and summarizes. The association of +# facts → account is NOT taken from this config: every fact carries +# `cloud_account` from the STS identity the worker actually ran with. +# +# targets (variable `targets`, JSON array — set it on the customer's revision or +# from a config entry): [{ "name": "prod", "agent_tags": {"cluster":"runtime"}, "agent_nrn": "organization=4:account=17", +# "assume_role_arn": "arn:aws:iam::111122223333:role/np-finops", "assume_role_external_id": "…", +# "region": "us-east-1", "expected_account": "111122223333" }] +# `expected_account` (optional) makes the run fail if the worker's identity is a +# different account — catches a wrong role/agent pairing. +id: finops_aws_billing_dispatch +name: "FinOps — AWS billing dispatch (multi-account)" +description: > + Runs the daily AWS billing collector once per configured target (account × + agent × role) and summarizes facts written per account. +path: "/finops" +semantic_version: 0.1.0 + +inputs: + date: + type: string + required: false + description: "Day to collect, YYYY-MM-DD (UTC). Default: yesterday." + targets: + type: array + required: false + description: "Override of variables.targets: [{name, agent_tags, agent_nrn?, org_nrn?, assume_role_arn?, assume_role_external_id?, region?, expected_account?}]" + callback_base_url: + type: string + required: false + description: "Engine URL the workers can reach (local dev only)" + dry_run: + type: boolean + required: false + description: "Build facts without writing (collection, allocation and suggestions)" + allocate: + type: boolean + required: false + description: "Run the allocator + suggestions after collection (default true)" + k8s: + type: boolean + required: false + description: "Collect Kubernetes consumption per scope for the configured clusters before allocating (default true)" + k8s_clusters: + type: array + required: false + description: "Override of variables.k8s_clusters: [{cluster}]" + +variables: + # true: wf0 runs the allocator as a child after k8s (one chain); false: wf2 runs on its own daily cron. + allocate_in_chain: + initialValue: true + targets: + initialValue: [] + # Kubernetes clusters whose consumption splits their cost: [{cluster}] (wf3 variables hold the agent + collector per org). + k8s_clusters: + initialValue: [] + +steps: + - id: start + type: trigger + plugin_type: manual + name: "Dispatch" + config: + description: "Collect one day of AWS billing for every configured account." + inputs: + date: { type: string, required: false, description: "YYYY-MM-DD (UTC)" } + targets: { type: array, required: false, description: "Targets override" } + callback_base_url: { type: string, required: false, description: "Engine URL for the worker callback" } + dry_run: { type: boolean, required: false, description: "Build facts without writing" } + allocate: { type: boolean, required: false, description: "Run allocator + suggestions after collection (default true)" } + k8s: { type: boolean, required: false, description: "Collect Kubernetes consumption before allocating (default true)" } + k8s_clusters: { type: array, required: false, description: "Clusters override" } + + - id: daily_cron + type: trigger + plugin_type: cron + name: "Daily 04:15 UTC" + config: + schedule: "15 4 * * *" + timezone: "UTC" + + - id: prep + type: module + plugin_type: code-exec + name: "Resolve targets" + join_strategy: any + inputs: + targets: "${{ workflow.inputs.targets }}" + default_targets: "${{ variables.targets }}" + date: "${{ workflow.inputs.date }}" + callback_base_url: "${{ workflow.inputs.callback_base_url }}" + dry_run: "${{ workflow.inputs.dry_run }}" + allocate: "${{ workflow.inputs.allocate }}" + allocate_default: "${{ variables.allocate_in_chain }}" + k8s: "${{ workflow.inputs.k8s }}" + k8s_clusters: "${{ workflow.inputs.k8s_clusters }}" + k8s_clusters_default: "${{ variables.k8s_clusters }}" + config: + language: javascript + code: | + var list = Array.isArray(inputs.targets) && inputs.targets.length ? inputs.targets : (Array.isArray(inputs.default_targets) ? inputs.default_targets : []); + if (!list.length) throw new Error("no targets configured (variables.targets or inputs.targets)"); + var seen = {}; + var runs = list.map(function (t, i) { + if (!t || typeof t !== "object") throw new Error("targets[" + i + "] must be an object"); + var name = String(t.name || ("target-" + i)); + if (seen[name]) throw new Error("duplicate target name " + name); seen[name] = true; + if (!t.agent_tags || typeof t.agent_tags !== "object" || !Object.keys(t.agent_tags).length) throw new Error("targets[" + i + "] (" + name + ") needs agent_tags"); + // Keys match wf1's inputs: the forEach spreads each target into the child's inputs. + return { target_name: name, agent_tags: t.agent_tags, agent_nrn: t.agent_nrn || null, org_nrn: t.org_nrn || null, assume_role_arn: t.assume_role_arn || null, assume_role_external_id: t.assume_role_external_id || null, + package_version: t.package_version || null, region: t.region || null, expected_account: t.expected_account ? String(t.expected_account) : null, account_dimensions: t.dimensions && typeof t.dimensions === "object" ? t.dimensions : null, + date: inputs.date || null, callback_base_url: inputs.callback_base_url || null, dry_run: !!inputs.dry_run }; + }); + function iso(dt) { return dt.toISOString().slice(0, 10); } + var day = inputs.date && /^\d{4}-\d{2}-\d{2}$/.test(String(inputs.date)) ? String(inputs.date) : iso(new Date(Date.now() - 86400000)); + // Default from variables.allocate_in_chain: false = the allocator runs on its own cron (wf2 05:00 UTC) + // instead of as a child here — see docs/customer-onboarding.md §5.1 (engine retry of long children). + var doAlloc = inputs.allocate === undefined || inputs.allocate === null || inputs.allocate === "" ? (inputs.allocate_default !== false && String(inputs.allocate_default) !== "false") : !!inputs.allocate; + var doK8s = inputs.k8s === undefined || inputs.k8s === null || inputs.k8s === "" ? true : !!inputs.k8s; + var clusters = Array.isArray(inputs.k8s_clusters) && inputs.k8s_clusters.length ? inputs.k8s_clusters : (Array.isArray(inputs.k8s_clusters_default) ? inputs.k8s_clusters_default : []); + var k8s = doK8s ? clusters.map(function (c) { return { date: day, cluster: String(c.cluster), dry_run: !!inputs.dry_run }; }) : []; + return { runs: runs, count: runs.length, day: day, k8s: k8s, allocate: doAlloc ? [{ date: day, dry_run: !!inputs.dry_run }] : [] }; + + - id: collect + type: module + plugin_type: sub-workflow + # Children return their facts/batches for tests and drill-down; the dispatcher only needs the summary + # (and the allocator's unallocated leaves). Keeps the parent's history and the next step's input small. + output_projection: ["items[].outputs.summary", "items[].outputs.unallocated_leaves", "items[]._childExecutionId", "summary", "unallocated_leaves"] + name: "Collect per account (fan-out)" + forEach: + expression: "${{ steps.prep.outputs.runs }}" + itemVariable: target + # Spread each target object into the child's inputs (its keys are wf1's input names). + spreadItem: true + parallel: true + maxConcurrency: 3 + config: + # Definition id of finops/wf1-aws-billing-daily.yaml (patched at publish time). + workflowId: FINOPS_AWS_BILLING_DAILY_ID + alias: live + waitForCompletion: true + + - id: summary + type: module + plugin_type: code-exec + name: "Summary per account" + metadata: { fanOutPerItem: false } + inputs: + runs: "${{ steps.prep.outputs.runs }}" + results: "${{ steps.collect.outputs.items }}" + config: + language: javascript + code: | + var runs = inputs.runs || []; var results = inputs.results || []; + var accounts = results.map(function (r, i) { + // live engine wraps each fan-out item as {outputs, _childExecutionId}; the E2E stub returns outputs flat + var o = (r || {}).outputs || r || {}; var s = o.summary || {}; + return { target: (runs[i] || {}).target_name || null, account: s.account || null, day: s.day || null, + daily_total_usd: s.daily_total_usd || 0, facts: s.facts || 0, written: s.written || 0, clusters: (s.clusters || []).length }; + }); + var total = accounts.reduce(function (a, x) { return a + (Number(x.daily_total_usd) || 0); }, 0); + return { targets: runs.length, accounts: accounts, total_usd: Math.round(total * 1e6) / 1e6 }; + + # ── Kubernetes consumption per scope for each configured cluster (needs the raw cluster row of the day). + - id: k8s + type: module + plugin_type: sub-workflow + # Children return their facts/batches for tests and drill-down; the dispatcher only needs the summary + # (and the allocator's unallocated leaves). Keeps the parent's history and the next step's input small. + output_projection: ["items[].outputs.summary", "items[].outputs.unallocated_leaves", "items[]._childExecutionId", "summary", "unallocated_leaves"] + name: "k8s consumption per scope (wf3)" + forEach: + expression: "${{ steps.prep.outputs.k8s }}" + itemVariable: run + spreadItem: true + config: + # Definition id of finops/wf3-k8s-consumption-daily.yaml (patched at publish time). + workflowId: FINOPS_K8S_CONSUMPTION_ID + alias: live + waitForCompletion: true + + # ── allocate the day (rules → allocated facts + rollup per application). One item, or none when allocate=false. + - id: allocate + type: module + plugin_type: sub-workflow + # Children return their facts/batches for tests and drill-down; the dispatcher only needs the summary + # (and the allocator's unallocated leaves). Keeps the parent's history and the next step's input small. + output_projection: ["items[].outputs.summary", "items[].outputs.unallocated_leaves", "items[]._childExecutionId", "summary", "unallocated_leaves"] + name: "Allocate the day (wf2)" + forEach: + expression: "${{ steps.prep.outputs.allocate }}" + itemVariable: run + spreadItem: true + config: + # Definition id of finops/wf2-allocate-daily.yaml (patched at publish time). + workflowId: FINOPS_ALLOCATE_DAILY_ID + alias: live + waitForCompletion: true + + - id: suggest_prep + type: module + plugin_type: code-exec + name: "Unallocated leaves → suggestion run" + metadata: { fanOutPerItem: false } + inputs: + day: "${{ steps.prep.outputs.day }}" + dry_run: "${{ workflow.inputs.dry_run }}" + results: "${{ steps.allocate.outputs.items }}" + config: + language: javascript + code: | + var r = (inputs.results || [])[0] || {}; var o = r.outputs || r || {}; + var leaves = o.unallocated_leaves || []; + var summary = o.summary || null; + return { allocation: summary, runs: leaves.length ? [{ date: String(inputs.day), unallocated_leaves: leaves, dry_run: !!inputs.dry_run }] : [] }; + + - id: suggest + type: module + plugin_type: sub-workflow + name: "Suggest mapping rules (evidence)" + forEach: + expression: "${{ steps.suggest_prep.outputs.runs }}" + itemVariable: run + spreadItem: true + config: + # Definition id of finops/wf-suggest-mappings.yaml (patched at publish time). + workflowId: FINOPS_SUGGEST_MAPPINGS_ID + alias: live + waitForCompletion: true + + - id: final + type: module + plugin_type: code-exec + name: "Daily summary" + metadata: { fanOutPerItem: false } + inputs: + collection: "${{ steps.summary.outputs }}" + k8s: "${{ steps.k8s.outputs.items }}" + allocation: "${{ steps.suggest_prep.outputs.allocation }}" + suggestions: "${{ steps.suggest.outputs.items }}" + config: + language: javascript + code: | + var sg = (inputs.suggestions || [])[0] || {}; var so = sg.outputs || sg || {}; + var k = (inputs.k8s || []).map(function (r) { var o = r.outputs || r || {}; return o.summary || null; }).filter(Boolean); + return { collection: inputs.collection || null, kubernetes: k, allocation: inputs.allocation || null, suggestions: so.summary || null }; + +connections: + - { from: start, to: prep } + - { from: daily_cron, to: prep } + - { from: prep, to: collect } + - { from: collect, to: summary } + - { from: summary, to: k8s } + - { from: k8s, to: allocate } + - { from: allocate, to: suggest_prep } + - { from: suggest_prep, to: suggest } + - { from: suggest, to: final } + +outputs: + summary: "${{ steps.final.outputs }}" diff --git a/finops/wf1-aws-billing-daily.yaml b/finops/wf1-aws-billing-daily.yaml new file mode 100644 index 0000000..e87e626 --- /dev/null +++ b/finops/wf1-aws-billing-daily.yaml @@ -0,0 +1,893 @@ +# finops/wf1-aws-billing-daily.yaml +# +# Daily collector (phase 1): one day of AWS billing → cost_daily facts. +# +# cloud-query (agent, IAM role) build_facts (code-exec) write_facts (fan-out) +# ───────────────────────────── ─────────────────────── ─────────────────────── +# CE by SERVICE → cloud_service facts (Σ = day) → finops_cost_fact_upsert +# CE by SERVICE + USAGE_TYPE → bucket facts (parent = service) one child per fact +# CE per EC2 resource (+ hours) → scope / resource facts +# EC2 DescribeInstances / Volumes → cluster facts + components +# Tagging API (LBs, RDS, cache…) → cluster LB share, DB facts +# EC2 DescribeInstanceTypes (2nd call) → capacity → blended rates +# +# Cost basis: AMORTIZED (cost_usd), unblended kept in unblended_usd. +# Sum rule: Σ cost_usd over subject_type=cloud_service for a day equals the +# AWS daily amortized total. Every other subject is an ATTRIBUTION of part of +# that total (bucket / resource / scope / cluster carry `parent_id` or are +# derived) and must not be added to it. +# +# Cluster = nodes + control plane + load balancers tagged to it + VPC-level +# networking (NAT, endpoints, public IPv4, data transfer) + EBS attached to +# its nodes + other EC2 charges (CPU credits) by instance share. The cluster is +# NOT split per scope here: it carries a BLENDED RATE per core-hour and per +# GB-hour over its reserved capacity (cpu_share configurable) so the allocator +# can charge consumers by consumption; idle capacity stays on the cluster. +# `dry_run: true` builds everything and writes nothing. +id: finops_aws_billing_daily +name: "FinOps — AWS billing daily" +description: > + Collects one day of AWS cost through the cloud-query package (Cost Explorer + by service, by usage type, per EC2 resource; EC2, volumes, tags, instance + types) and upserts dimensioned cost_daily facts: cloud services, buckets, + tagged scopes, EKS clusters with components and blended rates, databases. +path: "/finops" +semantic_version: 0.2.0 + +inputs: + date: + type: string + required: false + description: "Day to collect, YYYY-MM-DD (UTC). Default: yesterday." + agent_tags: + type: object + required: false + description: "Agent selector tags. Default: variables.agent_tags" + agent_nrn: + type: string + required: false + description: "NRN subtree of the agent (e.g. organization=4:account=17). Default: variables.agent_nrn" + org_nrn: + type: string + required: false + description: "Root NRN for listing null services (mapping DB hosts → services). Default: variables.org_nrn" + callback_base_url: + type: string + required: false + description: "Engine URL the worker can reach (local dev: http://host.docker.internal:)" + dry_run: + type: boolean + required: false + description: "When true, build the facts but do not write them" + assume_role_arn: + type: string + required: false + description: "Per-customer/account role the worker assumes for the AWS calls (default: variables.assume_role_arn). Empty = the pod's own identity." + assume_role_external_id: + type: string + required: false + description: "ExternalId for that role's trust policy (default: variables.assume_role_external_id)" + package_version: + type: string + required: false + description: "cloud-query version to run → selects the agent's worker pin and its SERVICE ACCOUNT (default: variables.package_version)" + target_name: + type: string + required: false + description: "Label of the dispatcher target (informational)" + expected_account: + type: string + required: false + description: "If set, the run FAILS when the worker's STS identity is another AWS account (wrong agent/role pairing)" + +variables: + account_dimensions: + type: object + required: false + description: "null dimensions the whole AWS account maps to (e.g. {environment: development}); stamped on every fact that has no scope/service of its own, so application-level charges carry the environment" + agent_tags: + initialValue: { package: "cloud-query" } + # Per-customer role configuration. Set these on the customer's revision (or + # pass the inputs): with an ARN the worker runs STS AssumeRole before every + # call; empty means the pod's own IAM identity (IRSA / Pod Identity). + assume_role_arn: + initialValue: "" + assume_role_external_id: + initialValue: "" + package_version: + initialValue: "" + # AWS region for regional SDK calls (EC2, ELB, RDS). Cost Explorer is global. + aws_region: + initialValue: "us-east-1" + ec2_service_name: + initialValue: "Amazon Elastic Compute Cloud - Compute" + ec2_other_service_name: + initialValue: "EC2 - Other" + eks_service_name: + initialValue: "Amazon Elastic Container Service for Kubernetes" + vpc_service_name: + initialValue: "Amazon Virtual Private Cloud" + elb_service_name: + initialValue: "Amazon Elastic Load Balancing" + rds_service_name: + initialValue: "Amazon Relational Database Service" + # Cost allocation tag that names the owner of RDS resources (nullplatform: `application`). + rds_owner_tag: + initialValue: "application" + # Cloud services whose resources carry the null tags (Lambda functions today): their cost is + # grouped by the active cost allocation tags `scope` × `application` and mapped to the scope id + # through the resource tags (tagging API). One CE call per service (CE groups by 2 keys max). + tagged_services: + initialValue: ["AWS Lambda"] + tagged_resource_types: + initialValue: ["lambda:function"] + # Share of the cluster cost attributed to CPU (the rest to memory) when + # deriving the blended rates. 0.5 = Kubecost-style even split; tune per org. + cpu_share: + initialValue: 0.5 + # Root NRN for listing null services (mapping cloud databases/caches → null service by host). + org_nrn: + initialValue: "organization=1255165411" + # NRN subtree of the agent; needed when the agent is registered under an account. + agent_nrn: + initialValue: "" + collector: + initialValue: "finops_aws_billing_daily@0.2.0" + +steps: + - id: start + type: trigger + plugin_type: manual + name: "Collect" + config: + description: "Collect one day of AWS billing into cost_daily facts." + inputs: + date: + type: string + required: false + description: "YYYY-MM-DD (UTC), default yesterday" + agent_tags: + type: object + required: false + description: "Agent selector tags" + agent_nrn: + type: string + required: false + description: "NRN subtree of the agent" + org_nrn: + type: string + required: false + description: "Root NRN for listing null services" + callback_base_url: + type: string + required: false + description: "Engine URL for the worker callback" + dry_run: + type: boolean + required: false + description: "Build facts without writing" + assume_role_arn: + type: string + required: false + description: "Role ARN the worker assumes" + assume_role_external_id: + type: string + required: false + description: "AssumeRole ExternalId" + package_version: + type: string + required: false + description: "cloud-query version (→ pin → service account)" + target_name: + type: string + required: false + description: "Dispatcher target label" + expected_account: + type: string + required: false + description: "Expected AWS account id" + + - id: prep + type: module + plugin_type: code-exec + name: "Day window + cloud-query calls" + join_strategy: any + inputs: + date: "${{ workflow.inputs.date }}" + agent_tags: "${{ workflow.inputs.agent_tags }}" + default_tags: "${{ variables.agent_tags }}" + agent_nrn: "${{ workflow.inputs.agent_nrn }}" + agent_nrn_default: "${{ variables.agent_nrn }}" + org_nrn: "${{ workflow.inputs.org_nrn }}" + org_nrn_default: "${{ variables.org_nrn }}" + region: "${{ variables.aws_region }}" + ec2_service: "${{ variables.ec2_service_name }}" + rds_service: "${{ variables.rds_service_name }}" + rds_owner_tag: "${{ variables.rds_owner_tag }}" + tagged_services: "${{ variables.tagged_services }}" + tagged_resource_types: "${{ variables.tagged_resource_types }}" + role_arn: "${{ workflow.inputs.assume_role_arn }}" + role_arn_default: "${{ variables.assume_role_arn }}" + role_ext: "${{ workflow.inputs.assume_role_external_id }}" + role_ext_default: "${{ variables.assume_role_external_id }}" + package_version: "${{ workflow.inputs.package_version }}" + package_version_default: "${{ variables.package_version }}" + config: + language: javascript + code: | + function iso(d) { return d.toISOString().slice(0, 10); } + var day = inputs.date && /^\d{4}-\d{2}-\d{2}$/.test(String(inputs.date)) ? String(inputs.date) : iso(new Date(Date.now() - 86400000)); + var next = iso(new Date(new Date(day + "T00:00:00Z").getTime() + 86400000)); + var period = { Start: day, End: next }; + var tags = (inputs.agent_tags && Object.keys(inputs.agent_tags).length) ? inputs.agent_tags : inputs.default_tags; + var agentNrn = String(inputs.agent_nrn || inputs.agent_nrn_default || "").trim(); + var orgNrn = String(inputs.org_nrn || inputs.org_nrn_default || "").trim(); + if (!/^organization=\d+/.test(orgNrn)) throw new Error("org_nrn must start with organization= (got " + orgNrn + ")"); + var calls = [ + { id: "identity", service: "sts", operation: "GetCallerIdentity" }, + { id: "by_service", service: "ce", operation: "GetCostAndUsage", + params: { TimePeriod: period, Granularity: "DAILY", Metrics: ["AmortizedCost", "UnblendedCost"], GroupBy: [{ Type: "DIMENSION", Key: "SERVICE" }] } }, + { id: "by_usage_type", service: "ce", operation: "GetCostAndUsage", + params: { TimePeriod: period, Granularity: "DAILY", Metrics: ["AmortizedCost", "UnblendedCost", "UsageQuantity"], GroupBy: [{ Type: "DIMENSION", Key: "SERVICE" }, { Type: "DIMENSION", Key: "USAGE_TYPE" }] } }, + // CloudWatch Logs: every log group (name + stored bytes). Phase 2 adds IncomingBytes per group for the + // day; both become metric_shares on the CloudWatch usage-type buckets (ingestion / storage) so a + // by_metric rule can hand log costs to the application each group belongs to. + { id: "log_groups", service: "logs", operation: "DescribeLogGroups", params: { limit: 50 }, paginate: true, maxPages: 20 }, + { id: "ec2_by_resource", service: "ce", operation: "GetCostAndUsageWithResources", + params: { TimePeriod: period, Granularity: "DAILY", Metrics: ["AmortizedCost", "UnblendedCost", "UsageQuantity"], GroupBy: [{ Type: "DIMENSION", Key: "RESOURCE_ID" }, { Type: "DIMENSION", Key: "INSTANCE_TYPE" }], + Filter: { Dimensions: { Key: "SERVICE", Values: [String(inputs.ec2_service)] } } } }, + // RDS by the owner tag (cost allocation tag `application`, active on the payer) × usage type: the real + // cost per cluster (tagged) and the untagged usage types (an untagged instance, backups) to place by class. + { id: "rds_by_tag", service: "ce", operation: "GetCostAndUsage", + params: { TimePeriod: period, Granularity: "DAILY", Metrics: ["AmortizedCost"], GroupBy: [{ Type: "TAG", Key: String(inputs.rds_owner_tag) }, { Type: "DIMENSION", Key: "USAGE_TYPE" }], + Filter: { Dimensions: { Key: "SERVICE", Values: [String(inputs.rds_service)] } } } }, + { id: "instances", service: "ec2", operation: "DescribeInstances", params: {} }, + { id: "volumes", service: "ec2", operation: "DescribeVolumes", params: {} }, + // Tags for the resources we attribute beyond EC2: load balancers (cluster + // ownership), databases, caches. Scoped by type to stay under the cap. + { id: "tagged", service: "tagging", operation: "GetResources", + params: { ResourceTypeFilters: ["elasticloadbalancing:loadbalancer", "rds:cluster", "rds:db", "elasticache:cluster", "elasticache:replicationgroup"], ResourcesPerPage: 100 }, maxPages: 20 }, + // All load balancers (the tagging API omits never-tagged resources, so it + // cannot be the denominator of the cluster's LB share). + { id: "lbs", service: "elbv2", operation: "DescribeLoadBalancers", params: {} }, + { id: "db_clusters", service: "rds", operation: "DescribeDBClusters", params: {} }, + { id: "db_instances", service: "rds", operation: "DescribeDBInstances", params: {} } + ]; + // Tagged services (Lambda…): cost by the null tags `scope` × `application`, plus the resources' full tag + // sets so the (application, scope) pair resolves to scope_id / application_id / namespace_id. + var tagged = Array.isArray(inputs.tagged_services) ? inputs.tagged_services.map(String).filter(Boolean) : []; + tagged.forEach(function (svc) { + calls.push({ id: "svc_by_tag_" + svc.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""), service: "ce", operation: "GetCostAndUsage", + params: { TimePeriod: period, Granularity: "DAILY", Metrics: ["AmortizedCost", "UnblendedCost"], GroupBy: [{ Type: "TAG", Key: "scope" }, { Type: "TAG", Key: "application" }], + Filter: { Dimensions: { Key: "SERVICE", Values: [svc] } } } }); + }); + var taggedTypes = Array.isArray(inputs.tagged_resource_types) ? inputs.tagged_resource_types.map(String).filter(Boolean) : []; + if (tagged.length && taggedTypes.length) calls.push({ id: "fn_tags", service: "tagging", operation: "GetResources", params: { ResourceTypeFilters: taggedTypes, ResourcesPerPage: 100 }, maxPages: 20 }); + var roleArn = String(inputs.role_arn || inputs.role_arn_default || "").trim(); + if (roleArn && !/^arn:aws:iam::\d{12}:role\/[\w+=,.@\/-]+$/.test(roleArn)) throw new Error("assume_role_arn is not an IAM role ARN: " + roleArn); + var roleExt = String(inputs.role_ext || inputs.role_ext_default || "").trim(); + var ver = String(inputs.package_version || inputs.package_version_default || "").trim(); + var acctNp = /account=(\d+)/.exec(String(agentNrn || "")); return { day: day, calls: calls, agent_tags: tags, agent_nrn: agentNrn || null, account_np_id: acctNp ? acctNp[1] : "", services_nrn: acctNp ? String(agentNrn) : orgNrn, org_nrn: orgNrn, region: String(inputs.region), assume_role_arn: roleArn || null, assume_role_external_id: roleExt || null, package_version: ver || null }; + + - id: query + type: module + plugin_type: sub-workflow + name: "cloud-query (agent)" + inputs: + agent_tags: "${{ steps.prep.outputs.agent_tags }}" + agent_nrn: "${{ steps.prep.outputs.agent_nrn }}" + calls: "${{ steps.prep.outputs.calls }}" + region: "${{ steps.prep.outputs.region }}" + callback_base_url: "${{ workflow.inputs.callback_base_url }}" + assume_role_arn: "${{ steps.prep.outputs.assume_role_arn }}" + assume_role_external_id: "${{ steps.prep.outputs.assume_role_external_id }}" + package_version: "${{ steps.prep.outputs.package_version }}" + config: + # Definition id of finops/tool-cloud-query.yaml (patched at publish time). + workflowId: FINOPS_CLOUD_QUERY_ID + alias: live + waitForCompletion: true + iterateItems: false + + - id: prep_types + type: module + plugin_type: code-exec + name: "Instance types in use" + inputs: + results: "${{ steps.query.outputs.results }}" + failed: "${{ steps.query.outputs.failed }}" + day: "${{ steps.prep.outputs.day }}" + config: + language: javascript + code: | + var failed = inputs.failed || []; + if (failed.length) throw new Error("cloud-query calls failed: " + failed.join(", ")); + var types = {}; + (((inputs.results || {}).instances || {}).Reservations || []).forEach(function (res) { + (res.Instances || []).forEach(function (i) { if (i.InstanceType) types[i.InstanceType] = true; }); + }); + // types of instances that already terminated (Cost Explorer resource rows carry INSTANCE_TYPE) + (((inputs.results || {}).ec2_by_resource || {}).ResultsByTime || []).forEach(function (rt) { (rt.Groups || []).forEach(function (g) { var t = g.Keys && g.Keys[1]; if (t && t !== "NoInstanceType" && /^[a-z0-9]+\.[a-z0-9]+$/.test(t)) types[t] = true; }); }); + var list = Object.keys(types).sort(); + var calls = [{ id: "instance_types", service: "ec2", operation: "DescribeInstanceTypes", params: { InstanceTypes: list.length ? list : ["t3.micro"] } }]; + // Performance Insights: DB load by DATABASE for each RDS subject (the writer of an Aurora + // cluster, or the instance itself). This is what splits a shared cluster by consumer. + var day = String(inputs.day); var next = new Date(new Date(day + "T00:00:00Z").getTime() + 86400000).toISOString().slice(0, 10); + var byInst = {}; (((inputs.results || {}).db_instances || {}).DBInstances || []).forEach(function (i) { byInst[i.DBInstanceIdentifier] = i; }); + var pi = []; + (((inputs.results || {}).db_clusters || {}).DBClusters || []).forEach(function (c) { + var w = (c.DBClusterMembers || []).filter(function (m) { return m.IsClusterWriter; })[0] || (c.DBClusterMembers || [])[0]; + var inst = w ? byInst[w.DBInstanceIdentifier] : null; + if (inst && inst.PerformanceInsightsEnabled && inst.DbiResourceId) pi.push({ subject: c.DBClusterIdentifier, res: inst.DbiResourceId, engine: c.Engine || inst.Engine }); + }); + Object.keys(byInst).forEach(function (id) { var i = byInst[id]; if (!i.DBClusterIdentifier && i.PerformanceInsightsEnabled && i.DbiResourceId) pi.push({ subject: id, res: i.DbiResourceId, engine: i.Engine }); }); + pi.forEach(function (p) { + // Performance Insights ServiceType: DocumentDB clusters are "DOCDB" (an RDS ServiceType is rejected: "invalid for engine chimera") + calls.push({ id: "pi_" + p.subject, service: "pi", operation: "GetResourceMetrics", params: { ServiceType: /docdb/i.test(String(p.engine || "")) ? "DOCDB" : "RDS", Identifier: p.res, StartTime: day + "T00:00:00Z", EndTime: next + "T00:00:00Z", PeriodInSeconds: 86400, + MetricQueries: [{ Metric: "db.load.avg", GroupBy: { Group: "db", Limit: 25 } }] } }); + }); + // CloudWatch Logs ingestion per log group for the day (AWS/Logs IncomingBytes, Sum). Query ids must be + // [a-z][a-zA-Z0-9_]*, so the id → log group name map travels to build_facts as log_metric_ids. + var groups = (((inputs.results || {}).log_groups || {}).logGroups || []).map(function (g) { return g.logGroupName; }).filter(Boolean); + var logIds = {}; var chunk = 100; + for (var ci = 0; ci < groups.length; ci += chunk) { + var qs = groups.slice(ci, ci + chunk).map(function (name, j) { var id = "q" + (ci + j); logIds[id] = name; + return { Id: id, MetricStat: { Metric: { Namespace: "AWS/Logs", MetricName: "IncomingBytes", Dimensions: [{ Name: "LogGroupName", Value: name }] }, Period: 86400, Stat: "Sum" } }; }); + calls.push({ id: "cw_logs_in_" + (ci / chunk), service: "cloudwatch", operation: "GetMetricData", params: { StartTime: day + "T00:00:00Z", EndTime: next + "T00:00:00Z", MetricDataQueries: qs } }); + } + return { types: list, pi_subjects: pi.map(function (p) { return p.subject; }), log_groups: groups.length, calls: calls, log_metric_ids: logIds }; + + - id: query_types + type: module + plugin_type: sub-workflow + name: "cloud-query: instance types" + inputs: + agent_tags: "${{ steps.prep.outputs.agent_tags }}" + agent_nrn: "${{ steps.prep.outputs.agent_nrn }}" + calls: "${{ steps.prep_types.outputs.calls }}" + region: "${{ steps.prep.outputs.region }}" + callback_base_url: "${{ workflow.inputs.callback_base_url }}" + assume_role_arn: "${{ steps.prep.outputs.assume_role_arn }}" + assume_role_external_id: "${{ steps.prep.outputs.assume_role_external_id }}" + package_version: "${{ steps.prep.outputs.package_version }}" + config: + workflowId: FINOPS_CLOUD_QUERY_ID + alias: live + waitForCompletion: true + iterateItems: false + + # null services (dependencies) with their attributes: a cloud database maps to + # the service whose `attributes.host` is its endpoint. No tags needed. + - id: np_services + type: module + plugin_type: np-api-call + name: "null services (descendants)" + inputs: + day: "${{ steps.prep.outputs.day }}" + # Inputs override config in np-api-call; the NRN is per run. The listing is capped at 500 rows, + # so it is scoped to the AGENT's subtree (the account) when there is one — an org can have thousands. + query: + nrn: "${{ steps.prep.outputs.services_nrn }}" + show_descendants: "true" + limit: "500" + config: + apiKey: "${{ secrets.NP_API_KEY }}" + method: GET + path: "/service" + query: + nrn: "${{ variables.org_nrn }}" + show_descendants: "true" + limit: "500" + failOnHttpError: false + + - id: np_apps + type: module + plugin_type: np-api-call + name: "null applications of the agent's account" + inputs: + day: "${{ steps.prep.outputs.day }}" + query: + nrn: "${{ steps.prep.outputs.agent_nrn }}" + limit: "200" + config: + apiKey: "${{ secrets.NP_API_KEY }}" + method: GET + path: "/application" + query: + nrn: "${{ variables.agent_nrn }}" + limit: "200" + failOnHttpError: false + + - id: np_namespaces + type: module + plugin_type: np-api-call + name: "null namespaces of the account (slugs for . names)" + inputs: + day: "${{ steps.prep.outputs.day }}" + query: + account_id: "${{ steps.prep.outputs.account_np_id }}" + limit: "100" + config: + apiKey: "${{ secrets.NP_API_KEY }}" + method: GET + path: "/namespace" + query: + limit: "100" + failOnHttpError: false + + - id: build_facts + type: module + plugin_type: code-exec + name: "Build cost_daily facts" + inputs: + day: "${{ steps.prep.outputs.day }}" + np_services: "${{ steps.np_services.outputs.body }}" + np_apps: "${{ steps.np_apps.outputs.body }}" + np_namespaces: "${{ steps.np_namespaces.outputs.body }}" + results: "${{ steps.query.outputs.results }}" + types_results: "${{ steps.query_types.outputs.results }}" + log_metric_ids: "${{ steps.prep_types.outputs.log_metric_ids }}" + identity: "${{ steps.query.outputs.identity }}" + failed: "${{ steps.query_types.outputs.failed }}" + region: "${{ steps.prep.outputs.region }}" + ec2_service: "${{ variables.ec2_service_name }}" + ec2_other_service: "${{ variables.ec2_other_service_name }}" + eks_service: "${{ variables.eks_service_name }}" + vpc_service: "${{ variables.vpc_service_name }}" + elb_service: "${{ variables.elb_service_name }}" + rds_service: "${{ variables.rds_service_name }}" + rds_owner_tag: "${{ variables.rds_owner_tag }}" + tagged_services: "${{ variables.tagged_services }}" + tagged_resource_types: "${{ variables.tagged_resource_types }}" + cpu_share: "${{ variables.cpu_share }}" + collector: "${{ variables.collector }}" + dry_run: "${{ workflow.inputs.dry_run }}" + expected_account: "${{ workflow.inputs.expected_account }}" + account_dimensions: "${{ workflow.inputs.account_dimensions }}" + target_name: "${{ workflow.inputs.target_name }}" + config: + language: javascript + code: | + var d = String(inputs.day); + var r = inputs.results || {}; + var failed = inputs.failed || []; + if (failed.length) throw new Error("cloud-query calls failed: " + failed.join(", ")); + function slug(s) { return String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); } + var acct = (inputs.identity && inputs.identity.account) ? String(inputs.identity.account) : null; + // The account comes from the STS identity the worker ACTUALLY ran with (after AssumeRole), + // never from config. `expected_account` guards against a wrong agent/role pairing. + if (inputs.expected_account && String(inputs.expected_account) !== String(acct)) throw new Error("worker identity is account " + acct + " but target " + (inputs.target_name || "?") + " expects " + inputs.expected_account); + var region = String(inputs.region); + var now = new Date().toISOString(); + var cpuShare = Number(inputs.cpu_share); if (!(cpuShare >= 0 && cpuShare <= 1)) cpuShare = 0.5; + function num(x) { var n = Number(x); return isFinite(n) ? Math.round(n * 1e6) / 1e6 : 0; } + // Cost basis: AMORTIZED (Savings Plans / RIs spread over the usage they cover). + // Unblended shows on-demand equivalents per resource plus a negation line + // without resource (seen in kwik: -22.85/day on NoResourceId), useless for + // attribution. Unblended is kept alongside for transparency. + function amort(g) { return num((g.Metrics.AmortizedCost || g.Metrics.UnblendedCost || {}).Amount); } + function unbl(g) { return num((g.Metrics.UnblendedCost || {}).Amount); } + function qty(g) { return num((g.Metrics.UsageQuantity || {}).Amount); } + function base(stage, type, id) { + // Logical id: every part is also a field. Dashes only — the catalog path rejects ':' and '/'. + var acctDims = inputs.account_dimensions && typeof inputs.account_dimensions === "object" && Object.keys(inputs.account_dimensions).length ? inputs.account_dimensions : null; + return { id: stage + "-" + type + "-" + slug(id) + "-" + d, date: d, day: d, stage: stage, subject_type: type, subject_id: id, + cloud: "aws", cloud_account: acct, source: "aws_ce", collected_at: now, collector: String(inputs.collector), + dimensions: acctDims, environment: acctDims && acctDims.environment ? String(acctDims.environment) : null, + allocation_method: "unallocated" }; + } + // Tag conventions seen in the wild: plain (EC2/Lambda/ECR) and `nullplatform:`-prefixed (API Gateway). + function tagMap(list) { var t = {}; (list || []).forEach(function (kv) { t[kv.Key] = kv.Value; }); return t; } + function npDims(t) { + function pick() { for (var i = 0; i < arguments.length; i++) { var v = t[arguments[i]]; if (v !== undefined && v !== null && v !== "") return String(v); } return null; } + return { scope_id: pick("scope_id", "nullplatform:scope-id", "np:scope_id"), scope_name: pick("scope", "nullplatform:scope", "np:scope"), + application_id: pick("application_id", "nullplatform:application-id"), application_name: pick("application", "nullplatform:application"), + namespace_id: pick("namespace_id", "nullplatform:namespace-id"), namespace_name: pick("namespace", "nullplatform:namespace"), + account_id: pick("account_id", "nullplatform:account-id"), account_name: pick("account", "nullplatform:account"), + environment: pick("environment", "nullplatform:environment", "env") }; + } + function clusterOf(t) { + var c = t["eks:cluster-name"] || t["aws:eks:cluster-name"] || t["elbv2.k8s.aws/cluster"] || null; + if (!c) Object.keys(t).forEach(function (k) { var m = /^kubernetes\.io\/cluster\/(.+)$/.exec(k); if (m) c = m[1]; }); + return c; + } + var facts = []; + // ── 1) cloud_service: the org-level truth, one per AWS service. Σ = daily total. + var svcTotal = {}, svcUnbl = {}; + ((r.by_service || {}).ResultsByTime || []).forEach(function (rt) { + (rt.Groups || []).forEach(function (g) { var svc = g.Keys[0]; svcTotal[svc] = (svcTotal[svc] || 0) + amort(g); svcUnbl[svc] = (svcUnbl[svc] || 0) + unbl(g); }); + }); + var svcFactId = {}; + Object.keys(svcTotal).forEach(function (svc) { + var f = base("raw", "cloud_service", slug(svc)); + f.subject_name = svc; f.cloud_service = svc; f.cost_usd = num(svcTotal[svc]); f.unblended_usd = num(svcUnbl[svc]); + svcFactId[svc] = f.id; facts.push(f); + }); + // CloudWatch Logs shares by log group: stored bytes (phase 1) and IncomingBytes of the day (phase 2). + function normShares(m, metric) { var tot = 0; Object.keys(m).forEach(function (k) { tot += m[k]; }); var out = {}; if (tot > 0) Object.keys(m).forEach(function (k) { if (m[k] > 0) out[k] = num(m[k] / tot); }); return { metric: metric, shares: out, total: tot }; } + var lgStored = {}; ((r.log_groups || {}).logGroups || []).forEach(function (g) { if (g.logGroupName && Number(g.storedBytes) > 0) lgStored[g.logGroupName] = Number(g.storedBytes); }); + var lgIn = {}; var logIds = inputs.log_metric_ids || {}; + Object.keys(inputs.types_results || {}).forEach(function (k) { if (k.indexOf("cw_logs_in_") !== 0) return; + ((inputs.types_results[k] || {}).MetricDataResults || []).forEach(function (m) { var name = logIds[m.Id]; var v = (m.Values || []).reduce(function (a, b) { return a + (Number(b) || 0); }, 0); if (name && v > 0) lgIn[name] = v; }); }); + var logStored = normShares(lgStored, "cloudwatch.StoredBytes"); var logIn = normShares(lgIn, "cloudwatch.IncomingBytes"); + // null applications keyed . — the naming convention of the platform's + // log groups (`backend.cart-service`, `.http_agg`, `.sys_agg`) and of its EMF metric namespaces. + function listOf(x) { return Array.isArray(x) ? x : ((x && (x.results || x.data)) || []); } + var nsSlug = {}; listOf(inputs.np_namespaces).forEach(function (n) { if (n && n.id != null) nsSlug[String(n.id)] = n.slug || null; }); + var appOwners = {}; + listOf(inputs.np_apps).forEach(function (ap) { if (!ap || ap.id == null || !ap.slug) return; var ns = nsSlug[String(ap.namespace_id)]; if (!ns) return; + appOwners[ns + "." + ap.slug] = { application_id: String(ap.id), namespace_id: String(ap.namespace_id), application_slug: String(ap.slug), namespace_slug: String(ns) }; }); + function appKeyOf(name) { var m = /^([a-z0-9-]+\.[a-z0-9-]+)(\..+)?$/i.exec(String(name || "")); return m && appOwners[m[1]] ? m[1] : null; } + var lgOwners = {}; Object.keys(lgStored).concat(Object.keys(lgIn)).forEach(function (g) { var k = appKeyOf(g); if (k) lgOwners[g] = appOwners[k]; }); + // EMF metrics (`MetricStorage:AWS/Logs-EMF`, and the metric stream that forwards them) are produced from the + // `*_agg` log groups: their IncomingBytes per application are the proxy for metric updates / custom metrics. + var emfBytes = {}; Object.keys(lgIn).forEach(function (g) { if (!/_agg$/.test(g)) return; var k = appKeyOf(g); if (k) emfBytes[k] = (emfBytes[k] || 0) + lgIn[g]; }); + var emf = normShares(emfBytes, "cloudwatch.EmfBytes"); + // ── 2) bucket per (service, usage_type): breakdown of the service fact (parent_id). + var usage = []; // [{svc, ut, cost, qty, unit}] + ((r.by_usage_type || {}).ResultsByTime || []).forEach(function (rt) { + (rt.Groups || []).forEach(function (g) { + var svc = g.Keys[0], ut = g.Keys[1]; var c = amort(g); + if (c === 0 && unbl(g) === 0) return; + usage.push({ svc: svc, ut: ut, cost: c, qty: qty(g), unit: g.Metrics.UsageQuantity ? g.Metrics.UsageQuantity.Unit : null }); + var f = base("raw", "bucket", slug(svc) + "|" + slug(ut)); + f.subject_name = svc + " / " + ut; f.cloud_service = svc; f.usage_type = ut; f.cost_usd = c; f.unblended_usd = unbl(g); + // CloudWatch Logs buckets carry the per-log-group shares (ingestion ← IncomingBytes of the day, + // storage ← storedBytes). A by_metric rule keyed by log group name splits them by application. + if (/cloudwatch/i.test(svc)) { + var lgShares = /DataProcessing|VendedLog|Ingest/i.test(ut) ? logIn : /TimedStorage|StorageUsage/i.test(ut) ? logStored : /MetricMonitorUsage|MetricStreamUsage/i.test(ut) ? emf : null; + if (lgShares && lgShares.total > 0) { + f.metric = lgShares.metric; f.metric_shares = lgShares.shares; + var own = {}; Object.keys(lgShares.shares).forEach(function (k) { var o = lgShares === emf ? appOwners[k] : lgOwners[k]; if (o) own[k] = o; }); + if (Object.keys(own).length) f.metric_owners = own; + } + } + f.quantity = qty(g); f.units = f.quantity ? (g.Metrics.UsageQuantity ? g.Metrics.UsageQuantity.Unit : null) : null; + f.parent_id = svcFactId[svc] || null; facts.push(f); + }); + }); + function sumUsage(svc, re) { return num(usage.filter(function (u) { return u.svc === svc && (!re || re.test(u.ut)); }).reduce(function (a, u) { return a + u.cost; }, 0)); } + // ── 3) inventory: instances (tags, type), volumes (attachments), instance types (capacity), tagged resources. + var inst = {}; // id -> {type, tags, cluster} + ((r.instances || {}).Reservations || []).forEach(function (res) { + (res.Instances || []).forEach(function (i) { var t = tagMap(i.Tags); inst[i.InstanceId] = { type: i.InstanceType, tags: t, cluster: clusterOf(t) }; }); + }); + var typeCap = {}; // type -> {vcpu, gib} + (((inputs.types_results || {}).instance_types || {}).InstanceTypes || []).forEach(function (it) { + typeCap[it.InstanceType] = { vcpu: Number((it.VCpuInfo || {}).DefaultVCpus || 0), gib: Number((it.MemoryInfo || {}).SizeInMiB || 0) / 1024 }; + }); + var volGbByInstance = {}, volGbTotal = 0; + ((r.volumes || {}).Volumes || []).forEach(function (v) { + var gb = Number(v.Size || 0); volGbTotal += gb; + (v.Attachments || []).forEach(function (a) { if (a.InstanceId) volGbByInstance[a.InstanceId] = (volGbByInstance[a.InstanceId] || 0) + gb; }); + }); + var tagged = ((r.tagged || {}).ResourceTagMappingList || []).map(function (x) { return { arn: x.ResourceARN, tags: tagMap(x.Tags) }; }); + var lbsTagged = tagged.filter(function (x) { return /:elasticloadbalancing:.*:loadbalancer\//.test(x.arn); }); + var lbsByCluster = {}; lbsTagged.forEach(function (x) { var c = clusterOf(x.tags); if (c) lbsByCluster[c] = (lbsByCluster[c] || 0) + 1; }); + var lbsAll = ((r.lbs || {}).LoadBalancers || []).length || lbsTagged.length; + // ── 4) EC2 instances (CE resource-level) × tags. One row per SUBJECT, not per instance: + // scope-tagged instance → `scope` fact (direct); cluster node → summed into the cluster's + // `nodes` component (no per-node row); anything else that is an instance → ONE + // `bucket` row per day (`ec2-instances-unattributed`: mostly nodes that terminated + // before collection — Karpenter churn — plus untagged VMs); non-instance resources + // (NAT gateways, …) keep a `resource` row. Keeps the day at tens of rows, not hundreds. + var ec2ParentId = svcFactId[String(inputs.ec2_service)] || null; + var cl = {}; // cluster -> {nodes, nodeIds[], coreH, gbH, volGb, instCount} + function clusterAcc(name) { return cl[name] || (cl[name] = { nodes: 0, nodesTerminated: 0, terminatedCount: 0, nodeIds: [], coreH: 0, gbH: 0, volGb: 0, instCount: 0 }); } + var totalInstances = Object.keys(inst).length; + var unattr = { cost: 0, unbl: 0, hours: 0, ids: 0, seen: 0, byType: {} }; + // Node instance types per cluster (from the instances still running): an instance that terminated + // before collection has no tags for us, but Cost Explorer still knows its type — if the type is a + // node type of a cluster, it was a node (Karpenter churn). Component `nodes_terminated`, kept apart. + var nodeTypeClusters = {}; Object.keys(inst).forEach(function (id) { var x = inst[id]; if (x.cluster && x.type) { nodeTypeClusters[x.type] = nodeTypeClusters[x.type] || {}; nodeTypeClusters[x.type][x.cluster] = (nodeTypeClusters[x.type][x.cluster] || 0) + 1; } }); + function inferCluster(type) { var m = nodeTypeClusters[type]; if (!m) return null; return Object.keys(m).sort(function (a, b) { return m[b] - m[a]; })[0]; } + // Accounts without Cost Explorer resource-level data return one row per INSTANCE_TYPE with + // RESOURCE_ID = NoResourceId. Those instance-hours still belong to a cluster when the running + // nodes of that type do (or when the account has a single cluster): price them into the + // cluster row (cost + capacity by type) instead of leaving the whole of EC2 as a bucket. + var clusterSet = {}; Object.keys(inst).forEach(function (id) { if (inst[id].cluster) clusterSet[inst[id].cluster] = true; }); + var soleCluster = Object.keys(clusterSet).length === 1 ? Object.keys(clusterSet)[0] : null; + ((r.ec2_by_resource || {}).ResultsByTime || []).forEach(function (rt) { + (rt.Groups || []).forEach(function (g) { + var rid = g.Keys[0]; var c = amort(g); var hours = qty(g); + if (!rid || (c === 0 && hours === 0)) return; + if (rid === "NoResourceId") { + var nt = g.Keys[1] && g.Keys[1] !== "NoInstanceType" ? String(g.Keys[1]) : null; if (!nt) return; + var ncl = inferCluster(nt) || soleCluster; if (!ncl) return; + var na = clusterAcc(ncl); var ncap = typeCap[nt] || { vcpu: 0, gib: 0 }; + na.nodes = num(na.nodes + c); na.instCount += 1; na.coreH += ncap.vcpu * hours; na.gbH += ncap.gib * hours; na.byType = na.byType || {}; na.byType[nt] = num((na.byType[nt] || 0) + c); + return; + } + var isInstance = /^i-[0-9a-z]+$/i.test(rid); + var ceType = g.Keys[1] && g.Keys[1] !== "NoInstanceType" ? String(g.Keys[1]) : null; + var i = inst[rid] || { type: ceType, tags: {}, cluster: null, gone: true }; + if (!i.type && ceType) i.type = ceType; + var t = i.tags; var dims = npDims(t); var f; + if (i.cluster) { + var a = clusterAcc(i.cluster); var cap = typeCap[i.type] || { vcpu: 0, gib: 0 }; + a.nodes += c; a.nodeIds.push(rid); a.instCount += 1; a.coreH += cap.vcpu * hours; a.gbH += cap.gib * hours; a.volGb += (volGbByInstance[rid] || 0); + return; // node cost lives in the cluster row (component `nodes`) + } + if (i.gone && isInstance && i.type && inferCluster(i.type)) { + var ac = clusterAcc(inferCluster(i.type)); var cp = typeCap[i.type] || { vcpu: 0, gib: 0 }; + ac.nodesTerminated = num((ac.nodesTerminated || 0) + c); ac.terminatedCount = (ac.terminatedCount || 0) + 1; ac.coreH += cp.vcpu * hours; ac.gbH += cp.gib * hours; + return; + } + if (dims.scope_id) { + f = base("raw", "scope", dims.scope_id); f.allocation_method = "direct_tag"; + Object.keys(dims).forEach(function (k) { f[k] = dims[k]; }); + f.subject_name = t.Name || dims.scope_name || rid; + } else if (isInstance) { + unattr.cost += c; unattr.unbl += unbl(g); unattr.hours += hours; unattr.ids += 1; if (inst[rid]) unattr.seen += 1; + if (i.type) unattr.byType[i.type] = (unattr.byType[i.type] || 0) + 1; + return; + } else { + f = base("raw", "resource", rid); f.subject_name = t.Name || rid; + } + var rtype = isInstance ? "ec2:instance" : (function () { var m = /^arn:aws:([^:]+):[^:]*:[^:]*:([^\/:]+)/.exec(rid); return m ? (m[1] + ":" + m[2]) : "ec2:other"; })(); + f.cloud_service = String(inputs.ec2_service); f.resource_id = rid; f.resource_type = rtype; f.usage_type = i.type || null; + f.region = region; f.cluster = i.cluster; f.cost_usd = c; f.unblended_usd = unbl(g); f.quantity = hours; f.units = hours ? "Hrs" : null; + f.tags = Object.keys(t).length ? t : null; f.parent_id = ec2ParentId; facts.push(f); + }); + }); + if (unattr.ids > 0) { + var u = base("raw", "bucket", "ec2-instances-unattributed"); u.subject_name = "EC2 instances not attributable (terminated before collection or untagged)"; + u.cloud_service = String(inputs.ec2_service); u.resource_type = "ec2:instance"; u.region = region; u.parent_id = ec2ParentId; + u.cost_usd = num(unattr.cost); u.unblended_usd = num(unattr.unbl); u.quantity = unattr.ids; u.units = "instances"; + u.usage_hours = num(unattr.hours); u.instances_seen = unattr.seen; u.instance_types = unattr.byType; u.allocation_method = "unallocated"; + facts.push(u); + } + // ── 5) clusters: nodes + control plane + LBs + networking + storage + other, and blended rates. + var clusters = Object.keys(cl); + var nClusters = clusters.length || 1; + var eks = num(svcTotal[String(inputs.eks_service)] || 0); + var elbTotal = num(svcTotal[String(inputs.elb_service)] || 0); + var vpcTotal = num(svcTotal[String(inputs.vpc_service)] || 0); + var ec2OtherSvc = String(inputs.ec2_other_service); + var netRe = /NatGateway|DataTransfer|PublicIPv4|VpcEndpoint|In-Bytes|Out-Bytes/i, ebsRe = /EBS:/i; + var ec2OtherNet = sumUsage(ec2OtherSvc, netRe), ec2OtherEbs = sumUsage(ec2OtherSvc, ebsRe); + var ec2OtherRest = num((svcTotal[ec2OtherSvc] || 0) - ec2OtherNet - ec2OtherEbs); + var clusterFacts = []; + clusters.forEach(function (name) { + var a = cl[name]; + // component → [cost, cloud service it comes from]; one row each so the allocator can + // reconcile every cloud service: total = Σ resource rows + Σ cluster components + remainder. + var compSvc = { + nodes: [num(a.nodes), String(inputs.ec2_service)], + nodes_terminated: [num(a.nodesTerminated || 0), String(inputs.ec2_service)], + control_plane: [num(eks / nClusters), String(inputs.eks_service)], + load_balancers: [num(lbsAll ? elbTotal * ((lbsByCluster[name] || 0) / lbsAll) : 0), String(inputs.elb_service)], + networking: [num(vpcTotal / nClusters), String(inputs.vpc_service)], + networking_ec2: [num(ec2OtherNet / nClusters), ec2OtherSvc], + storage: [num(volGbTotal ? ec2OtherEbs * (a.volGb / volGbTotal) : 0), ec2OtherSvc], + other: [num(totalInstances ? ec2OtherRest * (a.instCount / totalInstances) : 0), ec2OtherSvc] + }; + var comp = {}; Object.keys(compSvc).forEach(function (k) { comp[k] = compSvc[k][0]; }); + var total = num(Object.keys(comp).reduce(function (s, k) { return s + comp[k]; }, 0)); + var f = base("raw", "cluster", name); + f.subject_name = name; f.cluster = name; f.region = region; f.cost_usd = total; f.parent_id = null; + f.cpu_capacity_core_h = num(a.coreH); f.mem_capacity_gb_h = num(a.gbH); f.cpu_share = cpuShare; + f.rate_cpu_usd_core_h = a.coreH ? num(total * cpuShare / a.coreH) : null; + f.rate_mem_usd_gb_h = a.gbH ? num(total * (1 - cpuShare) / a.gbH) : null; + f.quantity = a.instCount + (a.terminatedCount || 0); f.units = "nodes"; f.instances_seen = a.instCount; + facts.push(f); + Object.keys(comp).forEach(function (k) { + var b = base("raw", "bucket", name + "|" + k); + b.subject_name = name + " / " + k; b.cluster = name; b.region = region; b.component = k; b.cost_usd = comp[k]; b.cloud_service = compSvc[k][1]; b.parent_id = f.id; facts.push(b); + }); + clusterFacts.push({ cluster: name, cost_usd: total, components: comp, nodes: a.instCount, core_h: num(a.coreH), gb_h: num(a.gbH), + rate_cpu_usd_core_h: f.rate_cpu_usd_core_h, rate_mem_usd_gb_h: f.rate_mem_usd_gb_h, lbs: lbsByCluster[name] || 0, ebs_gb: a.volGb }); + }); + // ── 6) databases (RDS clusters + instances): the RDS service cost split evenly across DB subjects, tags → null dims. + var dbs = []; + (((r.db_clusters || {}).DBClusters) || []).forEach(function (c) { dbs.push({ id: c.DBClusterIdentifier, arn: c.DBClusterArn, kind: "rds:cluster", engine: c.Engine, tags: tagMap(c.TagList), hosts: [c.Endpoint, c.ReaderEndpoint] }); }); + (((r.db_instances || {}).DBInstances) || []).forEach(function (i) { if (!i.DBClusterIdentifier) dbs.push({ id: i.DBInstanceIdentifier, arn: i.DBInstanceArn, kind: "rds:db", engine: i.Engine, tags: tagMap(i.TagList), hosts: [(i.Endpoint || {}).Address] }); }); + // null services indexed by host (attributes.host / attributes.hostname), lower-cased. + var svcList = inputs.np_services; svcList = Array.isArray(svcList) ? svcList : ((svcList && (svcList.results || svcList.data)) || []); + var svcByHost = {}; + svcList.forEach(function (sv) { + var at = sv.attributes || {}; [at.host, at.hostname, at.endpoint].forEach(function (h) { if (h) svcByHost[String(h).toLowerCase()] = sv; }); + }); + function nrnParts(nrn) { var o = {}; String(nrn || "").split(":").forEach(function (kv) { var p = kv.split("="); if (p.length === 2) o[p[0]] = p[1]; }); return o; } + var rdsDbs = dbs.filter(function (db) { return !/docdb/i.test(String(db.engine || "")); }); + var rdsTotal = num(svcTotal[String(inputs.rds_service)] || 0); + // Cost per RDS subject: Cost Explorer by owner tag (exact per tag value; clusters sharing a value split + // it evenly) + untagged usage types placed by instance class (an untagged instance of a tagged cluster) + // + whatever is left spread in proportion. Without tag data at all → even split (old behaviour). + var ownerTag = String(inputs.rds_owner_tag || "application"); var tagCost = {}; var untagged = []; var tagRows = 0; + ((r.rds_by_tag || {}).ResultsByTime || []).forEach(function (rt) { (rt.Groups || []).forEach(function (g) { + var tv = String(g.Keys[0] || ""); var ut = String(g.Keys[1] || ""); var c = amort(g); tagRows += 1; + var val = tv.indexOf("$") >= 0 ? tv.slice(tv.indexOf("$") + 1) : tv; + if (val) tagCost[val] = num((tagCost[val] || 0) + c); else untagged.push({ ut: ut, cost: c }); + }); }); + var byInstClass = {}; (((r.db_instances || {}).DBInstances) || []).forEach(function (i) { var cls = String(i.DBInstanceClass || "").replace(/^db\./, ""); var subj = i.DBClusterIdentifier || i.DBInstanceIdentifier; if (cls) (byInstClass[cls] = byInstClass[cls] || {})[subj] = true; }); + dbs.forEach(function (db) { tagged.forEach(function (x) { if (x.arn === db.arn) Object.keys(x.tags).forEach(function (k) { db.tags[k] = x.tags[k]; }); }); }); + var dbCost = {}; var dbMethod = {}; + if (tagRows > 0) { + var byVal = {}; dbs.forEach(function (db) { var v = db.tags[ownerTag]; if (v) (byVal[v] = byVal[v] || []).push(db.id); }); + dbs.forEach(function (db) { var v = db.tags[ownerTag]; dbCost[db.id] = v && tagCost[v] ? num(tagCost[v] / byVal[v].length) : 0; dbMethod[db.id] = v && tagCost[v] ? "direct_tag" : "unallocated"; }); + var rest = 0; + untagged.forEach(function (u) { + var m = /InstanceUsage:db\.([a-z0-9]+\.[a-z0-9]+)/i.exec(u.ut); var cls = m ? m[1] : null; + var owners = cls ? Object.keys(byInstClass).filter(function (k) { return k === cls || k.indexOf(cls) === 0; }).map(function (k) { return Object.keys(byInstClass[k]); }).reduce(function (a, b) { return a.concat(b); }, []) : []; + owners = owners.filter(function (o, i, arr) { return arr.indexOf(o) === i && dbCost[o] !== undefined; }); + if (owners.length) owners.forEach(function (o) { dbCost[o] = num(dbCost[o] + u.cost / owners.length); }); else rest += u.cost; + }); + var placed = num(Object.keys(dbCost).reduce(function (a, k) { return a + dbCost[k]; }, 0)); + if (rest > 0) { if (placed > 0) Object.keys(dbCost).forEach(function (k) { dbCost[k] = num(dbCost[k] + rest * (dbCost[k] / placed)); }); else dbs.forEach(function (db) { dbCost[db.id] = num(rest / dbs.length); }); } + } else { dbs.forEach(function (db) { if (!/docdb/i.test(String(db.engine || ""))) { dbCost[db.id] = num(rdsDbs.length ? rdsTotal / rdsDbs.length : 0); dbMethod[db.id] = "unallocated"; } }); } + // DocumentDB is billed as its own service: split its total evenly among the docdb subjects (Performance + // Insights shares on each of them do the real attribution downstream). + var docdbTotal = num(svcTotal[String(inputs.docdb_service || "Amazon DocumentDB (with MongoDB compatibility)")] || 0); + var docDbs = dbs.filter(function (db) { return /docdb/i.test(String(db.engine || "")); }); + docDbs.forEach(function (db) { dbCost[db.id] = num(docDbs.length ? docdbTotal / docDbs.length : 0); dbMethod[db.id] = "unallocated"; }); + dbs.forEach(function (db) { + var tg = db.tags; + var dims = npDims(tg); + var f = base("raw", "service", db.id); f.subject_name = db.id; f.resource_id = db.arn || db.id; f.resource_type = db.kind; f.usage_type = db.engine || null; + f.host = (db.hosts || []).filter(Boolean)[0] || null; + f.tags = Object.keys(tg).length ? tg : null; + var dbSvc = /docdb/i.test(String(db.engine || "")) ? String(inputs.docdb_service || "Amazon DocumentDB (with MongoDB compatibility)") : String(inputs.rds_service); + f.cloud_service = dbSvc; f.region = region; f.cost_usd = num(dbCost[db.id] || 0); f.parent_id = svcFactId[dbSvc] || null; + f.cost_method = dbMethod[db.id] || "unallocated"; + Object.keys(dims).forEach(function (k) { f[k] = dims[k]; }); + f.allocation_method = dims.application_id ? "direct_tag" : "unallocated"; + // null service whose host is this database's endpoint → owner application (service_owner). + var sv = null; (db.hosts || []).forEach(function (h) { if (!sv && h && svcByHost[String(h).toLowerCase()]) sv = svcByHost[String(h).toLowerCase()]; }); + if (sv) { + var np = nrnParts(sv.entity_nrn || sv.nrn); + f.service_id = String(sv.id); f.service_name = sv.name || sv.slug || null; f.subject_name = sv.name || db.id; f.nrn = sv.entity_nrn || null; + if (np.application) f.application_id = np.application; if (np.namespace) f.namespace_id = np.namespace; if (np.account) f.account_id = np.account; + var dm = sv.dimensions || {}; if (dm.environment) f.environment = dm.environment; f.dimensions = Object.keys(dm).length ? dm : null; + f.allocation_method = "service_owner"; + } + // Performance Insights db.load by database → shares (evidence for by_metric rules; a shared cluster + // is split by who actually loads it). Absent when PI is off or the call failed: the row stays whole. + var piRes = (inputs.types_results || {})["pi_" + db.id]; + if (piRes && piRes.MetricList) { + var shares = {}; var tot = 0; + piRes.MetricList.forEach(function (m) { + var dims = (m.Key || {}).Dimensions || {}; var name = dims["db.name"]; if (!name) return; + var vals = (m.DataPoints || []).map(function (x) { return Number(x.Value) || 0; }); var v = vals.length ? vals.reduce(function (a, b) { return a + b; }, 0) / vals.length : 0; + if (v > 0) { shares[name] = v; tot += v; } + }); + if (tot > 0) { Object.keys(shares).forEach(function (k) { shares[k] = num(shares[k] / tot); }); f.metric = "pi.db.load"; f.metric_shares = shares; } + } + facts.push(f); + }); + // §7 Tagged services (Lambda functions today): CE by (scope, application) tag → the resource tags give the + // null ids → one scope row per (service, scope). Untagged usage stays in the service remainder (unallocated). + var fnDims = {}; + (((r.fn_tags || {}).ResourceTagMappingList) || []).forEach(function (m) { var dm = npDims(tagMap(m.Tags)); if (dm.scope_id && dm.application_name && dm.scope_name) fnDims[dm.application_name + "|" + dm.scope_name] = dm; }); + (Array.isArray(inputs.tagged_services) ? inputs.tagged_services.map(String) : []).forEach(function (svc) { + var res = r["svc_by_tag_" + svc.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")] || {}; + var perScope = {}; + (res.ResultsByTime || []).forEach(function (rt) { (rt.Groups || []).forEach(function (g) { + var sc = String((g.Keys || [])[0] || "").replace(/^scope\$/, ""); var ap = String((g.Keys || [])[1] || "").replace(/^application\$/, ""); var c = amort(g); + if (!(c > 0) || !sc || !ap) return; var dm = fnDims[ap + "|" + sc]; if (!dm) return; + var acc = perScope[dm.scope_id] || (perScope[dm.scope_id] = { dims: dm, sc: sc, ap: ap, cost: 0, unbl: 0 }); acc.cost += c; acc.unbl += unbl(g); + }); }); + Object.keys(perScope).forEach(function (sid) { + var e = perScope[sid]; var f = base("raw", "scope", sid); f.allocation_method = "direct_tag"; + Object.keys(e.dims).forEach(function (k) { f[k] = e.dims[k]; }); + f.cloud_service = svc; f.subject_name = e.ap + "." + e.sc; f.tags = { scope: e.sc, application: e.ap }; f.cost_usd = num(e.cost); f.unblended_usd = num(e.unbl); + facts.push(f); + }); + }); + var total = num(Object.keys(svcTotal).reduce(function (a, k) { return a + svcTotal[k]; }, 0)); + var count = function (t) { return facts.filter(function (f) { return f.subject_type === t; }).length; }; + var summary = { day: d, account: acct, target: inputs.target_name || null, services: Object.keys(svcTotal).length, daily_total_usd: total, + buckets: count("bucket"), scopes: count("scope"), resources: count("resource"), databases: count("service"), + clusters: clusterFacts, facts: facts.length, dry_run: !!inputs.dry_run }; + // Batches of 40 → a handful of upsert children (a per-fact fan-out bloats the parent's Temporal history). + var batches = []; for (var bi = 0; bi < facts.length; bi += 40) batches.push({ facts: facts.slice(bi, bi + 40), catalog_slug: "cost_daily", dry_run: !!inputs.dry_run }); + return { raw_filters: { stage: "raw" }, facts: facts, batches: batches, ids: facts.map(function (f) { return f.id; }), summary: summary }; + + - id: write_facts + type: module + plugin_type: sub-workflow + name: "Upsert facts (fan-out)" + forEach: + expression: "${{ steps.build_facts.outputs.batches }}" + itemVariable: batch + spreadItem: true + parallel: true + maxConcurrency: 4 + config: + # Definition id of finops/wf-cost-fact-upsert.yaml (patched at publish time). + workflowId: FINOPS_COST_FACT_UPSERT_ID + alias: live + waitForCompletion: true + + # ── stale sweep: raw rows of the day that THIS collection did not produce (older wf1 revisions, + # resources that no longer exist) would double count; wf3's k8s rows (source k8s) are not ours. + - id: read_existing + type: module + plugin_type: np-entity-paginated-fetch + name: "Raw rows already in the catalog" + output_projection: ["items[].id", "items[].day", "items[].date", "items[].stage", "items[].source", "items[].cost_usd", "totalFetched", "pages"] + inputs: + filters: "${{ steps.build_facts.outputs.raw_filters }}" + config: + apiKey: "${{ secrets.NP_API_KEY }}" + entity: "catalog/instances/cost_daily" + filters: { stage: "raw" } + limit: 100 + maxPages: 200 + + - id: stale + type: module + plugin_type: code-exec + name: "Rows of the day this collection did not produce" + metadata: { fanOutPerItem: false } + inputs: + day: "${{ steps.prep.outputs.day }}" + ids: "${{ steps.build_facts.outputs.ids }}" + existing: "${{ steps.read_existing.outputs.items }}" + dry_run: "${{ workflow.inputs.dry_run }}" + config: + language: javascript + code: | + var d = String(inputs.day); var mine = {}; (inputs.ids || []).forEach(function (id) { mine[id] = true; }); + var stale = (inputs.existing || []).filter(function (r) { return r && r.stage === "raw" && r.source === "aws_ce" && (r.day === d || r.date === d) && !mine[r.id]; }); + var usd = Math.round(stale.reduce(function (s, r) { return s + (Number(r.cost_usd) || 0); }, 0) * 1e6) / 1e6; + return { count: stale.length, stale_usd: usd, ids: stale.map(function (r) { return r.id; }).slice(0, 50), + list: inputs.dry_run ? [] : stale.map(function (r) { return { path: "/catalog/instances/cost_daily/" + encodeURIComponent(r.id) }; }) }; + + - id: delete_stale + type: module + plugin_type: np-api-call + name: "Delete stale raw rows (fan-out)" + forEach: + expression: "${{ steps.stale.outputs.list }}" + itemVariable: row + spreadItem: true + parallel: true + maxConcurrency: 5 + config: + apiKey: "${{ secrets.NP_API_KEY }}" + method: DELETE + path: "/catalog/instances/cost_daily/placeholder" + + - id: summary + type: module + plugin_type: code-exec + name: "Summary" + # Aggregate step: never dispatch per upstream fan-out item. + metadata: { fanOutPerItem: false } + inputs: + summary: "${{ steps.build_facts.outputs.summary }}" + # After a forEach fan-out, `outputs.items` carries one entry per child run + # (live engine wraps each as {outputs, _childExecutionId}; the E2E stub flat). + written: "${{ steps.write_facts.outputs.items }}" + stale: "${{ steps.stale.outputs }}" + config: + language: javascript + code: | + // inputs are frozen in the sandbox: build a new object instead of mutating. + var s = inputs.summary || {}; + var n = (inputs.written || []).reduce(function (acc, r) { var o = (r || {}).outputs || r || {}; return acc + (Number(o.written) || 0); }, 0); + var out = {}; + Object.keys(s).forEach(function (k) { out[k] = s[k]; }); + out.written = s.dry_run ? 0 : n; + out.stale_rows = (inputs.stale || {}).count || 0; out.stale_usd = (inputs.stale || {}).stale_usd || 0; out.stale_deleted = s.dry_run ? 0 : out.stale_rows; + return out; + +connections: + - { from: start, to: prep } + - { from: prep, to: query } + - { from: query, to: prep_types } + - { from: prep_types, to: query_types } + - { from: query_types, to: np_services } + - { from: np_services, to: np_apps } + - { from: np_apps, to: np_namespaces } + - { from: np_namespaces, to: build_facts } + - { from: build_facts, to: write_facts } + - { from: write_facts, to: read_existing } + - { from: read_existing, to: stale } + - { from: stale, to: delete_stale } + - { from: delete_stale, to: summary } + +outputs: + summary: "${{ steps.summary.outputs }}" + facts: "${{ steps.build_facts.outputs.facts }}" diff --git a/finops/wf2-allocate-daily.yaml b/finops/wf2-allocate-daily.yaml new file mode 100644 index 0000000..935d814 --- /dev/null +++ b/finops/wf2-allocate-daily.yaml @@ -0,0 +1,574 @@ +# finops/wf2-allocate-daily.yaml +# +# The allocator: one day of `raw` cost_daily facts + the organization's active +# cost_mapping_rules → `allocated` facts (per source fact × owner, categorized) +# and one rollup row per application (+ one for what stays unallocated). +# +# Allocation leaves, per cloud service S (total T): +# - resource-evidence rows with parent_id = S: subject_type scope / resource / service, +# plus the `ec2-instances-unattributed` bucket; +# - cluster component buckets whose cloud_service = S (nodes, LBs, networking, …); +# - remainder = T − Σ(the above) → a synthetic leaf "S / remainder". +# Usage-type buckets are informational (they overlap the leaves) and are NOT allocated. +# +# Rules (finops/docs/mapping-rules-design.md, spec cost_mapping_rule): enabled + +# status=active, ascending priority, first match. Built-in defaults (lowest priority): +# default:null-dims the raw fact already carries application_id/scope_id (null tags, wf1) +# default:null-service the raw fact already carries service_id + nrn (host = null service) +# default:cluster cluster components → the cluster bucket (phase 3 splits it by consumption) +# Everything else → unallocated (kept per cloud service, so the gap is visible). +# +# Invariant: Σ allocated (owners + cluster + unallocated) = Σ cloud_service totals. +id: finops_allocate_daily +name: "FinOps — allocate one day to applications" +description: > + Applies the organization's cost mapping rules to one day of raw cost facts and + writes allocated facts per application (resource by resource, categorized) plus + a per-application rollup. +path: "/finops" +semantic_version: 0.1.0 + +inputs: + date: + type: string + required: false + description: "Day to allocate, YYYY-MM-DD (UTC). Default: yesterday." + dry_run: + type: boolean + required: false + description: "Compute without writing" + cloud_account: + type: string + required: false + description: "Only raw facts of this cloud account (default: all)" + +variables: + # cloud_service regex → category. First match wins; anything else → "other". + categories: + initialValue: + - { regex: "Elastic Compute Cloud - Compute|Lambda|Elastic Container Service|Fargate", category: "compute" } + - { regex: "Kubernetes", category: "kubernetes" } + - { regex: "Relational Database|DynamoDB|ElastiCache|OpenSearch|DocumentDB|Neptune|Redshift|Database Migration", category: "database" } + - { regex: "Simple Storage Service|EC2 - Other|Elastic File System|Backup|Glacier|Container Registry", category: "storage" } + - { regex: "Virtual Private Cloud|Load Balancing|Route 53|Direct Connect|Transit|Global Accelerator", category: "network" } + - { regex: "CloudWatch|X-Ray|Managed Grafana|Managed Service for Prometheus", category: "observability" } + - { regex: "Simple Queue Service|Simple Notification Service|EventBridge|Kinesis|MSK|MQ", category: "messaging" } + - { regex: "GuardDuty|Security Hub|Inspector|WAF|Config|Secrets Manager|Key Management|Cognito|Shield|Macie", category: "security" } + - { regex: "CloudFront", category: "cdn" } + # Organization NRN: scopes (dimensions) and services listings. + org_nrn: + initialValue: "organization=1255165411" + allocator: + initialValue: "finops_allocate_daily@0.1.0" + +steps: + - id: start + type: trigger + plugin_type: manual + name: "Allocate" + config: + description: "Allocate one day of raw cost facts to applications." + inputs: + date: { type: string, required: false, description: "YYYY-MM-DD (UTC)" } + dry_run: { type: boolean, required: false, description: "Compute without writing" } + cloud_account: { type: string, required: false, description: "Restrict to one cloud account" } + + # Daily allocation of yesterday, 45 minutes after the dispatcher's 04:15 UTC collection (see wf0 + # variables.allocate_in_chain: with it false, this cron is the daily allocation). + - id: daily_cron + type: trigger + plugin_type: cron + name: "Daily 05:00 UTC" + config: + schedule: "0 5 * * *" + timezone: "UTC" + + - id: prep + type: module + plugin_type: code-exec + join_strategy: any + name: "Day + filters" + inputs: + date: "${{ workflow.inputs.date }}" + cloud_account: "${{ workflow.inputs.cloud_account }}" + org_nrn: "${{ variables.org_nrn }}" + config: + language: javascript + code: | + function iso(dt) { return dt.toISOString().slice(0, 10); } + var day = inputs.date && /^\d{4}-\d{2}-\d{2}$/.test(String(inputs.date)) ? String(inputs.date) : iso(new Date(Date.now() - 86400000)); + // Catalog list filters only work on fields indexed when the spec was CREATED (stage, subject_type, + // cloud_service, cluster, …); `day` was added later and `date` is ignored → read all raw rows and + // filter by day here. TODO: switch to the lake (customers_lake.catalog_entities) once volume grows. + var org = String(inputs.org_nrn || ""); + var rawSql = "SELECT data FROM (SELECT id, argMax(data, _version) AS data, argMax(_deleted, _version) AS del FROM catalog_entities WHERE entity_specification_id IN (SELECT id FROM catalog_entity_specifications FINAL WHERE slug = 'cost_daily' AND _deleted = 0) GROUP BY id HAVING del = 0) WHERE JSONExtractString(data, 'stage') = 'raw' AND JSONExtractString(data, 'day') = '" + day + "' QUALIFY parseDateTimeBestEffortOrNull(JSONExtractString(data, 'collected_at')) >= max(parseDateTimeBestEffortOrNull(JSONExtractString(data, 'collected_at'))) OVER (PARTITION BY JSONExtractString(data, 'source')) - INTERVAL 30 MINUTE"; + return { raw_sql: rawSql, day: day, cloud_account: inputs.cloud_account ? String(inputs.cloud_account) : null, raw_filters: { stage: "raw" }, allocated_filters: { stage: "allocated" }, rule_filters: { status: "active" }, scope_filters: { nrn: org, status: "active" }, service_query: { nrn: org, show_descendants: "true", limit: "500" } }; + + # The catalog list cannot filter by day (only the fields indexed at spec creation), so it returned + # EVERY day's raw facts: 5 days of itti = 2.3 MB, over the 2 MB step budget. The Lake filters by day; + # the latest run per source (30-minute window: wf3 patches the cluster row minutes after wf1) is what + # the catalog holds after the collectors' stale sweeps. + - id: read_raw + type: module + plugin_type: np-lake-query + name: "Raw facts of the day (Lake)" + inputs: + sql: "${{ steps.prep.outputs.raw_sql }}" + config: + apiKey: "${{ secrets.NP_API_KEY }}" + sql: "SELECT 1" + maxRows: 20000 + + - id: read_rules + type: module + plugin_type: np-entity-paginated-fetch + name: "Active mapping rules" + inputs: + filters: "${{ steps.prep.outputs.rule_filters }}" + config: + apiKey: "${{ secrets.NP_API_KEY }}" + entity: "catalog/instances/cost_mapping_rule" + filters: { status: "active" } + limit: 100 + maxPages: 20 + + - id: scopes + type: module + plugin_type: np-entity-paginated-fetch + # The parent's Temporal history carries every step result: keep only what the allocator reads. + output_projection: ["items[].id", "items[].name", "items[].type", "items[].dimensions", "totalFetched", "pages"] + name: "Scopes (dimensions)" + inputs: + filters: "${{ steps.prep.outputs.scope_filters }}" + config: + apiKey: "${{ secrets.NP_API_KEY }}" + entity: "scope" + filters: { status: "active" } + limit: 100 + maxPages: 100 + + - id: services + type: module + plugin_type: np-api-call + output_projection: ["status", "body.results[].id", "body.results[].name", "body.results[].slug", "body.results[].dimensions", "body.data[].id", "body.data[].name", "body.data[].slug", "body.data[].dimensions"] + name: "Services (dimensions)" + inputs: + query: "${{ steps.prep.outputs.service_query }}" + config: + apiKey: "${{ secrets.NP_API_KEY }}" + method: GET + path: "/service" + query: { show_descendants: "true", limit: "500" } + failOnHttpError: false + + - id: allocate + type: module + plugin_type: code-exec + name: "Evaluate rules → allocated facts" + join_strategy: all + inputs: + day: "${{ steps.prep.outputs.day }}" + raw: "${{ steps.read_raw.outputs.rows }}" + rules: "${{ steps.read_rules.outputs.items }}" + cloud_account: "${{ steps.prep.outputs.cloud_account }}" + scopes: "${{ steps.scopes.outputs.items }}" + services: "${{ steps.services.outputs.body }}" + categories: "${{ variables.categories }}" + allocator: "${{ variables.allocator }}" + dry_run: "${{ workflow.inputs.dry_run }}" + config: + language: javascript + code: | + var d = String(inputs.day); var now = new Date().toISOString(); + var rawIn = (inputs.raw || []).map(function (r) { if (r && typeof r.data === "string") { try { return JSON.parse(r.data); } catch (e) { return null; } } return r && r.data && typeof r.data === "object" ? r.data : r; }); + var raw = rawIn.filter(function (f) { return f && f.stage === "raw" && (f.date === d || f.day === d) && (!inputs.cloud_account || String(f.cloud_account) === String(inputs.cloud_account)); }); + if (!raw.length) throw new Error("no raw facts for " + d + " (run finops_aws_billing_daily first)"); + var rules = (inputs.rules || []).filter(function (r) { return r && r.enabled !== false && r.status === "active" && r.target; }) + .map(function (r) { return JSON.parse(JSON.stringify(r)); }) + .sort(function (a, b) { return (Number(a.priority) || 1000) - (Number(b.priority) || 1000); }); + function num(x) { return Math.round((Number(x) || 0) * 1e6) / 1e6; } + function slug(x) { return String(x).toLowerCase().replace(/[^a-z0-9_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 100); } + function get(obj, path) { if (path == null) return undefined; if (String(path).charAt(0) === "$") return undefined; return String(path).split(".").reduce(function (o, k) { return o == null ? undefined : o[k]; }, obj); } + // null dimensions by scope / service id (the API has them; the lake scope table does not) + var scopeInfo = {}; (inputs.scopes || []).forEach(function (sc) { if (sc && sc.id) scopeInfo[String(sc.id)] = { dimensions: sc.dimensions && Object.keys(sc.dimensions).length ? sc.dimensions : null, type: sc.type || null, name: sc.name || null }; }); + var svcList = inputs.services; svcList = Array.isArray(svcList) ? svcList : ((svcList && (svcList.results || svcList.data)) || []); + var serviceInfo = {}; svcList.forEach(function (sv) { if (sv && sv.id) serviceInfo[String(sv.id)] = { dimensions: sv.dimensions && Object.keys(sv.dimensions).length ? sv.dimensions : null, name: sv.name || sv.slug || null }; }); + function dimsFor(scopeId, serviceId, fact) { var d = scopeId && scopeInfo[scopeId] ? scopeInfo[scopeId].dimensions : (serviceId && serviceInfo[serviceId] ? serviceInfo[serviceId].dimensions : null); return d || fact.dimensions || null; } + function categoryOf(svc) { var cats = inputs.categories || []; for (var i = 0; i < cats.length; i++) { if (new RegExp(cats[i].regex, "i").test(String(svc || ""))) return cats[i].category; } return "other"; } + + // ── 1) leaves per cloud service + var byId = {}; raw.forEach(function (f) { byId[f.id] = f; }); + var services = raw.filter(function (f) { return f.subject_type === "cloud_service"; }); + var leaves = []; + services.forEach(function (s) { + var name = s.cloud_service || s.subject_name; var covered = 0; + raw.forEach(function (f) { + var isRes = f.parent_id === s.id && (f.subject_type === "scope" || f.subject_type === "resource" || f.subject_type === "service" || (f.subject_type === "bucket" && f.subject_id === "ec2-instances-unattributed")); + var isComp = f.subject_type === "bucket" && f.component && f.cloud_service === name; + // usage-type buckets are informational EXCEPT when the collector attached consumption shares to them + // (CloudWatch Logs ingestion/storage by log group, EMF metrics by application): those are leaves. + var isMetricBucket = f.subject_type === "bucket" && f.usage_type && f.parent_id === s.id && f.metric_shares && typeof f.metric_shares === "object"; + if (isRes || isComp || isMetricBucket) { leaves.push({ fact: f, service: name, kind: isComp ? "cluster_component" : isMetricBucket ? "usage_type" : f.subject_type }); covered += Number(f.cost_usd) || 0; } + }); + var rem = num((Number(s.cost_usd) || 0) - covered); + if (Math.abs(rem) >= 0.000001) { + leaves.push({ fact: { id: s.id + "-remainder", date: d, subject_type: "cloud_service", subject_id: s.subject_id, subject_name: name + " / remainder", cloud_service: name, + cloud: s.cloud, cloud_account: s.cloud_account, region: s.region || null, cost_usd: rem, parent_id: s.id, synthetic: true, + dimensions: s.dimensions || null, environment: s.environment || null }, service: name, kind: "remainder" }); + } + }); + + // ── 2) rule evaluation + function test(pred, fact, caps) { + var v = get(fact, pred.field); + if (pred.exists !== undefined) return pred.exists ? (v !== undefined && v !== null && v !== "") : (v === undefined || v === null || v === ""); + if (v === undefined || v === null) return false; + var sv = String(v); + if (pred.equals !== undefined) return sv === String(pred.equals); + if (pred.in) return pred.in.map(String).indexOf(sv) >= 0; + if (pred.regex) { var m = new RegExp(pred.regex, pred.flags || "i").exec(sv); if (!m) return false; if (m.groups) Object.keys(m.groups).forEach(function (g) { caps["$" + g] = m.groups[g]; }); for (var i = 1; i < m.length; i++) caps["$" + i] = m[i]; return true; } + return false; + } + function scopeOk(rule, fact) { + var sc = rule.scope || {}; + return Object.keys(sc).every(function (k) { + var want = sc[k], v = get(fact, k); + if (want && typeof want === "object" && want.regex) return v != null && new RegExp(want.regex, "i").test(String(v)); + if (Array.isArray(want)) return v != null && want.map(String).indexOf(String(v)) >= 0; + return v != null && String(v) === String(want); + }); + } + var OWNER = ["application_id", "namespace_id", "scope_id", "service_id", "account_id", "cluster", "bucket", "application_slug"]; + function resolveTarget(t, fact, caps) { + if (!t || typeof t !== "object") return null; + var out = {}; + OWNER.forEach(function (k) { if (t[k] !== undefined && t[k] !== null && t[k] !== "") out[k] = String(t[k]); }); + if (t.capture) Object.keys(t.capture).forEach(function (k) { + var src = String(t.capture[k]); var v = src.charAt(0) === "$" ? caps[src] : get(fact, src); + if (v !== undefined && v !== null && v !== "") out[k] = String(v); + }); + return Object.keys(out).length ? out : null; + } + function apply(rule, fact, caps) { + var t = rule.target || {}; var method = rule.method || (t.split ? "split" : t.map ? "map" : "direct"); + if (method === "split" && Array.isArray(t.split)) { + var tot = t.split.reduce(function (s, p) { return s + (Number(p.weight) || 0); }, 0) || 1; + return t.split.map(function (p) { return { share: (Number(p.weight) || 0) / tot, owner: resolveTarget(p.target, fact, caps) }; }).filter(function (x) { return x.owner && x.share > 0; }); + } + if (method === "map" && t.map && t.map.entries) { + var key = String(t.map.key || ""); var kv = key.charAt(0) === "$" ? caps[key] : get(fact, key); + var e = kv != null ? t.map.entries[String(kv)] : undefined; + if (!e) return []; + return [{ share: 1, owner: resolveTarget(e, fact, caps) }].filter(function (x) { return x.owner; }); + } + if (method === "spread") return [{ share: 1, owner: null, spread: true }]; // resolved in §3b once every application of the day is known + if (method === "by_metric_parent") { + // Cluster components: the consumption metric lives on the PARENT cluster row (wf3 attaches + // metric_shares {scope_id → share} + metric_owners {scope_id → dims}). Shares sum to < 1: the + // rest (system namespaces, idle headroom) stays with the cluster as k8s overhead. + var parent = byId[fact.parent_id]; if (!parent || !parent.metric_shares) return []; + var ps = parent.metric_shares; var po = parent.metric_owners || {}; var out = []; var covered = 0; + Object.keys(ps).forEach(function (k) { var sh = Number(ps[k]) || 0; if (sh <= 0) return; covered += sh; var o = po[k] ? resolveTarget(po[k], fact, caps) : null; out.push(o ? { share: sh, owner: o, key: k } : { share: sh, owner: null, key: k }); }); + if (covered < 1) out.push({ share: num(1 - covered), owner: { cluster: String(fact.cluster) }, key: "kubernetes-overhead", overhead: true }); + return out; + } + if (method === "by_metric") { + // The fact carries `metric_shares` {key → share} (e.g. Performance Insights db.load by db.name, + // attached by the collector). `target.map.entries` maps each key to an owner; keys without an + // owner stay unallocated (returned as an explicit `unallocated` part so the gap is visible). + var shares = fact.metric_shares; if (!shares || typeof shares !== "object") return []; + var entries = (t.map && t.map.entries) || {}; var parts = []; + Object.keys(shares).forEach(function (k) { + var s = Number(shares[k]) || 0; if (s <= 0) return; + // owner: the rule's map first, else the owner the collector resolved for the key (fact.metric_owners) + var e = entries[k] || ((fact.metric_owners || {})[k]); var o = e ? resolveTarget(e, fact, caps) : null; + parts.push(o ? { share: s, owner: o, key: k } : { share: s, owner: null, key: k }); + }); + return parts; + } + var o = resolveTarget(t, fact, caps); return o ? [{ share: 1, owner: o }] : []; + } + var defaults = [ + { id: "default:null-service", match: [{ field: "service_id", exists: true }, { field: "application_id", exists: true }], target: { capture: { application_id: "application_id", namespace_id: "namespace_id", service_id: "service_id", account_id: "account_id" } } }, + { id: "default:null-dims", match: [{ field: "application_id", exists: true }], target: { capture: { application_id: "application_id", namespace_id: "namespace_id", scope_id: "scope_id", account_id: "account_id" } } }, + { id: "default:cluster-consumption", match: [{ field: "component", exists: true }, { field: "cluster", exists: true }], method: "by_metric_parent", target: {}, category: "kubernetes" }, + { id: "default:cluster", match: [{ field: "component", exists: true }, { field: "cluster", exists: true }], target: { capture: { cluster: "cluster" } }, cluster_pending: true } + ]; + function evaluate(fact) { + var all = rules.concat(defaults); + for (var i = 0; i < all.length; i++) { + var r = all[i]; var caps = {}; + if (!scopeOk(r, fact)) continue; + var preds = Array.isArray(r.match) ? r.match : []; + var ok = true; for (var j = 0; j < preds.length; j++) { if (!test(preds[j], fact, caps)) { ok = false; break; } } + if (!ok) continue; + var parts = apply(r, fact, caps); + if (parts.length) return { rule: r, parts: parts }; + } + return null; + } + + // ── 3) allocated facts + var allocated = []; var perApp = {}; var unalloc = {}; var perCluster = {}; var overhead = {}; var sumLeaves = 0, sumAlloc = 0; + var spreadLeaves = []; var spreadTotal = 0; // rules with method "spread": shared by every application of the day (§3b) + function ownerKey(o) { return o.application_id ? "app-" + o.application_id : o.scope_id ? "scope-" + o.scope_id : o.service_id ? "svc-" + o.service_id : o.namespace_id ? "ns-" + o.namespace_id : o.cluster ? "cluster-" + o.cluster : o.bucket ? "bucket-" + o.bucket : o.application_slug ? "app-" + o.application_slug : "unknown"; } + leaves.forEach(function (L) { + var f = L.fact; var cost = num(f.cost_usd); sumLeaves += cost; + var cat = categoryOf(L.service); var hit = evaluate(f); + var base = { date: d, day: d, stage: "allocated", subject_type: f.subject_type, subject_id: f.subject_id, subject_name: f.subject_name || f.subject_id, + cloud: f.cloud || "aws", cloud_account: f.cloud_account || null, cloud_service: L.service, region: f.region || null, cluster: f.cluster || null, + resource_id: f.resource_id || null, resource_type: f.resource_type || null, component: f.component || null, usage_type: f.usage_type || null, + source: "finops_allocator", source_fact_id: f.id, parent_id: f.id, collected_at: now, collector: String(inputs.allocator), category: cat, + scope_name: f.scope_name || null, scope_type: f.scope_type || null, service_name: f.service_name || null }; + if (!hit) { + var u = Object.assign({}, base, { id: "alloc-" + slug(f.id.replace(/^raw-/, "")) + "-unallocated", cost_usd: cost, share: 1, allocation_method: "unallocated", rule_id: null }); + allocated.push(u); unalloc[L.service] = num((unalloc[L.service] || 0) + cost); sumAlloc += cost; return; + } + if (hit.rule.method === "spread") { spreadLeaves.push({ L: L, base: base, hit: hit, cost: cost }); return; } + var rcat = hit.rule.category || cat; + hit.parts.forEach(function (p) { + var amt = num(cost * p.share); + if (!p.owner) { // by_metric key without an owner in the map → unallocated portion, key kept for the suggestion pass + var un = Object.assign({}, base, { id: "alloc-" + slug(f.id.replace(/^raw-/, "")) + "-" + slug(p.key) + "-unallocated", cost_usd: amt, share: num(p.share), allocation_method: "unallocated", rule_id: String(hit.rule.id), metric_key: String(p.key) }); + allocated.push(un); unalloc[L.service] = num((unalloc[L.service] || 0) + amt); sumAlloc += amt; return; + } + var o = p.owner; var key = ownerKey(o); + var method = hit.rule.cluster_pending ? "cluster_pending_consumption" : p.overhead ? "kubernetes_overhead" : (hit.rule.method === "by_metric_parent" ? "by_metric" : (hit.rule.method || (hit.rule.target && hit.rule.target.split ? "split" : hit.rule.target && hit.rule.target.map ? "map" : "direct"))); + var a = Object.assign({}, base, { id: "alloc-" + slug(f.id.replace(/^raw-/, "")) + "-" + slug(key) + (p.key ? "-" + slug(p.key) : ""), cost_usd: amt, share: num(p.share), allocation_method: method, rule_id: String(hit.rule.id), category: rcat }); + OWNER.forEach(function (k) { if (o[k] !== undefined) a[k] = o[k]; }); + allocated.push(a); sumAlloc += amt; + if (o.cluster && !o.application_id) { var pc = p.overhead ? overhead : perCluster; pc[o.cluster] = num((pc[o.cluster] || 0) + amt); return; } + var app = perApp[key] || (perApp[key] = { key: key, owner: o, cost_usd: 0, by_category: {}, by_cloud_service: {}, facts: 0 }); + app.cost_usd = num(app.cost_usd + amt); app.by_category[rcat] = num((app.by_category[rcat] || 0) + amt); app.by_cloud_service[L.service] = num((app.by_cloud_service[L.service] || 0) + amt); app.facts += 1; + // The null OBJECT the cost came through (a scope of any type, a service, or the application) + its dimensions. + var scopeId = o.scope_id || f.scope_id || (p.key && hit.rule.method === "by_metric_parent" ? String(p.key) : null); + var serviceId = !scopeId && (o.service_id || f.service_id) ? String(o.service_id || f.service_id) : null; + // A cloud resource that acts as a service (a database, a cache) without a null service registered + // for it is still charged as a SERVICE — flagged service_kind "cloud" (vs "null") so the invoice + // and the dashboard can tell them apart. + var cloudSvc = !scopeId && !serviceId && f.subject_type === "service" && f.subject_id ? String(f.subject_id) : null; + a.charge_type = scopeId ? "scope" : (serviceId || cloudSvc) ? "service" : "application"; + if (scopeId) { a.scope_id = String(scopeId); a.scope_name = a.scope_name || (scopeInfo[String(scopeId)] || {}).name || null; a.scope_type = a.scope_type || (scopeInfo[String(scopeId)] || {}).type || null; } + if (serviceId) { a.service_id = serviceId; a.service_name = a.service_name || (serviceInfo[serviceId] || {}).name || f.subject_name || null; a.service_kind = "null"; } + if (cloudSvc) { a.service_id = "cloud:" + slug(cloudSvc); a.service_name = f.subject_name || cloudSvc; a.service_kind = "cloud"; } + var dims = dimsFor(scopeId ? String(scopeId) : null, serviceId, f); var env = dims && dims.environment ? String(dims.environment) : null; + a.dimensions = dims; if (env) a.environment = env; + a.cloud_account = a.cloud_account || ((byId[f.parent_id] || {}).cloud_account) || null; + }); + }); + // ── 3b) spread: platform cost every application of the account shares. Weights = each application's + // cost attributed so far (default) or equal. Decided AFTER the main pass so the weights are the day's + // attribution, never the spread itself. No application yet → the leaf stays visibly unallocated. + var spreadWeights = {}; Object.keys(perApp).forEach(function (k) { if (perApp[k].owner.application_id) spreadWeights[k] = num(perApp[k].cost_usd); }); + spreadLeaves.forEach(function (S) { + var f = S.L.fact; var mode = String(((S.hit.rule.target || {}).spread || {}).weights || "attributed"); var rcat = S.hit.rule.category || "platform"; + var keys = Object.keys(spreadWeights); var w = {}; var tot = 0; + keys.forEach(function (k) { var v = mode === "equal" ? 1 : spreadWeights[k]; if (v > 0) { w[k] = v; tot += v; } }); + if (tot <= 0) { keys.forEach(function (k) { w[k] = 1; }); tot = keys.length; } + if (!tot) { + var un = Object.assign({}, S.base, { id: "alloc-" + slug(f.id.replace(/^raw-/, "")) + "-unallocated", cost_usd: S.cost, share: 1, allocation_method: "unallocated", rule_id: String(S.hit.rule.id) }); + allocated.push(un); unalloc[S.L.service] = num((unalloc[S.L.service] || 0) + S.cost); sumAlloc += S.cost; return; + } + var wk = Object.keys(w); var acc = 0; + wk.forEach(function (k, i) { + var share = w[k] / tot; var amt = i === wk.length - 1 ? num(S.cost - acc) : num(S.cost * share); acc = num(acc + amt); var o = perApp[k].owner; + var a = Object.assign({}, S.base, { id: "alloc-" + slug(f.id.replace(/^raw-/, "")) + "-" + slug(k) + "-spread", cost_usd: amt, share: num(share), allocation_method: "spread", rule_id: String(S.hit.rule.id), category: rcat, charge_type: "application" }); + OWNER.forEach(function (kk) { if (o[kk] !== undefined) a[kk] = o[kk]; }); + var dims = dimsFor(null, null, f); a.dimensions = dims; if (dims && dims.environment) a.environment = String(dims.environment); + allocated.push(a); sumAlloc += amt; spreadTotal = num(spreadTotal + amt); + var app = perApp[k]; app.cost_usd = num(app.cost_usd + amt); app.by_category[rcat] = num((app.by_category[rcat] || 0) + amt); app.by_cloud_service[S.L.service] = num((app.by_cloud_service[S.L.service] || 0) + amt); app.facts += 1; + }); + }); + // ── 4) rollups: one per owner, one for unallocated + Object.keys(perApp).forEach(function (k) { + var a = perApp[k]; var o = a.owner; + var r = { id: "alloc-" + slug(k) + "-" + d, date: d, day: d, stage: "allocated", subject_type: "application", subject_id: k, subject_name: o.application_slug || k, + cloud: "aws", source: "finops_allocator", collected_at: now, collector: String(inputs.allocator), allocation_method: "rollup", cost_usd: a.cost_usd, + by_category: a.by_category, by_cloud_service: a.by_cloud_service, quantity: a.facts, units: "facts" }; + OWNER.forEach(function (kk) { if (o[kk] !== undefined) r[kk] = o[kk]; }); + allocated.push(r); + }); + var unTotal = num(Object.keys(unalloc).reduce(function (s, k) { return s + unalloc[k]; }, 0)); + allocated.push({ id: "alloc-unallocated-" + d, date: d, day: d, stage: "allocated", subject_type: "unallocated", subject_id: "unallocated", subject_name: "Not attributable yet", + cloud: "aws", source: "finops_allocator", collected_at: now, collector: String(inputs.allocator), allocation_method: "unallocated", cost_usd: unTotal, by_cloud_service: unalloc }); + var svcTotal = num(services.reduce(function (s, f) { return s + (Number(f.cost_usd) || 0); }, 0)); + if (Math.abs(sumAlloc - sumLeaves) > 0.01 || Math.abs(sumLeaves - svcTotal) > 0.01) throw new Error("allocation does not reconcile: services " + svcTotal + " leaves " + sumLeaves + " allocated " + sumAlloc); + var apps = Object.keys(perApp).map(function (k) { var a = perApp[k]; return { owner: k, application_id: a.owner.application_id || null, cost_usd: a.cost_usd, by_category: a.by_category }; }).sort(function (x, y) { return y.cost_usd - x.cost_usd; }); + var unLeaves = leaves.filter(function (L) { return !evaluate(L.fact); }).map(function (L) { return { id: L.fact.id, cloud_service: L.service, subject_type: L.fact.subject_type, subject_id: L.fact.subject_id, resource_type: L.fact.resource_type || null, host: L.fact.host || null, tags: L.fact.tags || null, cost_usd: num(L.fact.cost_usd), kind: L.kind }; }); + // by_metric keys nobody owns (a new database on a shared cluster, …) are candidates too: the + // suggestion pass proposes a map entry from the key name, the DB users and the DB_NAME parameters. + allocated.forEach(function (a) { + if (a.metric_key && a.allocation_method === "unallocated") unLeaves.push({ id: a.source_fact_id + "#" + a.metric_key, cloud_service: a.cloud_service, subject_type: a.subject_type, subject_id: a.subject_id, resource_type: a.resource_type || null, host: byId[a.source_fact_id] ? (byId[a.source_fact_id].host || null) : null, tags: null, cost_usd: a.cost_usd, kind: "metric_key", metric_key: a.metric_key, rule_id: a.rule_id }); + }); + unLeaves.sort(function (x, y) { return y.cost_usd - x.cost_usd; }); + var seen = {}; allocated.forEach(function (a) { if (seen[a.id]) throw new Error("duplicate allocated id " + a.id); seen[a.id] = true; }); + // Batches of 40 → ~20 upsert children per day instead of one per fact (the per-fact fan-out + // re-ran in a loop in prod once the parent state grew). The facts live ONCE, here. + var batches = []; for (var bi = 0; bi < allocated.length; bi += 40) batches.push({ facts: allocated.slice(bi, bi + 40), catalog_slug: "cost_daily", dry_run: !!inputs.dry_run }); + // batches_out: the workflow output. Only a DRY RUN returns the batches (a real day is ~1 MB and a parent + // waiting on this child gets the whole output into its history; the summary step must not receive them either). + return { day: d, batches: batches, batches_out: inputs.dry_run ? batches : [], ids: Object.keys(seen), count: allocated.length, summary: { day: d, total_usd: svcTotal, allocated_usd: num(sumAlloc - unTotal - Object.keys(perCluster).reduce(function (s, k) { return s + perCluster[k]; }, 0) - Object.keys(overhead).reduce(function (s, k) { return s + overhead[k]; }, 0)), + cluster_pending_usd: perCluster, kubernetes_overhead_usd: overhead, platform_spread_usd: spreadTotal, unallocated_usd: unTotal, unallocated_by_service: unalloc, applications: apps.slice(0, 50), rules_active: rules.length, leaves: leaves.length, dry_run: !!inputs.dry_run }, + unallocated_leaves: unLeaves.slice(0, 200) }; + + - id: write_facts + type: module + plugin_type: sub-workflow + name: "Upsert allocated facts (fan-out)" + forEach: + expression: "${{ steps.allocate.outputs.batches }}" + itemVariable: batch + spreadItem: true + parallel: true + maxConcurrency: 4 + config: + # Definition id of finops/wf-cost-fact-upsert.yaml (patched at publish time). + workflowId: FINOPS_COST_FACT_UPSERT_ID + alias: live + waitForCompletion: true + + # ── invoices: one application_cost_daily row per application, built from the allocated facts. + - id: invoices + type: module + plugin_type: code-exec + name: "Invoices per application" + metadata: { fanOutPerItem: false } + inputs: + day: "${{ steps.prep.outputs.day }}" + batches: "${{ steps.allocate.outputs.batches }}" + allocator: "${{ variables.allocator }}" + dry_run: "${{ workflow.inputs.dry_run }}" + config: + language: javascript + code: | + var d = String(inputs.day); var now = new Date().toISOString(); + function num(x) { return Math.round((Number(x) || 0) * 1e6) / 1e6; } + var apps = {}; var facts = []; (inputs.batches || []).forEach(function (b) { (b.facts || []).forEach(function (f) { facts.push(f); }); }); + facts.forEach(function (a) { + if (!a || a.stage !== "allocated" || !a.application_id || a.allocation_method === "rollup" || a.subject_type === "unallocated" || a.subject_type === "application") return; + var id = String(a.application_id); + var app = apps[id] || (apps[id] = { application_id: id, application_slug: a.application_slug || null, namespace_id: a.namespace_id || null, account_id: a.account_id || null, total: 0, by_charge_type: {}, by_environment: {}, by_category: {}, by_cloud_service: {}, accounts: {}, items: [] }); + if (!app.application_slug && a.application_slug) app.application_slug = a.application_slug; + var amt = num(a.cost_usd); var ctype = a.charge_type || (a.scope_id ? "scope" : a.service_id ? "service" : "application"); var env = a.environment || "none"; var cat = a.category || "other"; var svc = a.cloud_service || "other"; + app.total = num(app.total + amt); app.by_charge_type[ctype] = num((app.by_charge_type[ctype] || 0) + amt); app.by_environment[env] = num((app.by_environment[env] || 0) + amt); + app.by_category[cat] = num((app.by_category[cat] || 0) + amt); app.by_cloud_service[svc] = num((app.by_cloud_service[svc] || 0) + amt); if (a.cloud_account) app.accounts[String(a.cloud_account)] = true; + app.items.push({ charge_type: ctype, scope_id: a.scope_id || null, scope_name: a.scope_name || null, scope_type: a.scope_type || null, service_id: a.service_id || null, service_name: a.service_name || null, service_kind: a.service_kind || null, + dimensions: a.dimensions || null, environment: a.environment || null, category: cat, cloud_service: a.cloud_service || null, subject_type: a.subject_type, subject_id: a.subject_id, subject_name: a.subject_name || a.subject_id, + cluster: a.cluster || null, component: a.component || null, cost_usd: amt, share: num(a.share), allocation_method: a.allocation_method, rule_id: a.rule_id || null, source_fact_id: a.source_fact_id || null }); + }); + var rows = Object.keys(apps).map(function (id) { + var a = apps[id]; a.items.sort(function (x, y) { return y.cost_usd - x.cost_usd; }); + return { id: id + "-" + d, date: d, day: d, application_id: id, application_slug: a.application_slug, namespace_id: a.namespace_id, account_id: a.account_id, total_usd: a.total, currency: "USD", + charge_items: a.items, charge_items_count: a.items.length, totals: { by_charge_type: a.by_charge_type, by_environment: a.by_environment, by_category: a.by_category, by_cloud_service: a.by_cloud_service }, + cloud_accounts: Object.keys(a.accounts), allocator: String(inputs.allocator), computed_at: now }; + }).sort(function (x, y) { return y.total_usd - x.total_usd; }); + var batches = []; for (var bi = 0; bi < rows.length; bi += 25) batches.push({ facts: rows.slice(bi, bi + 25), catalog_slug: "application_cost_daily", dry_run: !!inputs.dry_run }); + return { count: rows.length, batches: batches, batches_out: inputs.dry_run ? batches : [] }; + + - id: write_invoices + type: module + plugin_type: sub-workflow + name: "Upsert invoices (fan-out)" + forEach: + expression: "${{ steps.invoices.outputs.batches }}" + itemVariable: batch + spreadItem: true + parallel: true + maxConcurrency: 2 + config: + workflowId: FINOPS_COST_FACT_UPSERT_ID + alias: live + waitForCompletion: true + + # ── stale sweep: re-allocating a day must not leave rows from a previous allocation behind. + - id: read_allocated + type: module + plugin_type: np-entity-paginated-fetch + output_projection: ["items[].id", "items[].day", "items[].date", "items[].stage", "items[].cost_usd", "totalFetched", "pages"] + name: "Allocated rows already in the catalog" + inputs: + filters: "${{ steps.prep.outputs.allocated_filters }}" + config: + apiKey: "${{ secrets.NP_API_KEY }}" + entity: "catalog/instances/cost_daily" + filters: { stage: "allocated" } + limit: 100 + maxPages: 500 + + - id: stale + type: module + plugin_type: code-exec + name: "Rows of the day this run did not produce" + metadata: { fanOutPerItem: false } + inputs: + day: "${{ steps.prep.outputs.day }}" + ids: "${{ steps.allocate.outputs.ids }}" + existing: "${{ steps.read_allocated.outputs.items }}" + dry_run: "${{ workflow.inputs.dry_run }}" + config: + language: javascript + code: | + var d = String(inputs.day); var mine = {}; (inputs.ids || []).forEach(function (id) { mine[id] = true; }); + var stale = (inputs.existing || []).filter(function (r) { return r && r.stage === "allocated" && (r.day === d || r.date === d) && !mine[r.id]; }); + var usd = Math.round(stale.reduce(function (s, r) { return s + (Number(r.cost_usd) || 0); }, 0) * 1e6) / 1e6; + return { count: stale.length, stale_usd: usd, ids: stale.map(function (r) { return r.id; }).slice(0, 50), + list: inputs.dry_run ? [] : stale.map(function (r) { return { path: "/catalog/instances/cost_daily/" + encodeURIComponent(r.id) }; }) }; + + - id: delete_stale + type: module + plugin_type: np-api-call + name: "Delete stale allocated rows (fan-out)" + forEach: + expression: "${{ steps.stale.outputs.list }}" + itemVariable: row + spreadItem: true + parallel: true + maxConcurrency: 5 + config: + apiKey: "${{ secrets.NP_API_KEY }}" + method: DELETE + path: "/catalog/instances/cost_daily/placeholder" + + - id: summary + type: module + plugin_type: code-exec + name: "Summary" + metadata: { fanOutPerItem: false } + inputs: + summary: "${{ steps.allocate.outputs.summary }}" + results: "${{ steps.write_facts.outputs.items }}" + invoices: "${{ steps.write_invoices.outputs.items }}" + stale: "${{ steps.stale.outputs }}" + invoice_count: "${{ steps.invoices.outputs.count }}" + config: + language: javascript + code: | + var s = Object.assign({}, inputs.summary || {}); s.invoices = Number(inputs.invoice_count) || 0; function wr(list) { return (list || []).reduce(function (n, r) { var o = (r || {}).outputs || r || {}; return n + (Number(o.written) || 0); }, 0); } + s.invoices_written = s.dry_run ? 0 : wr(inputs.invoices); s.written = s.dry_run ? 0 : wr(inputs.results) + s.invoices_written; s.stale_rows = (inputs.stale || {}).count || 0; s.stale_usd = (inputs.stale || {}).stale_usd || 0; s.stale_deleted = s.dry_run ? 0 : s.stale_rows; return s; + +connections: + - { from: start, to: prep } + - { from: daily_cron, to: prep } + - { from: prep, to: read_raw } + - { from: prep, to: read_rules } + - { from: prep, to: scopes } + - { from: prep, to: services } + - { from: read_raw, to: allocate } + - { from: read_rules, to: allocate } + - { from: scopes, to: allocate } + - { from: services, to: allocate } + - { from: allocate, to: write_facts } + - { from: allocate, to: invoices } + - { from: invoices, to: write_invoices } + - { from: write_facts, to: read_allocated } + - { from: read_allocated, to: stale } + - { from: stale, to: delete_stale } + - { from: delete_stale, to: summary } + - { from: write_invoices, to: summary } + +outputs: + summary: "${{ steps.summary.outputs }}" + # dry_run only (see summary): a real day returns [] so the dispatcher's history stays small + batches: "${{ steps.allocate.outputs.batches_out }}" + invoice_batches: "${{ steps.invoices.outputs.batches_out }}" + unallocated_leaves: "${{ steps.allocate.outputs.unallocated_leaves }}" diff --git a/finops/wf3-k8s-consumption-daily.yaml b/finops/wf3-k8s-consumption-daily.yaml new file mode 100644 index 0000000..8b6e9a9 --- /dev/null +++ b/finops/wf3-k8s-consumption-daily.yaml @@ -0,0 +1,427 @@ +# finops/wf3-k8s-consumption-daily.yaml +# +# Kubernetes consumption of one day, per null scope, priced with the cluster's +# BLENDED rates of that day (raw cluster row from wf1: rate_cpu_usd_core_h, +# rate_mem_usd_gb_h) — and the consumption metric the allocator uses to split the +# cluster's cost among applications. +# +# Chargeback model (same as the cost tracker, docs/decisions D-chargeback): a scope +# pays max(usage, request) PER HOUR — requests bind nodes whether used or not. +# core_h_chargeable = Σ_hours max(cpu_used, cpu_requested) / 1000 +# gb_h_chargeable = Σ_hours max(mem_used, mem_requested) / 1024 +# cost_usd = core_h_chargeable × rate_cpu + gb_h_chargeable × rate_mem +# usage_usd / waste_usd alongside (what it used / reserved-but-unused). +# Σ scope cost ≤ cluster cost; the difference (system namespaces, observability +# stacks, idle headroom) is the cluster's k8s overhead and stays with the cluster. +# +# Data path: scopes from the lake → per scope, the collector script on the +# customer's agent (`collect-metrics.sh --mode day --scope `, Prometheus in the +# cluster, matches both k8s scope naming styles by pod name) → raw facts +# `raw-k8s-scope--` (source k8s) + the cluster row patched with +# `metric: k8s.chargeable`, `metric_shares` {scope_id → share}, `metric_owners`. +# Replaces the metadata `cost_tracking` write of cost/wf1b: everything is catalog. +id: finops_k8s_consumption_daily +name: "FinOps — Kubernetes consumption per scope (daily)" +description: > + Per-scope Kubernetes consumption of a day (usage vs request, priced with the + cluster's blended rates) and the consumption shares that split the cluster's + cost among applications. +path: "/finops" +semantic_version: 0.1.0 + +inputs: + date: + type: string + required: false + description: "Day, YYYY-MM-DD (UTC). Default: yesterday." + cluster: + type: string + required: false + description: "Cluster name as in the raw cluster row (default: variables.cluster)" + nrn: + type: string + required: false + description: "Only scopes under this NRN" + dry_run: + type: boolean + required: false + collector_mode: + type: string + required: false + description: "Override of variables.collector_mode: agent | newrelic" + nr_account_id: + type: number + required: false + description: "Override of the NR_ACCOUNT_ID config entry (newrelic mode)" + +variables: + # Where the per-scope consumption comes from: `agent` (collector script on the customer's agent → + # Prometheus in the cluster) or `newrelic` (NerdGraph K8sContainerSample, labels scope_id/application_id + # ingested by the NR Kubernetes integration; needs config entries NR_USER_KEY, NR_ACCOUNT_ID [, NR_GRAPHQL_URL]). + collector_mode: + initialValue: "agent" + cluster: + initialValue: "runtime" + # NRN subtree of the scopes this cluster serves (the org, or one account): lake + API listings. + org_nrn: + initialValue: "organization=1255165411" + # Agent that can reach the cluster's Prometheus (the same one the cost tracker uses). + agent_tags: + initialValue: { cluster: "runtime" } + agent_nrn: + initialValue: "" + # Collector command on the agent (command repo), day mode: ` --mode day --date D --scope ID`. + collector_cmd: + initialValue: "nullplatform/platform-scopes-override/cost/collect_metrics --prom http://prometheus-server.default.svc.cluster.local" + max_concurrency: + initialValue: 4 + collector: + initialValue: "finops_k8s_consumption_daily@0.1.0" + +steps: + - id: start + type: trigger + plugin_type: manual + name: "Collect k8s consumption" + config: + description: "Per-scope Kubernetes consumption of a day." + inputs: + date: { type: string, required: false, description: "YYYY-MM-DD" } + cluster: { type: string, required: false, description: "Cluster name" } + nrn: { type: string, required: false, description: "Scope subtree" } + dry_run: { type: boolean, required: false, description: "Compute without writing" } + + - id: prep + type: module + plugin_type: code-exec + name: "Day, cluster, agent" + inputs: + date: "${{ workflow.inputs.date }}" + cluster: "${{ workflow.inputs.cluster }}" + cluster_default: "${{ variables.cluster }}" + collector_mode: "${{ workflow.inputs.collector_mode }}" + collector_mode_default: "${{ variables.collector_mode }}" + agent_tags: "${{ variables.agent_tags }}" + agent_nrn: "${{ variables.agent_nrn }}" + org_nrn: "${{ variables.org_nrn }}" + config: + language: javascript + code: | + function iso(dt) { return dt.toISOString().slice(0, 10); } + var day = inputs.date && /^\d{4}-\d{2}-\d{2}$/.test(String(inputs.date)) ? String(inputs.date) : iso(new Date(Date.now() - 86400000)); + var cluster = String(inputs.cluster || inputs.cluster_default || "").trim(); + if (!cluster) throw new Error("cluster is required"); + var mode = String(inputs.collector_mode || inputs.collector_mode_default || "agent").trim().toLowerCase(); if (mode !== "agent" && mode !== "newrelic") throw new Error("collector_mode must be agent or newrelic (got " + mode + ")"); + var sel = { tags: inputs.agent_tags || { cluster: cluster } }; + if (inputs.agent_nrn) sel.nrn = String(inputs.agent_nrn); + return { day: day, cluster: cluster, collector_mode: mode, agent_selector: sel, scope_filters: { nrn: String(inputs.org_nrn || ""), status: "active" }, cluster_row_path: "/catalog/instances/cost_daily/raw-cluster-" + cluster.toLowerCase().replace(/[^a-z0-9_]+/g, "-") + "-" + day }; + + - id: lake_scopes + type: module + plugin_type: np-lake-query + # Only the subtree the cluster serves (variables.org_nrn: the org, or one account) — an org with + # thousands of scopes dragged ~0.5 MB through the parent's history. No SQL comments in the query: + # the Lake splits statements on ';' and rejects "two statements". + name: "Active k8s-capable scopes (lake)" + config: + apiKey: "${{ secrets.NP_API_KEY }}" + sql: | + SELECT s.id AS scope_id, s.name AS scope_name, s.scope_slug AS scope_slug, + s.nrn AS scope_nrn, s.type AS scope_type, a.app_name AS app_name, a.application_slug AS app_slug + FROM core_entities_scope AS s FINAL + JOIN core_entities_application AS a FINAL + ON s.application_id = a.app_id AND a._deleted = 0 + WHERE s._deleted = 0 + AND s.status = 'active' + AND s.type IN ('web_pool_k8s', 'custom', 'web_pool') + AND (s.nrn = '${{ variables.org_nrn }}' OR s.nrn LIKE '${{ variables.org_nrn }}:%') + LIMIT 5000 + + - id: scope_dims + type: module + plugin_type: np-entity-paginated-fetch + name: "Scopes with dimensions (API)" + output_projection: ["items[].id", "items[].dimensions", "totalFetched", "pages"] + inputs: + filters: "${{ steps.prep.outputs.scope_filters }}" + config: + apiKey: "${{ secrets.NP_API_KEY }}" + entity: "scope" + filters: { status: "active" } + limit: 100 + maxPages: 100 + + - id: cluster_row + type: module + plugin_type: np-api-call + name: "Raw cluster row of the day (rates)" + inputs: + path: "${{ steps.prep.outputs.cluster_row_path }}" + config: + apiKey: "${{ secrets.NP_API_KEY }}" + method: GET + path: "/catalog/instances/cost_daily/placeholder" + failOnHttpError: false + + - id: prep_run + type: module + plugin_type: code-exec + name: "Scopes → collector commands" + metadata: { fanOutPerItem: false } + join_strategy: all + inputs: + rows: "${{ steps.lake_scopes.outputs.rows }}" + api_scopes: "${{ steps.scope_dims.outputs.items }}" + day: "${{ steps.prep.outputs.day }}" + nrn: "${{ workflow.inputs.nrn }}" + base: "${{ variables.collector_cmd }}" + cluster_status: "${{ steps.cluster_row.outputs.status }}" + cluster_body: "${{ steps.cluster_row.outputs.body }}" + config: + language: javascript + code: | + var rows = inputs.rows || []; var nrn = String(inputs.nrn || "").trim(); + var dimsById = {}; (inputs.api_scopes || []).forEach(function (sc) { if (sc && sc.id) dimsById[String(sc.id)] = sc.dimensions && Object.keys(sc.dimensions).length ? sc.dimensions : null; }); + function dims(n) { var o = {}; String(n || "").split(":").forEach(function (kv) { var p = kv.split("="); if (p.length === 2) o[p[0]] = p[1]; }); return o; } + var scopes = []; + rows.forEach(function (r) { + var sn = String(r.scope_nrn || ""); if (nrn && sn !== nrn && sn.indexOf(nrn + ":") !== 0) return; + var d = dims(sn); + scopes.push({ scope_id: String(r.scope_id), scope_name: r.scope_name || null, scope_slug: r.scope_slug || null, scope_type: r.scope_type || null, app_name: r.app_name || null, app_slug: r.app_slug || null, + application_id: d.application || null, namespace_id: d.namespace || null, account_id: d.account || null, nrn: sn || null, + dimensions: dimsById[String(r.scope_id)] || null }); + }); + if (Number(inputs.cluster_status) !== 200 || !inputs.cluster_body || !inputs.cluster_body.id) throw new Error("raw cluster row not found for the day (run finops_aws_billing_daily first): " + JSON.stringify(inputs.cluster_body).slice(0, 200)); + var c = inputs.cluster_body; + if (!(Number(c.rate_cpu_usd_core_h) > 0) || !(Number(c.rate_mem_usd_gb_h) > 0)) throw new Error("cluster row has no blended rates"); + var cmds = scopes.map(function (s) { return { cmdline: String(inputs.base) + " --mode day --date " + String(inputs.day) + " --scope " + s.scope_id }; }); + return { scopes: scopes, commands: cmds, count: scopes.length, cluster: { id: c.id, cost_usd: Number(c.cost_usd) || 0, rate_cpu: Number(c.rate_cpu_usd_core_h), rate_mem: Number(c.rate_mem_usd_gb_h), cpu_share: Number(c.cpu_share) || 0.5, core_h: Number(c.cpu_capacity_core_h) || 0, gb_h: Number(c.mem_capacity_gb_h) || 0 }, cluster_body: c }; + + - id: route + type: decider + plugin_type: conditional + name: "Collector: New Relic or agent?" + config: + expression: "steps.prep.outputs.collector_mode == 'newrelic'" + + # ── New Relic path: one NRQL for the whole cluster-day, FACET scope_id × hour ───────────── + - id: nr_build + type: module + plugin_type: code-exec + name: "Build NerdGraph query (cluster-day by scope × hour)" + inputs: + day: "${{ steps.prep.outputs.day }}" + cluster: "${{ steps.prep.outputs.cluster }}" + account_id: "${{ workflow.inputs.nr_account_id || vars.NR_ACCOUNT_ID }}" + scopes: "${{ steps.prep_run.outputs.scopes }}" + config: + language: javascript + code: | + var accountId = Number(inputs.account_id); if (!(accountId > 0)) throw new Error("NR_ACCOUNT_ID config entry must be a positive number"); + var day = String(inputs.day); var next = new Date(new Date(day + "T00:00:00Z").getTime() + 86400000).toISOString().slice(0, 10); + var cluster = String(inputs.cluster).replace(/'/g, ""); + // Per (scope, hour): average per pod-sample × distinct pods ≈ the scope's hourly consumption; same + // pod-hour semantics as the agent collector (cost-nr wf1b, FACET podName+hour, summed here per hour). + var nrql = "SELECT average(cpuUsedCores) AS cpu, average(memoryWorkingSetBytes) AS mem, average(cpuRequestedCores) AS cpuReq, average(memoryRequestedBytes) AS memReq, uniqueCount(podName) AS pods, count(*) AS samples " + + "FROM K8sContainerSample WHERE clusterName = '" + cluster + "' AND containerName = 'application' AND `label.scope_id` IS NOT NULL " + + "FACET `label.scope_id`, hourOf(timestamp) SINCE '" + day + " 00:00:00 UTC' UNTIL '" + next + " 00:00:00 UTC' LIMIT MAX"; + function esc(q) { return q.split("\\").join("\\\\").split('"').join('\\"'); } + var gql = "{ actor { account(id: " + accountId + ") { usage: nrql(query: \"" + esc(nrql) + "\", timeout: 120) { results } } } }"; + return { payload: { query: gql }, nrql: nrql, scopes: (inputs.scopes || []).length }; + + # inputs shield: http-request merges ctx.inputs OVER config — the declared body keeps inherited + # passthrough from touching url/method. + - id: nr_query + type: module + plugin_type: http-request + name: "Query New Relic" + error_handling: + retry_policy: + max_attempts: 3 + initial_interval: "5s" + backoff_strategy: exponential + jitter: 0.3 + inputs: + body: "${{ steps.nr_build.outputs.payload }}" + config: + url: "${{ vars.NR_GRAPHQL_URL || 'https://api.newrelic.com/graphql' }}" + method: POST + timeout: "150s" + headers: + Api-Key: "${{ secrets.NR_USER_KEY }}" + Content-Type: "application/json" + + - id: nr_shape + type: module + plugin_type: code-exec + name: "NR rows → per-scope collector shape" + inputs: + scopes: "${{ steps.prep_run.outputs.scopes }}" + status: "${{ steps.nr_query.outputs.statusCode }}" + body: "${{ steps.nr_query.outputs.body }}" + config: + language: javascript + code: | + var body = inputs.body; if (typeof body === "string") { try { body = JSON.parse(body); } catch (e) { body = null; } } + if (Number(inputs.status) !== 200 || !body) throw new Error("NerdGraph HTTP " + inputs.status + ": " + String(JSON.stringify(inputs.body) || "").slice(0, 200)); + if (body.errors && body.errors.length) throw new Error("NerdGraph error: " + JSON.stringify(body.errors).slice(0, 300)); + var rows = ((((body.data || {}).actor || {}).account || {}).usage || {}).results || []; + // Same shape the agent collector prints per scope (hours[] + totals), keyed by scope id. + var per = {}; + rows.forEach(function (r) { + var sid = String(r["label.scope_id"] || (r.facet && r.facet[0]) || ""); if (!sid) return; + var hourLabel = String(r["Hour of timestamp"] || (r.facet && r.facet[1]) || ""); var h = parseInt(hourLabel, 10); if (isNaN(h)) return; + var pods = Number(r.pods) || 0; if (!(pods > 0)) return; + var o = per[sid] || (per[sid] = { samples: 0, hours: [], cpu_mc_hours: 0, mem_mb_hours: 0 }); + var cpuMc = (Number(r.cpu) || 0) * 1000 * pods, memMb = (Number(r.mem) || 0) / 1048576 * pods; + o.hours.push({ hour: h, cpu_mc: cpuMc, mem_mb: memMb, cpu_req_mc: (Number(r.cpuReq) || 0) * 1000 * pods, mem_req_mb: (Number(r.memReq) || 0) / 1048576 * pods, pods: pods }); + o.cpu_mc_hours += cpuMc; o.mem_mb_hours += memMb; o.samples += Number(r.samples) || pods; + }); + var items = (inputs.scopes || []).map(function (s) { var o = per[String(s.scope_id)]; return { stdout: JSON.stringify(o || { samples: 0, hours: [] }) }; }); + return { items: items, scopes_with_data: Object.keys(per).length, rows: rows.length }; + + - id: collect + type: module + plugin_type: np-agent-command + name: "Collector on the agent (per scope, fan-out)" + forEach: + expression: "${{ steps.prep_run.outputs.commands }}" + itemVariable: cmd + spreadItem: true + parallel: true + maxConcurrency: 4 + error_handling: + retry_policy: + max_attempts: 2 + initial_interval: "5s" + backoff_strategy: exponential + jitter: 0.3 + config: + command_type: exec + apikey: "${{ secrets.NP_API_KEY }}" + timeout_seconds: 120 + inject_workflow_env: false + agent_selector: + nrn: "${{ variables.agent_nrn }}" + tags: "${{ variables.agent_tags }}" + cmdline: "/bin/true" + + - id: build + type: module + plugin_type: code-exec + name: "Price scopes + cluster shares" + metadata: { fanOutPerItem: false } + join_strategy: any + inputs: + results_nr: "${{ steps.nr_shape.outputs.items }}" + day: "${{ steps.prep.outputs.day }}" + cluster_name: "${{ steps.prep.outputs.cluster }}" + scopes: "${{ steps.prep_run.outputs.scopes }}" + cluster: "${{ steps.prep_run.outputs.cluster }}" + cluster_body: "${{ steps.prep_run.outputs.cluster_body }}" + results: "${{ steps.collect.outputs.items }}" + collector: "${{ variables.collector }}" + collector_mode: "${{ steps.prep.outputs.collector_mode }}" + dry_run: "${{ workflow.inputs.dry_run }}" + config: + language: javascript + code: | + var d = String(inputs.day); var now = new Date().toISOString(); var C = inputs.cluster || {}; var cname = String(inputs.cluster_name); + function num(x) { return Math.round((Number(x) || 0) * 1e6) / 1e6; } + var scopes = inputs.scopes || []; var results = String(inputs.collector_mode) === "newrelic" ? (inputs.results_nr || []) : (inputs.results || []); + var facts = []; var usageRows = []; var shares = {}; var owners = {}; var sum = 0; var withData = 0; var errors = []; var mode = String(inputs.collector_mode || "agent"); + scopes.forEach(function (s, i) { + var r = results[i] || {}; var o = r.outputs || r; var parsed = null; + try { parsed = JSON.parse(String(o.stdout || "")); } catch (e) { errors.push({ scope_id: s.scope_id, error: "stdout is not JSON: " + String(o.stdout || o.stderr || "").slice(0, 120) }); return; } + if (parsed.error) { errors.push({ scope_id: s.scope_id, error: String(parsed.error).slice(0, 200) }); return; } + var samples = Number(parsed.samples) || 0; if (!samples) return; // no pods in this cluster → not a k8s scope here + withData += 1; + var hrs = parsed.hours || []; var chCore = 0, chGb = 0, reqCore = 0, reqGb = 0, pods = 0, podsN = 0; + hrs.forEach(function (h) { + var cu = Number(h.cpu_mc) || 0, mu = Number(h.mem_mb) || 0, cr = Number(h.cpu_req_mc) || 0, mr = Number(h.mem_req_mb) || 0, p = Number(h.pods) || 0; + if (cu <= 0 && mu <= 0 && cr <= 0 && mr <= 0) return; + chCore += Math.max(cu, cr) / 1000; chGb += Math.max(mu, mr) / 1024; reqCore += cr / 1000; reqGb += mr / 1024; if (p > 0) { pods += p; podsN += 1; } + }); + var usedCore = (Number(parsed.cpu_mc_hours) || 0) / 1000, usedGb = (Number(parsed.mem_mb_hours) || 0) / 1024; + if (!hrs.length) { chCore = Math.max(usedCore, (Number(parsed.cpu_req_mc_avg) || 0) * samples / 1000); chGb = Math.max(usedGb, (Number(parsed.mem_req_mb_avg) || 0) * samples / 1024); } + var cost = num(chCore * C.rate_cpu + chGb * C.rate_mem); var usage = num(usedCore * C.rate_cpu + usedGb * C.rate_mem); + var f = { id: "raw-k8s-scope-" + s.scope_id + "-" + d, date: d, day: d, stage: "raw", subject_type: "scope", subject_id: s.scope_id, subject_name: (s.app_slug ? s.app_slug + "." : "") + (s.scope_slug || s.scope_name || s.scope_id), + cloud: "aws", cloud_account: inputs.cluster_body.cloud_account || null, cloud_service: null, cluster: cname, region: inputs.cluster_body.region || null, source: "k8s", collected_at: now, collector: String(inputs.collector), + scope_id: s.scope_id, scope_name: s.scope_name || null, scope_type: s.scope_type || null, dimensions: s.dimensions || null, environment: (s.dimensions || {}).environment || null, application_id: s.application_id, application_slug: s.app_slug || null, namespace_id: s.namespace_id, account_id: s.account_id, nrn: s.nrn, + cost_usd: cost, usage_usd: usage, waste_usd: num(Math.max(cost - usage, 0)), core_h_chargeable: num(chCore), gb_h_chargeable: num(chGb), core_h_used: num(usedCore), gb_h_used: num(usedGb), + core_h_requested: num(reqCore), gb_h_requested: num(reqGb), pods_avg: podsN ? num(pods / podsN) : null, quantity: samples, units: "samples", + rate_cpu_usd_core_h: C.rate_cpu, rate_mem_usd_gb_h: C.rate_mem, allocation_method: "direct_resource", metric: "k8s.chargeable", usage_id: "usage-" + s.scope_id + "-" + d }; + facts.push(f); sum += cost; + // The scope's consumption as its own entity (scope_usage_daily): the right-sizing source, and what the + // pricing above is derived from — re-pricing a day never needs the metrics source again. + usageRows.push({ id: "usage-" + s.scope_id + "-" + d, date: d, day: d, scope_id: s.scope_id, scope_name: s.scope_name || null, scope_slug: s.scope_slug || null, scope_type: s.scope_type || null, + application_id: s.application_id, application_slug: s.app_slug || null, namespace_id: s.namespace_id, account_id: s.account_id, nrn: s.nrn, cluster: cname, + cloud: "aws", cloud_account: inputs.cluster_body.cloud_account || null, region: inputs.cluster_body.region || null, dimensions: s.dimensions || null, environment: (s.dimensions || {}).environment || null, + source: mode, collector: String(inputs.collector), collected_at: now, samples: samples, pods_avg: podsN ? num(pods / podsN) : null, hours_with_data: hrs.length, + core_h_used: num(usedCore), core_h_requested: num(reqCore), core_h_chargeable: num(chCore), gb_h_used: num(usedGb), gb_h_requested: num(reqGb), gb_h_chargeable: num(chGb), + cpu_utilization_pct: reqCore > 0 ? num(100 * usedCore / reqCore) : null, mem_utilization_pct: reqGb > 0 ? num(100 * usedGb / reqGb) : null, + cpu_waste_core_h: num(Math.max(reqCore - usedCore, 0)), mem_waste_gb_h: num(Math.max(reqGb - usedGb, 0)), hours: hrs }); + if (cost > 0) { shares[s.scope_id] = cost; owners[s.scope_id] = { application_id: s.application_id, namespace_id: s.namespace_id, scope_id: s.scope_id, account_id: s.account_id, application_slug: s.app_slug || null, scope_name: s.scope_name || null, scope_type: s.scope_type || null, dimensions: s.dimensions || null }; } + }); + var clusterCost = num(C.cost_usd); var denom = clusterCost > 0 ? Math.max(clusterCost, sum) : sum; // shares never exceed 1 + Object.keys(shares).forEach(function (k) { shares[k] = denom ? num(shares[k] / denom) : 0; }); + var overhead = num(Math.max(clusterCost - sum, 0)); + var clusterRow = Object.assign({}, inputs.cluster_body, { metric: "k8s.chargeable", metric_shares: shares, metric_owners: owners, k8s_overhead_usd: overhead, collected_at: now }); + var writes = usageRows.map(function (u) { return { fact: u, catalog_slug: "scope_usage_daily" }; }).concat(facts.map(function (f) { return { fact: f, catalog_slug: "cost_daily" }; })); + if (Object.keys(shares).length) writes.push({ fact: clusterRow, catalog_slug: "cost_daily" }); + var summary = { day: d, cluster: cname, source: mode, cluster_cost_usd: clusterCost, scopes: scopes.length, with_data: withData, usage_rows: usageRows.length, scopes_cost_usd: num(sum), overhead_usd: overhead, coverage_pct: clusterCost ? num(100 * Math.min(sum, clusterCost) / clusterCost) : 0, + rate_cpu_usd_core_h: C.rate_cpu, rate_mem_usd_gb_h: C.rate_mem, errors: errors.slice(0, 20), error_count: errors.length, dry_run: !!inputs.dry_run, + top: facts.slice().sort(function (a, b) { return b.cost_usd - a.cost_usd; }).slice(0, 15).map(function (f) { return { scope: f.subject_name, application_id: f.application_id, cost_usd: f.cost_usd, usage_usd: f.usage_usd, core_h_chargeable: f.core_h_chargeable, gb_h_chargeable: f.gb_h_chargeable }; }) }; + return { facts: facts, usage: usageRows, cluster_row: clusterRow, to_write: inputs.dry_run ? [] : writes, summary: summary }; + + - id: write + type: module + plugin_type: sub-workflow + name: "Upsert scope facts + cluster metric (fan-out)" + forEach: + expression: "${{ steps.build.outputs.to_write }}" + itemVariable: row + spreadItem: true + parallel: true + maxConcurrency: 5 + config: + # Definition id of finops/wf-cost-fact-upsert.yaml (patched at publish time). + workflowId: FINOPS_COST_FACT_UPSERT_ID + alias: live + waitForCompletion: true + + - id: summary + type: module + plugin_type: code-exec + name: "Summary" + metadata: { fanOutPerItem: false } + inputs: + summary: "${{ steps.build.outputs.summary }}" + results: "${{ steps.write.outputs.items }}" + config: + language: javascript + code: | + var s = Object.assign({}, inputs.summary || {}); s.written = s.dry_run ? 0 : (inputs.results || []).length; return s; + +connections: + - { from: start, to: prep } + - { from: prep, to: lake_scopes } + - { from: prep, to: scope_dims } + - { from: prep, to: cluster_row } + - { from: lake_scopes, to: prep_run } + - { from: scope_dims, to: prep_run } + - { from: cluster_row, to: prep_run } + - { from: prep_run, to: route } + - { id: r_agent, from: route, to: collect, source_port: "false" } + - { id: r_nr, from: route, to: nr_build, source_port: "true" } + - { from: nr_build, to: nr_query } + - { from: nr_query, to: nr_shape } + - { from: nr_shape, to: build } + - { from: collect, to: build } + - { from: build, to: write } + - { from: write, to: summary } + +outputs: + summary: "${{ steps.summary.outputs }}" + facts: "${{ steps.build.outputs.facts }}" + usage: "${{ steps.build.outputs.usage }}" diff --git a/package.json b/package.json index b3a7d2f..c5f482f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "description": "Production-grade workflow suites for the nullplatform workflow engine \u2014 executable documentation: clone, npm install, npx vitest.", "scripts": { "test": "vitest run", - "validate": "find . -name '*.yaml' -not -path './node_modules/*' -not -path '*/checklists/*' | xargs np-workflow validate" + "validate": "find . -name '*.yaml' -not -path '*/node_modules/*' -not -path '*/checklists/*' -not -path './finops/tool-cloud-query.yaml' | xargs np-workflow validate" }, "devDependencies": { "@nullplatform/workflow-kit": "^0.1.0",