diff --git a/legacy/cost-optimization/README.md b/legacy/cost-optimization/README.md new file mode 100644 index 0000000..446a188 --- /dev/null +++ b/legacy/cost-optimization/README.md @@ -0,0 +1,33 @@ +# cost-optimization (legacy — superseded by `cost/`) + +> **Do not deploy this suite on new orgs.** It is the July 2026 first-pass +> PoC of cost governance, kept for reference. The production suite is +> [`cost/`](../../cost/) (wf1 cost tracker, wf2 right-sizing scanner, wf3 +> events, wf4 apply, wf6 calibration, wf7 closer, wf8 QA), which replaced +> this design after live bring-up on two real clusters. + +## What it was + +A scanner → planner → apply pipeline over action items: + +| File | Role | +|---|---| +| `wf1-cost-scanner.yaml` | Entry scanner: finds over-provisioned scopes, opens action items | +| `wf1b/c/d-cost-scanner-*.yaml` | Same scanner scoped per namespace / account / organization | +| `analyze-scope.yaml` | Per-scope analysis sub-workflow | +| `wf2-remediation-planner.yaml` | Turns findings into remediation suggestions | +| `wf3-apply-and-deploy.yaml` | Applies the suggested resources and redeploys the scope | +| `on-action-item-event.yaml` | Reacts to action-item transitions | + +## Why it was replaced + +- Billing moved to **scope CONFIG as the source of truth** with a + cluster-level loading factor (`cost/wf6-cluster-cost-calibration.yaml`) + instead of point-in-time usage inference. +- Metrics collection moved to a remote agent on the cluster + (`COST_AGENT_CMDLINE`) rather than API-side estimation. +- The scanner/closer pair adopted the streaming-pagination + idempotent + action-item patterns shared with `ami-drift/` and `runtime-lifecycle/`. + +See `cost/docs/architecture.md` and `cost/docs/decisions.md` for the +decision trail. diff --git a/legacy/cost-optimization/analyze-scope.yaml b/legacy/cost-optimization/analyze-scope.yaml new file mode 100644 index 0000000..f78a017 --- /dev/null +++ b/legacy/cost-optimization/analyze-scope.yaml @@ -0,0 +1,567 @@ +# Sub-workflow: Analyze a single scope for cost optimization +id: analyze_scope +name: "Analyze Scope" +description: > + Analyzes a single scope for over-provisioned CPU and memory. + Creates an action item if waste exceeds the minimum threshold. + Only scope_id is required — application_id, nrn, and name are derived + from the scope details fetched at the start. +path: "/nullplatform/cost-optimization" +semantic_version: 1.0.0 + +inputs: + scope_id: + type: number + required: true + description: "NP scope ID — the only required input." + np_api_base: + type: string + required: false + +secrets: + np_api_token: + key: "np.api.token" + +# Workflow shared context. Downstream steps read these via ${{ variables.X }} +# instead of reaching into other steps' outputs (which would couple steps to +# each other's names + output shape — fragile across renames/refactors). +variables: + scope: { initialValue: null } # populated after fetch_scope + dup_check: { initialValue: { count: 0, items: [] } } # populated after check_duplicates + finding: { initialValue: null } # populated after analyze (when has_finding) + action_item_id: { initialValue: null } # populated after create_ai + +steps: + - id: start + type: trigger + plugin_type: manual + name: "Start" + config: + description: "Analyzes a single scope for over-provisioned resources. Only needs scope_id." + inputs: + scope_id: + type: number + required: true + description: "NP scope ID (application_id, nrn, name are derived from the scope)" + placeholder: "e.g. 1823123121" + + # Fetch scope details — the only serial call. Everything else runs in parallel. + - id: fetch_scope + type: module + plugin_type: http-request + name: "Fetch Scope" + config: + method: GET + url: "${{ (workflow.inputs.np_api_base || 'https://api.nullplatform.com') }}/scope/${{ workflow.inputs.scope_id }}" + headers: + Authorization: "Bearer ${{ secrets.np_api_token }}" + + # Stash the entire scope into shared context. Anything downstream that + # needs nrn/application_id/name reads from variables.scope.* — never from + # steps.fetch_scope.outputs.*. This is the single point where the scope + # leaves its producing step. + - id: stash_scope + type: module + plugin_type: set-variable + name: "Stash Scope → variables" + # Bridge fetch_scope's response body into shared context. + # See `stash_dup_check` below for the inputs/value pattern rationale. + inputs: + body: "${{ steps.fetch_scope.outputs.body }}" + config: + path: scope + value: "${{ inputs.body }}" + + # ── Active-scope guard ─────────────────────────────────────────────── + # Skip anything that isn't an active scope (deleted, archived, etc.) — + # there's nothing to right-size on a non-active workload, and any + # "savings" computed from stale data would be misleading. + - id: is_active + type: decider + plugin_type: conditional + name: "Scope Active?" + config: + expression: "variables.scope.status == 'active'" + + - id: log_inactive + type: module + plugin_type: log + name: "Skip: Scope Not Active" + config: + level: info + message: "Scope ${{ workflow.inputs.scope_id }}: skipped — status='${{ variables.scope.status }}' (not active)" + + # ── Deduplication check ────────────────────────────────────────────── + - id: check_duplicates + type: module + plugin_type: np-action-item-find + name: "Check for Open Duplicates" + config: + nrn: "${{ variables.scope.nrn }}" + status: + - open + - pending_verification + - pending_deferral + - deferred + labels: + workflow_type: cost-optimization + metadata: + scope_id: "${{ workflow.inputs.scope_id }}" + limit: 1 + + # Stash the full check_duplicates result into shared context. set-variable + # uses an `inputs:` declaration to bridge the producing step's outputs + # into the variable — that keeps the rule "no steps.X.outputs in config" + # intact (the only reference is in `inputs:`, which IS the documented + # contract for cross-step data). + - id: stash_dup_check + type: module + plugin_type: set-variable + name: "Stash Dedup Result → variables" + inputs: + raw: "${{ steps.check_duplicates.outputs }}" + config: + path: dup_check + value: "${{ inputs.raw }}" + + - id: has_duplicate + type: decider + plugin_type: conditional + name: "Already Has Open AI?" + config: + # Raw expression — conditional reads from variables (set-variable + # has populated dup_check by now). + expression: "variables.dup_check.count > 0" + + - id: log_duplicate + type: module + plugin_type: log + name: "Skip: Duplicate Found" + config: + level: info + message: "Scope ${{ workflow.inputs.scope_id }}: skipped — open action item already exists (${{ variables.dup_check.items[0].id }})" + + # ── Parallel fetches — read application_id from shared context ───── + - id: fetch_instances + type: module + plugin_type: http-request + name: "Fetch Instances" + config: + method: GET + url: "${{ (workflow.inputs.np_api_base || 'https://api.nullplatform.com') }}/telemetry/instance?application_id=${{ variables.scope.application_id }}&scope_id=${{ workflow.inputs.scope_id }}" + headers: + Authorization: "Bearer ${{ secrets.np_api_token }}" + + - id: fetch_cpu + type: module + plugin_type: http-request + name: "Fetch CPU Metrics (12h)" + config: + method: GET + url: "${{ (workflow.inputs.np_api_base || 'https://api.nullplatform.com') }}/telemetry/application/${{ variables.scope.application_id }}/metric/system.cpu_usage_percentage?scope_id=${{ workflow.inputs.scope_id }}&minutes=720&period=300" + headers: + Authorization: "Bearer ${{ secrets.np_api_token }}" + + - id: fetch_mem + type: module + plugin_type: http-request + name: "Fetch Memory Metrics (12h)" + config: + method: GET + url: "${{ (workflow.inputs.np_api_base || 'https://api.nullplatform.com') }}/telemetry/application/${{ variables.scope.application_id }}/metric/system.memory_usage_percentage?scope_id=${{ workflow.inputs.scope_id }}&minutes=720&period=300" + headers: + Authorization: "Bearer ${{ secrets.np_api_token }}" + + # `analyze` has 3 incoming connections (from fetch_cpu, fetch_mem, + # fetch_instances). It declares its inputs explicitly — that IS the + # contract of this step. Inputs are the documented entry points for a + # plugin and can legitimately reference upstream step outputs (this is + # not the same as reaching into other steps' outputs from inside `config`). + - id: analyze + type: module + plugin_type: code-exec + name: "Analyze Resources" + config: + code: | + var scope = $item.scope_data || {}; + var instances = $item.instances || []; + var cpuMetrics = $item.cpu_body || {}; + var memMetrics = $item.mem_body || {}; + + var THRESHOLD = 20, MIN_SAVINGS = 5, SAFETY = 2, BURST = 6; + var CPU_PRICE = 33.28, MEM_PRICE = 4.49; + + // ── Stage 1: what scope are we analyzing ─────────────────────── + log.info('Analyzing scope', { + scope_id: scope.id, + scope_name: scope.name, + scope_status: scope.status, + application_id: scope.application_id, + tier: scope.tier, + }); + + // ── Stage 2: instances summary ───────────────────────────────── + if (instances && instances.results) { + // NP returns { results: [...], filters: {...} } — unwrap + instances = instances.results; + } + var instanceCount = Array.isArray(instances) ? instances.length : 0; + log.info('Instances fetched', { + count: instanceCount, + first_instance_id: instanceCount > 0 ? instances[0].id : null, + }); + + // ── Stage 3: per-pod allocation FROM SCOPE CONFIG ────────────── + // We read the configured per-pod resources from scope.capabilities + // (this is what we'd actually adjust to right-size the workload). + // Then multiply by replica count to reason about fleet-level waste. + // Reading instances[0].details is wrong because: (a) some instances + // may not have populated details, (b) replica count drives total + // allocation, (c) the recommendation is what we'll write back to + // the scope config, not to a single pod. + var caps = scope.capabilities || {}; + var spec = scope.specification || {}; + var cpuReqM = caps.cpu_millicores || 0; + var memReqMi = caps.ram_memory || 0; + // No explicit limit field on scope config — common k8s pattern is + // limit = request * BURST. Track it for reporting only. + var cpuLimM = cpuReqM > 0 ? Math.ceil(cpuReqM * BURST) : 0; + var memLimMi = memReqMi > 0 ? Math.ceil(memReqMi * BURST) : 0; + + // Replica count: prefer current actual (instance count) — falls + // back to autoscaling.min_replicas (configured floor). + var replicas = instanceCount > 0 + ? instanceCount + : ((caps.autoscaling && caps.autoscaling.min_replicas) || spec.replicas || 1); + + log.info('Per-pod allocation (from scope.capabilities)', { + cpu_request_m: cpuReqM, + memory_request_mi: memReqMi, + replicas: replicas, + source: 'scope.capabilities (right-sizing target)', + }); + log.info('Total fleet allocation', { + total_cpu_m: cpuReqM * replicas, + total_memory_mi: memReqMi * replicas, + }); + + // Compute robust stats from a metric body. Returns: + // avg — arithmetic mean across all samples + // max — true max (kept for transparency) + // p95 — 95th percentile, the value used by the algorithm + // to decide over-provisioning. Discards isolated + // spikes (e.g. NP returning a single 1507% sample + // amid 143 zeros) which would otherwise mislead + // right-sizing. + // sample_count, nonzero_count — for log diagnostics. + function getStats(body) { + var data = []; + if (body && body.results && body.results.length > 0) data = body.results[0].data || []; + if (data.length === 0) return { avg: 0, max: 0, p95: 0, sample_count: 0, nonzero_count: 0 }; + var values = []; + var sum = 0, max = 0, nonzero = 0; + for (var i = 0; i < data.length; i++) { + var v = data[i].value || 0; + values.push(v); + sum += v; + if (v > max) max = v; + if (v > 0) nonzero++; + } + values.sort(function (a, b) { return a - b; }); + // Linear-interpolated p95 — for 144 samples, idx ≈ 135.85 → + // mostly captures the 136th value, smoothing past the top tail. + var rank = 0.95 * (values.length - 1); + var lo = Math.floor(rank); + var hi = Math.ceil(rank); + var p95 = lo === hi ? values[lo] : values[lo] + (values[hi] - values[lo]) * (rank - lo); + return { + avg: sum / data.length, + max: max, + p95: p95, + sample_count: data.length, + nonzero_count: nonzero, + }; + } + + // ── Stage 3: metrics summaries (% utilization over 12h window) ─ + // Use p95 as the "peak" signal so isolated spikes don't dominate + // the recommendation. True max is kept in the log for visibility. + var cpuStats = getStats(cpuMetrics); + var memStats = getStats(memMetrics); + log.info('CPU metrics stats (% utilization, 12h)', { + samples: cpuStats.sample_count, + nonzero_samples: cpuStats.nonzero_count, + avg_pct: Math.round(cpuStats.avg * 100) / 100, + p95_pct: Math.round(cpuStats.p95 * 100) / 100, + max_pct: Math.round(cpuStats.max * 100) / 100, + }); + log.info('Memory metrics stats (% utilization, 12h)', { + samples: memStats.sample_count, + nonzero_samples: memStats.nonzero_count, + avg_pct: Math.round(memStats.avg * 100) / 100, + p95_pct: Math.round(memStats.p95 * 100) / 100, + max_pct: Math.round(memStats.max * 100) / 100, + }); + + // Use p95 (not max) for over-provisioning decision — robust to + // single-sample spikes from NP metric pipeline glitches. + var cpuMaxM = (cpuStats.p95 / 100) * cpuReqM; + var cpuAvgM = (cpuStats.avg / 100) * cpuReqM; + var memMaxMi = (memStats.p95 / 100) * memReqMi; + var memAvgMi = (memStats.avg / 100) * memReqMi; + + // `alloc` is per-pod. `peak`/`avg` are per-pod usage in the same + // unit. Waste and savings get scaled by `replicas` so the dollar + // figure reflects fleet-wide impact (the recommendation itself is + // per-pod since that's what we'll write back to the scope config). + function check(res, alloc, allocLim, peak, avg, repl) { + if (alloc <= 0) { + log.info('Skipping ' + res + ' — no allocation declared (alloc=0)', { resource: res }); + return null; + } + var util = (peak / alloc) * 100; + if (util >= THRESHOLD) { + log.info('Skipping ' + res + ' — utilization >= ' + THRESHOLD + '% (no waste)', { + resource: res, peak_per_pod: peak, allocated_per_pod: alloc, + utilization_pct: Math.round(util * 100) / 100, + }); + return null; + } + var rec = Math.max(1, Math.ceil(peak * SAFETY)); + var recLim = Math.max(rec, Math.ceil(peak * BURST)); + if (rec >= alloc) { + log.info('Skipping ' + res + ' — recommended >= allocated (no savings)', { + resource: res, recommended_per_pod: rec, allocated_per_pod: alloc, + }); + return null; + } + var wastePerPod = alloc - rec; + var fleetWaste = wastePerPod * repl; + var sav = res === 'cpu' ? (fleetWaste / 1000) * CPU_PRICE : (fleetWaste / 1024) * MEM_PRICE; + sav = Math.round(sav * 100) / 100; + if (sav < MIN_SAVINGS) { + log.info('Skipping ' + res + ' — fleet savings $' + sav + '/mo below MIN_SAVINGS=$' + MIN_SAVINGS, { + resource: res, fleet_savings_usd_month: sav, min_required: MIN_SAVINGS, + waste_per_pod: wastePerPod, replicas: repl, + }); + return null; + } + log.info(res.toUpperCase() + ' is over-provisioned across the fleet', { + resource: res, + allocated_per_pod: alloc, + peak_per_pod: Math.round(peak * 100) / 100, + utilization_pct: Math.round(util * 100) / 100, + recommended_per_pod: rec, + replicas: repl, + fleet_waste: fleetWaste, + fleet_savings_usd_month: sav, + }); + return { + resource: res, + allocated: alloc, allocated_limit: allocLim, + peak: Math.round(peak * 100) / 100, avg: Math.round(avg * 100) / 100, + utilization_pct: Math.round(util * 100) / 100, + recommended_request: rec, recommended_limit: Math.min(recLim, allocLim), + waste_units: wastePerPod, + fleet_waste_units: fleetWaste, + replicas: repl, + savings_usd_month: sav, + }; + } + + var analyses = []; + var cpu = check('cpu', cpuReqM, cpuLimM, cpuMaxM, cpuAvgM, replicas); + if (cpu) analyses.push(cpu); + var mem = check('memory', memReqMi, memLimMi, memMaxMi, memAvgMi, replicas); + if (mem) analyses.push(mem); + + if (analyses.length === 0) { + log.warn('No actionable finding for this scope', { + reason: 'no resource crossed the waste threshold (see per-resource skip logs above)', + }); + return { has_finding: false, finding: null }; + } + + var totalSav = 0, resList = []; + for (var a = 0; a < analyses.length; a++) { + totalSav += analyses[a].savings_usd_month; + resList.push(analyses[a].resource); + } + totalSav = Math.round(totalSav * 100) / 100; + var resStr = resList.join(' & '); + var scopeName = scope.name || scope.slug || ('scope-' + scope.id); + var appName = scope.application_name || ('app-' + scope.application_id); + var nrn = scope.nrn || ''; + var pri = totalSav >= 50 ? 'critical' : totalSav >= 20 ? 'high' : totalSav >= 5 ? 'medium' : 'low'; + + log.info('Finding produced', { + resources: resStr, + total_savings_usd_month: totalSav, + priority: pri, + analyses_count: analyses.length, + }); + + return { + has_finding: true, + finding: { + nrn: nrn, + title: resStr.toUpperCase() + ' over-provisioned: ' + appName + ' / ' + scopeName + ' - $' + totalSav.toFixed(2) + '/mo waste', + priority: pri, value: totalSav, resources: resStr, + app_name: appName, scope_name: scopeName, analyses: analyses, + metadata: { + scope_id: scope.id, application_id: scope.application_id, + replicas: (scope.specification && scope.specification.replicas) || 1, + analyses: analyses, + total_savings_usd_month: totalSav, analysis_period_hours: 12, confidence: 0.85 + } + } + }; + inputs: + # code-exec exposes `inputs` inside the sandbox — these declared + # inputs ARE this step's contract (its "function signature"). + # Inputs may reference upstream steps explicitly because that IS + # the documented input wiring; the rule only forbids reaching into + # other steps' outputs from inside `config:` blocks. + # scope_data still comes from shared context (set once after fetch_scope). + scope_data: "${{ variables.scope }}" + instances: "${{ steps.fetch_instances.outputs.body }}" + cpu_body: "${{ steps.fetch_cpu.outputs.body }}" + mem_body: "${{ steps.fetch_mem.outputs.body }}" + + # Stash the finding into shared context so create_ai (and anyone else) + # can read it without naming `analyze`. + - id: stash_finding + type: module + plugin_type: set-variable + name: "Stash Finding → variables" + inputs: + finding: "${{ steps.analyze.outputs.finding }}" + config: + path: finding + value: "${{ inputs.finding }}" + + - id: has_finding + type: decider + plugin_type: conditional + name: "Has Finding?" + config: + expression: "variables.finding != null" + + - id: no_finding + type: module + plugin_type: log + name: "No Finding" + config: + level: info + message: "Scope ${{ workflow.inputs.scope_id }}: resources well-utilized" + + - id: build_description + type: module + plugin_type: code-exec + name: "Build AI Description" + config: + code: | + var f = $item.finding; + if (!f) return { description: '', skip: true }; + var lines = ['## Over-provisioned Resources', '', + '- **App**: ' + f.app_name, '- **Scope**: ' + f.scope_name, '']; + for (var i = 0; i < f.analyses.length; i++) { + var a = f.analyses[i]; + var u = a.resource === 'cpu' ? 'm' : 'Mi'; + lines.push('### ' + a.resource.toUpperCase(), + '- Allocated: **' + a.allocated + u + '**, Peak: **' + a.peak + u + '** (' + a.utilization_pct + '%)', + '- Recommend: **' + a.recommended_request + u + '** request, **' + a.recommended_limit + u + '** limit', + '- Savings: **$' + a.savings_usd_month.toFixed(2) + '/mo**', ''); + } + lines.push('**Total: $' + f.value.toFixed(2) + '/mo**'); + return { description: lines.join('\n') }; + inputs: + # Declared input — reads finding from shared context. + finding: "${{ variables.finding }}" + + - id: create_ai + type: module + plugin_type: np-action-item-create + name: "Create Action Item" + config: + categorySlug: cost-optimization + createdBy: "agent:cost-scanner" + # All finding fields come from shared context, not from steps.analyze.* + nrn: "${{ variables.finding.nrn }}" + title: "${{ variables.finding.title }}" + # description is the build_description output — single-hop passthrough. + description: "${{ $item.description }}" + priority: "${{ variables.finding.priority }}" + value: "${{ variables.finding.value }}" + labels: + workflow_type: cost-optimization + resources: "${{ variables.finding.resources }}" + scanner_version: "1.0" + auto_detected: "true" + metadata: "${{ variables.finding.metadata }}" + + # Stash the created action_item_id so `comment` and `done` (and the + # workflow outputs) read from variables instead of reaching into create_ai. + - id: stash_action_item_id + type: module + plugin_type: set-variable + name: "Stash Action Item ID → variables" + inputs: + id: "${{ steps.create_ai.outputs.actionItemId }}" + config: + path: action_item_id + value: "${{ inputs.id }}" + + - id: comment + type: module + plugin_type: np-action-item-add-comment + name: "Add Scan Comment" + config: + author: "agent:cost-scanner" + content: "Automated scan detected this over-provisioning. A remediation plan will be generated." + actionItemId: "${{ variables.action_item_id }}" + + - id: done + type: module + plugin_type: log + name: "Done" + config: + level: info + message: "Scope ${{ workflow.inputs.scope_id }}: created AI ${{ variables.action_item_id }}" + +connections: + - { id: c1, from: start, to: fetch_scope } + - { id: c2, from: fetch_scope, to: stash_scope } + - { id: c3, from: stash_scope, to: is_active } + - { id: c3a, from: is_active, to: log_inactive, source_port: "false" } + - { id: c3b, from: is_active, to: check_duplicates, source_port: "true" } + - { id: c4, from: check_duplicates, to: stash_dup_check } + - { id: c5, from: stash_dup_check, to: has_duplicate } + - { id: c6, from: has_duplicate, to: log_duplicate, source_port: "true" } + - { id: c7, from: has_duplicate, to: fetch_instances, source_port: "false" } + - { id: c8, from: has_duplicate, to: fetch_cpu, source_port: "false" } + - { id: c9, from: has_duplicate, to: fetch_mem, source_port: "false" } + - { id: c10, from: fetch_instances, to: analyze } + - { id: c11, from: fetch_cpu, to: analyze } + - { id: c12, from: fetch_mem, to: analyze } + - { id: c13, from: analyze, to: stash_finding } + - { id: c14, from: stash_finding, to: has_finding } + - { id: c15, from: has_finding, to: build_description, source_port: "true" } + - { id: c16, from: has_finding, to: no_finding, source_port: "false" } + - { id: c17, from: build_description, to: create_ai } + - { id: c18, from: create_ai, to: stash_action_item_id } + - { id: c19, from: stash_action_item_id, to: comment } + - { id: c20, from: comment, to: done } + +# Workflow-level outputs read from shared context — same pattern. +outputs: + action_item_id: "${{ variables.action_item_id }}" + has_finding: "${{ variables.finding != null }}" + finding: "${{ variables.finding }}" + # Echo the input identifiers so the parent's accumulator gets a full + # breadcrumb without having to reach into the sub-execution. + scope_id: "${{ workflow.inputs.scope_id }}" + application_id: "${{ variables.scope.application_id }}" + namespace_id: "${{ variables.scope.namespace_id }}" + scope_name: "${{ variables.scope.name }}" + scope_nrn: "${{ variables.scope.nrn }}" diff --git a/legacy/cost-optimization/on-action-item-event.yaml b/legacy/cost-optimization/on-action-item-event.yaml new file mode 100644 index 0000000..70d18d5 --- /dev/null +++ b/legacy/cost-optimization/on-action-item-event.yaml @@ -0,0 +1,94 @@ +id: on_action_item_event +name: "On Action Item Event" +description: > + Routes action item events to handler workflows. + onCreated → remediation planner, onSuggestionAccepted → apply & deploy, + onSuggestionRejected → log, onCommentAdded/onUpdated/onResolved → log. +path: "/nullplatform/cost-optimization" +semantic_version: 1.0.0 + +secrets: + np_api_token: + key: "np.api.token" + +steps: + - id: trigger + type: trigger + plugin_type: np-action-item-trigger + name: "Action Item Events" + config: + pathPrefix: np-cost-events + mode: start + # NP channel auto-creation: requires both npApiToken AND nrn. Without + # them, the trigger only registers the local webhook URL but no + # channel is created on the NP side, so events never arrive. + npApiToken: "${{ secrets.np_api_token }}" + nrn: "organization=4" + labelFilters: + workflow_type: cost-optimization + + - id: on_created + type: module + plugin_type: sub-workflow + name: "Plan Remediation" + config: + workflowId: remediation_planner + waitForCompletion: true + # Child inputs MUST live under `inputs:` — fields in `config:` are for + # the sub-workflow plugin itself (workflowId/alias/waitForCompletion) + # and are NOT forwarded to the child. Only `inputs:` maps 1:1 to + # `workflow.inputs.*` inside the child. + inputs: + action_item_id: "${{ $item.actionItem.id }}" + + - id: on_suggestion_accepted + type: module + plugin_type: sub-workflow + name: "Apply & Deploy" + config: + workflowId: apply_and_deploy + waitForCompletion: true + inputs: + action_item_id: "${{ $item.actionItem.id }}" + suggestion_id: "${{ $item.suggestion.id }}" + nrn: "${{ $item.actionItem.nrn }}" + + - id: on_suggestion_rejected + type: module + plugin_type: log + name: "Suggestion Rejected" + config: + level: info + message: "User rejected suggestion ${{ $item.suggestion.id }} on ${{ $item.actionItem.id }}" + + - id: on_comment + type: module + plugin_type: log + name: "Comment Added" + config: + level: info + message: "Comment on ${{ $item.actionItem.id }} by ${{ $item.userEmail }}" + + - id: on_updated + type: module + plugin_type: log + name: "Item Updated" + config: + level: info + message: "Action item ${{ $item.actionItem.id }} updated to ${{ $item.actionItem.status }}" + + - id: on_resolved + type: module + plugin_type: log + name: "Item Resolved" + config: + level: info + message: "Action item ${{ $item.actionItem.id }} resolved" + +connections: + - { id: c1, from: trigger, to: on_created, source_port: onCreated } + - { id: c2, from: trigger, to: on_suggestion_accepted, source_port: onSuggestionAccepted } + - { id: c3, from: trigger, to: on_suggestion_rejected, source_port: onSuggestionRejected } + - { id: c4, from: trigger, to: on_comment, source_port: onCommentAdded } + - { id: c5, from: trigger, to: on_updated, source_port: onUpdated } + - { id: c6, from: trigger, to: on_resolved, source_port: onResolved } diff --git a/legacy/cost-optimization/wf1-cost-scanner.yaml b/legacy/cost-optimization/wf1-cost-scanner.yaml new file mode 100644 index 0000000..17a2828 --- /dev/null +++ b/legacy/cost-optimization/wf1-cost-scanner.yaml @@ -0,0 +1,158 @@ +# Cost Action Items — main orchestrator +id: cost_action_items +name: "Cost Action Items" +description: > + Scans all scopes in an application for over-provisioned CPU and memory. + Processes scopes one at a time using split-in-batches to handle large + scope counts. Each scope is analyzed by the analyze-scope sub-workflow + which creates action items for findings. +path: "/nullplatform/cost-optimization" +semantic_version: 1.0.0 + +inputs: + application_id: + type: number + required: true + description: "NP application ID to scan" + np_api_base: + type: string + required: false + +secrets: + np_api_token: + key: "np.api.token" + +# Per-page scope results accumulate here across re-entries via the +# `acc_scopes` step. `summary` reads this variable directly because the +# `fetch_scopes:done` envelope it normally fans into is empty. +variables: + scope_results: + initialValue: [] + +steps: + - id: start + type: trigger + plugin_type: manual + name: "Start Cost Scan" + config: + description: "Starts a cost optimization scan across all active scopes of an application" + inputs: + application_id: + type: number + required: true + description: "NP application ID" + placeholder: "e.g. 1182532716" + example: 1182532716 + np_api_base: + type: string + required: false + description: "NP API base URL" + default: "https://api.nullplatform.com" + placeholder: "https://api.nullplatform.com" + + # Streaming pagination: fetches ONE page per invocation and emits its + # items via the `loop` port. Downstream `analyze` fans out per scope + # within that page; once finished, `track` connects back to + # fetch_scopes:callback (re-entry port) to pull the next page. + # When fetch_scopes detects the last page, it routes via `done` to + # `summary` instead of `loop`. Memory stays bounded to one page. + - id: fetch_scopes + type: module + plugin_type: np-entity-paginated-fetch + name: "List Active Scopes" + config: + apiBaseUrl: "${{ workflow.inputs.np_api_base || 'https://api.nullplatform.com' }}" + apiToken: "${{ secrets.np_api_token }}" + entity: scope + filters: + application: "${{ workflow.inputs.application_id }}" + status: active + limit: 100 + mode: stream + + - id: analyze + type: module + plugin_type: sub-workflow + name: "Analyze Scope" + config: + workflowId: analyze_scope + waitForCompletion: true + inputs: + scope_id: "${{ $item.id }}" + application_id: "${{ $item.application_id }}" + scope_name: "${{ $item.name }}" + scope_nrn: "${{ $item.nrn }}" + + # Append this page's per-scope analyze_scope outputs to the workflow + # accumulator. set-variable runs once per loop iteration with the full + # batch in `$items`. flipPorts keeps the visual back-edge tidy. + - id: acc_scopes + type: module + plugin_type: set-variable + name: "Accumulate Scope Results" + metadata: + flipPorts: true + config: + path: scope_results + value: "${{ concat(variables.scope_results, $items) }}" + + - id: summary + type: module + plugin_type: code-exec + name: "Build Summary" + inputs: + results: "${{ variables.scope_results }}" + config: + code: | + var items = inputs.results || []; + var findings = []; + for (var i = 0; i < items.length; i++) { + var r = items[i] || {}; + if (!r.action_item_id) continue; + var f = r.finding || {}; + findings.push({ + namespace_id: r.namespace_id != null ? r.namespace_id : null, + application_id: r.application_id != null ? r.application_id : null, + scope_id: r.scope_id != null ? r.scope_id : null, + scope_name: r.scope_name || null, + scope_nrn: r.scope_nrn || f.nrn || null, + action_item_id: r.action_item_id, + title: f.title || null, + value: f.value != null ? f.value : null, + priority: f.priority || null, + resources: f.resources || null, + }); + } + return { + total_scopes_scanned: items.length, + action_items_created: findings.length, + action_item_ids: findings.map(function(f){ return f.action_item_id; }), + findings: findings + }; + + - id: log_done + type: module + plugin_type: log + name: "Scan Complete" + config: + level: info + message: "Cost scan complete. Scanned ${{ steps.summary.outputs.total_scopes_scanned }} scopes, created ${{ steps.summary.outputs.action_items_created }} action items." + +connections: + - { id: c1, from: start, to: fetch_scopes } + # fetch_scopes streams: `loop` port → analyze (per-page items fan out). + - { id: c2, from: fetch_scopes, to: analyze, source_port: loop } + - { id: c3, from: analyze, to: acc_scopes } + # `acc_scopes` loops back into fetch_scopes:callback (reentry port) to + # pull the next page. The engine resets fetch_scopes' join state on + # each reentry-edge settle so it can re-fire. + - { id: c4, from: acc_scopes, to: fetch_scopes, target_port: callback } + # When fetch_scopes detects the last page, it activates `done`. + - { id: c5, from: fetch_scopes, to: summary, source_port: done } + - { id: c6, from: summary, to: log_done } + +outputs: + total_scopes: "${{ steps.summary.outputs.total_scopes_scanned }}" + action_items_created: "${{ steps.summary.outputs.action_items_created }}" + action_item_ids: "${{ steps.summary.outputs.action_item_ids }}" + findings: "${{ steps.summary.outputs.findings }}" diff --git a/legacy/cost-optimization/wf1b-cost-scanner-namespace.yaml b/legacy/cost-optimization/wf1b-cost-scanner-namespace.yaml new file mode 100644 index 0000000..1251e8e --- /dev/null +++ b/legacy/cost-optimization/wf1b-cost-scanner-namespace.yaml @@ -0,0 +1,160 @@ +# Cost Action Items — NAMESPACE scope +# +# Scans all active applications of a namespace. Per app, delegates to +# the `cost_action_items` sub-workflow (which itself streams scopes). +# Three-level delegation chain: +# organization → account → namespace → application → scope +# wf6 wf5 wf4 (this) → wf1 → analyze_scope + +id: cost_action_items_for_namespace +name: "Cost Action Items (namespace)" +description: > + Scans every active application within a single namespace for + over-provisioned CPU and memory. Fetches apps page-by-page (streaming) + and delegates the per-application work to `cost_action_items`. +path: "/nullplatform/cost-optimization" +semantic_version: 1.0.0 + +inputs: + namespace_id: + type: number + required: true + description: "NP namespace ID to scan" + np_api_base: + type: string + required: false + +secrets: + np_api_token: + key: "np.api.token" + +# Per-page results accumulate here across re-entries via the `acc_apps` +# step. The `summary` node reads this variable directly because the +# `fetch_applications:done` envelope it normally fans into is empty. +variables: + app_results: + initialValue: [] + +steps: + - id: start + type: trigger + plugin_type: manual + name: "Start Namespace Scan" + config: + description: "Run cost-optimization scan across every application in a namespace" + inputs: + namespace_id: + type: number + required: true + description: "NP namespace ID" + placeholder: "e.g. 55381482" + np_api_base: + type: string + required: false + default: "https://api.nullplatform.com" + + # Streaming page-by-page: loop until `done` port fires. + - id: fetch_applications + type: module + plugin_type: np-entity-paginated-fetch + name: "List Applications" + config: + apiBaseUrl: "${{ workflow.inputs.np_api_base || 'https://api.nullplatform.com' }}" + apiToken: "${{ secrets.np_api_token }}" + entity: application + filters: + namespace_id: "${{ workflow.inputs.namespace_id }}" + status: active + limit: 100 + mode: stream + + - id: scan_application + type: module + plugin_type: sub-workflow + name: "Scan Application" + config: + workflowId: cost_action_items + waitForCompletion: true + inputs: + application_id: "${{ $item.id }}" + + # Append this page's per-app scan outputs to the workflow-level + # accumulator. set-variable runs once per loop iteration with the full + # batch in `$items`, so `concat(variables.app_results, $items)` grows + # the list page-by-page. `flipPorts` keeps the visual back-edge tidy. + - id: acc_apps + type: module + plugin_type: set-variable + name: "Accumulate App Results" + metadata: + flipPorts: true + config: + path: app_results + value: "${{ concat(variables.app_results, $items) }}" + + # Pulls the accumulated list out of variables (the `done` envelope from + # `fetch_applications` is empty by design) and rolls it into the + # workflow-level totals. + - id: summary + type: module + plugin_type: code-exec + name: "Namespace Summary" + inputs: + results: "${{ variables.app_results }}" + namespace_id: "${{ workflow.inputs.namespace_id }}" + config: + code: | + var items = inputs.results || []; + var totalAIs = 0; + var allIds = []; + var allFindings = []; + for (var i = 0; i < items.length; i++) { + var r = items[i] || {}; + totalAIs += r.action_items_created || 0; + var ids = r.action_item_ids || []; + for (var j = 0; j < ids.length; j++) allIds.push(ids[j]); + var fs = r.findings || []; + for (var k = 0; k < fs.length; k++) { + var f = fs[k] || {}; + allFindings.push({ + namespace_id: f.namespace_id != null ? f.namespace_id : inputs.namespace_id, + application_id: f.application_id != null ? f.application_id : null, + scope_id: f.scope_id || null, + scope_name: f.scope_name || null, + scope_nrn: f.scope_nrn || null, + action_item_id: f.action_item_id || null, + title: f.title || null, + value: f.value != null ? f.value : null, + priority: f.priority || null, + resources: f.resources || null, + }); + } + } + return { + apps_scanned: items.length, + action_items_created: totalAIs, + action_item_ids: allIds, + findings: allFindings, + }; + + - id: log_done + type: module + plugin_type: log + name: "Scan Complete" + config: + level: info + message: "Namespace scan complete. Apps: ${{ steps.summary.outputs.apps_scanned }}, AIs: ${{ steps.summary.outputs.action_items_created }}." + +connections: + - { id: c1, from: start, to: fetch_applications } + - { id: c2, from: fetch_applications, to: scan_application, source_port: loop } + - { id: c3, from: scan_application, to: acc_apps } + - { id: c4, from: acc_apps, to: fetch_applications, target_port: callback } + - { id: c5, from: fetch_applications, to: summary, source_port: done } + - { id: c6, from: summary, to: log_done } + +outputs: + apps_scanned: "${{ steps.summary.outputs.apps_scanned }}" + action_items_created: "${{ steps.summary.outputs.action_items_created }}" + action_item_ids: "${{ steps.summary.outputs.action_item_ids }}" + findings: "${{ steps.summary.outputs.findings }}" diff --git a/legacy/cost-optimization/wf1c-cost-scanner-account.yaml b/legacy/cost-optimization/wf1c-cost-scanner-account.yaml new file mode 100644 index 0000000..909cc15 --- /dev/null +++ b/legacy/cost-optimization/wf1c-cost-scanner-account.yaml @@ -0,0 +1,132 @@ +# Cost Action Items — ACCOUNT scope +# +# Scans every namespace of an account. Per namespace, delegates to +# `cost_action_items_for_namespace`. Same streaming pattern. + +id: cost_action_items_for_account +name: "Cost Action Items (account)" +description: > + Scans every namespace within a single account for cost-optimization + findings. Fetches namespaces page-by-page and delegates per-namespace + work to `cost_action_items_for_namespace`. +path: "/nullplatform/cost-optimization" +semantic_version: 1.0.0 + +inputs: + account_id: + type: number + required: true + description: "NP account ID to scan" + np_api_base: + type: string + required: false + +secrets: + np_api_token: + key: "np.api.token" + +variables: + ns_results: + initialValue: [] + +steps: + - id: start + type: trigger + plugin_type: manual + name: "Start Account Scan" + config: + description: "Run cost-optimization scan across every namespace in an account" + inputs: + account_id: + type: number + required: true + description: "NP account ID" + placeholder: "e.g. 17" + np_api_base: + type: string + required: false + default: "https://api.nullplatform.com" + + - id: fetch_namespaces + type: module + plugin_type: np-entity-paginated-fetch + name: "List Namespaces" + config: + apiBaseUrl: "${{ workflow.inputs.np_api_base || 'https://api.nullplatform.com' }}" + apiToken: "${{ secrets.np_api_token }}" + entity: namespace + filters: + account_id: "${{ workflow.inputs.account_id }}" + limit: 100 + mode: stream + + - id: scan_namespace + type: module + plugin_type: sub-workflow + name: "Scan Namespace" + config: + workflowId: cost_action_items_for_namespace + waitForCompletion: true + inputs: + namespace_id: "${{ $item.id }}" + + - id: acc_namespaces + type: module + plugin_type: set-variable + name: "Accumulate Namespace Results" + metadata: + flipPorts: true + config: + path: ns_results + value: "${{ concat(variables.ns_results, $items) }}" + + - id: summary + type: module + plugin_type: code-exec + name: "Account Summary" + inputs: + results: "${{ variables.ns_results }}" + config: + code: | + var items = inputs.results || []; + var totalApps = 0, totalAIs = 0, allIds = []; + var allFindings = []; + for (var i = 0; i < items.length; i++) { + var r = items[i] || {}; + totalApps += r.apps_scanned || 0; + totalAIs += r.action_items_created || 0; + var ids = r.action_item_ids || []; + for (var j = 0; j < ids.length; j++) allIds.push(ids[j]); + var fs = r.findings || []; + for (var k = 0; k < fs.length; k++) allFindings.push(fs[k]); + } + return { + namespaces_scanned: items.length, + apps_scanned: totalApps, + action_items_created: totalAIs, + action_item_ids: allIds, + findings: allFindings, + }; + + - id: log_done + type: module + plugin_type: log + name: "Scan Complete" + config: + level: info + message: "Account scan done. Namespaces: ${{ steps.summary.outputs.namespaces_scanned }}, Apps: ${{ steps.summary.outputs.apps_scanned }}, AIs: ${{ steps.summary.outputs.action_items_created }}." + +connections: + - { id: c1, from: start, to: fetch_namespaces } + - { id: c2, from: fetch_namespaces, to: scan_namespace, source_port: loop } + - { id: c3, from: scan_namespace, to: acc_namespaces } + - { id: c4, from: acc_namespaces, to: fetch_namespaces, target_port: callback } + - { id: c5, from: fetch_namespaces, to: summary, source_port: done } + - { id: c6, from: summary, to: log_done } + +outputs: + namespaces_scanned: "${{ steps.summary.outputs.namespaces_scanned }}" + apps_scanned: "${{ steps.summary.outputs.apps_scanned }}" + action_items_created: "${{ steps.summary.outputs.action_items_created }}" + action_item_ids: "${{ steps.summary.outputs.action_item_ids }}" + findings: "${{ steps.summary.outputs.findings }}" diff --git a/legacy/cost-optimization/wf1d-cost-scanner-organization.yaml b/legacy/cost-optimization/wf1d-cost-scanner-organization.yaml new file mode 100644 index 0000000..3fac62f --- /dev/null +++ b/legacy/cost-optimization/wf1d-cost-scanner-organization.yaml @@ -0,0 +1,137 @@ +# Cost Action Items — ORGANIZATION scope +# +# Top of the delegation chain: scans every account in an organization. +# Per account, delegates to `cost_action_items_for_account`. + +id: cost_action_items_for_organization +name: "Cost Action Items (organization)" +description: > + Org-wide cost-optimization scan. Fetches every account in the + organization and delegates per-account work to + `cost_action_items_for_account`. Together the four layers (org → + account → namespace → application → scope) share exactly one place + for the actual analysis logic: `analyze_scope`. +path: "/nullplatform/cost-optimization" +semantic_version: 1.0.0 + +inputs: + organization_id: + type: number + required: true + description: "NP organization ID to scan" + np_api_base: + type: string + required: false + +secrets: + np_api_token: + key: "np.api.token" + +variables: + acct_results: + initialValue: [] + +steps: + - id: start + type: trigger + plugin_type: manual + name: "Start Org Scan" + config: + description: "Run cost-optimization scan across every account in an organization" + inputs: + organization_id: + type: number + required: true + description: "NP organization ID" + placeholder: "e.g. 4" + np_api_base: + type: string + required: false + default: "https://api.nullplatform.com" + + - id: fetch_accounts + type: module + plugin_type: np-entity-paginated-fetch + name: "List Accounts" + config: + apiBaseUrl: "${{ workflow.inputs.np_api_base || 'https://api.nullplatform.com' }}" + apiToken: "${{ secrets.np_api_token }}" + entity: account + filters: + organization_id: "${{ workflow.inputs.organization_id }}" + limit: 100 + mode: stream + + - id: scan_account + type: module + plugin_type: sub-workflow + name: "Scan Account" + config: + workflowId: cost_action_items_for_account + waitForCompletion: true + inputs: + account_id: "${{ $item.id }}" + + - id: acc_accounts + type: module + plugin_type: set-variable + name: "Accumulate Account Results" + metadata: + flipPorts: true + config: + path: acct_results + value: "${{ concat(variables.acct_results, $items) }}" + + - id: summary + type: module + plugin_type: code-exec + name: "Org Summary" + inputs: + results: "${{ variables.acct_results }}" + config: + code: | + var items = inputs.results || []; + var totalNs = 0, totalApps = 0, totalAIs = 0, allIds = []; + var allFindings = []; + for (var i = 0; i < items.length; i++) { + var r = items[i] || {}; + totalNs += r.namespaces_scanned || 0; + totalApps += r.apps_scanned || 0; + totalAIs += r.action_items_created || 0; + var ids = r.action_item_ids || []; + for (var j = 0; j < ids.length; j++) allIds.push(ids[j]); + var fs = r.findings || []; + for (var k = 0; k < fs.length; k++) allFindings.push(fs[k]); + } + return { + accounts_scanned: items.length, + namespaces_scanned: totalNs, + apps_scanned: totalApps, + action_items_created: totalAIs, + action_item_ids: allIds, + findings: allFindings, + }; + + - id: log_done + type: module + plugin_type: log + name: "Scan Complete" + config: + level: info + message: "Org scan done. Accounts: ${{ steps.summary.outputs.accounts_scanned }}, NS: ${{ steps.summary.outputs.namespaces_scanned }}, Apps: ${{ steps.summary.outputs.apps_scanned }}, AIs: ${{ steps.summary.outputs.action_items_created }}." + +connections: + - { id: c1, from: start, to: fetch_accounts } + - { id: c2, from: fetch_accounts, to: scan_account, source_port: loop } + - { id: c3, from: scan_account, to: acc_accounts } + - { id: c4, from: acc_accounts, to: fetch_accounts, target_port: callback } + - { id: c5, from: fetch_accounts, to: summary, source_port: done } + - { id: c6, from: summary, to: log_done } + +outputs: + accounts_scanned: "${{ steps.summary.outputs.accounts_scanned }}" + namespaces_scanned: "${{ steps.summary.outputs.namespaces_scanned }}" + apps_scanned: "${{ steps.summary.outputs.apps_scanned }}" + action_items_created: "${{ steps.summary.outputs.action_items_created }}" + action_item_ids: "${{ steps.summary.outputs.action_item_ids }}" + findings: "${{ steps.summary.outputs.findings }}" diff --git a/legacy/cost-optimization/wf2-remediation-planner.yaml b/legacy/cost-optimization/wf2-remediation-planner.yaml new file mode 100644 index 0000000..e3819d6 --- /dev/null +++ b/legacy/cost-optimization/wf2-remediation-planner.yaml @@ -0,0 +1,176 @@ +# Remediation Planner — sub-workflow +# +# Invoked by on-action-item-event when an action item with +# workflow_type=cost-optimization is created. +# Can also be run manually by passing action_item_id. +# +# Uses a Claude agent to analyze the action item context and create +# a structured suggestion with a concrete remediation plan. + +id: remediation_planner +name: "Remediation Planner" +description: > + Sub-workflow that analyzes a cost-optimization action item and creates + a structured remediation suggestion. Uses an AI agent to contextualize + the recommendation based on the application type and workload patterns. + Called by on-action-item-event or manually. +path: "/nullplatform/cost-optimization" +semantic_version: 1.0.0 + +inputs: + action_item_id: + type: string + required: true + description: "Action item ID to plan remediation for" + +secrets: + np_api_token: + key: "np.api.token" + +steps: + - id: start + type: trigger + plugin_type: manual + name: "Start Planning" + config: + description: "Creates a remediation plan (suggestion) for a cost-optimization action item" + inputs: + action_item_id: + type: string + required: true + description: "Action item ID" + placeholder: "e.g. AI-001 or THPEK4MRL4oJ" + + - id: fetch_action_item + type: module + plugin_type: np-action-item-get + name: "Get Action Item" + config: + actionItemId: "${{ workflow.inputs.action_item_id }}" + + - id: comment_start + type: module + plugin_type: np-action-item-add-comment + name: "Notify Planning Started" + config: + actionItemId: "${{ workflow.inputs.action_item_id }}" + author: "agent:remediation-planner" + content: "Analyzing this finding and preparing a remediation plan..." + + - id: plan_agent + type: module + plugin_type: claude-code-agent + name: "Create Remediation Plan" + config: + model: claude-opus-4-7 + maxIterations: 5 + systemPrompt: | + You are a cost optimization specialist for nullplatform. + You analyze over-provisioned resources and create remediation plans. + + You will receive an action item with metadata containing: + - analyses[]: each with resource type, current allocation, recommended values + - scope_id, application_id, replicas + + Your job: + 1. Analyze the recommendation in the action item metadata + 2. Consider the application context (replicas, workload type) + 3. Create a concrete remediation plan with specific parameter changes + 4. Be conservative: ensure the recommended values have enough headroom + + For CPU: + - Never recommend less than 100m request (Nullplatform enforces this floor — any value below is rejected with 400) + - Always keep limit >= 2x request for burst handling + - Round to nice values (multiples of 50m or 100m) + + For memory: + - Never recommend less than 128Mi (matching NP's minimum allocation) + - Keep limit >= 1.5x request + - Round to multiples of 32Mi or 64Mi + + Output a structured JSON with the plan. + userPrompt: | + Analyze this action item and create a remediation plan: + + Title: ${{ steps.fetch_action_item.outputs.actionItem.title }} + Metadata: ${{ steps.fetch_action_item.outputs.actionItem.metadata }} + Current status: ${{ steps.fetch_action_item.outputs.actionItem.status }} + Priority: ${{ steps.fetch_action_item.outputs.actionItem.priority }} + + Create a plan with specific CPU and/or memory changes. + outputSchema: + type: object + required: [plan_description, changes, deployment_required, risk_level] + properties: + plan_description: + type: string + description: "Human-readable description of the plan" + changes: + type: array + items: + type: object + required: [parameter, from_value, to_value, unit] + properties: + parameter: + type: string + description: "cpu_request, cpu_limit, memory_request, memory_limit" + from_value: { type: number } + to_value: { type: number } + unit: + type: string + description: "m (millicores) or Mi (mebibytes)" + deployment_required: { type: boolean } + risk_level: + type: string + enum: [low, medium, high] + rollback_plan: { type: string } + estimated_savings_usd_month: { type: number } + tools: [] + + - id: create_suggestion + type: module + plugin_type: np-action-item-suggestion-create + name: "Create Suggestion" + config: + actionItemId: "${{ workflow.inputs.action_item_id }}" + createdBy: "agent:remediation-planner" + owner: "executor:cost-applier" + confidence: 0.85 + description: "${{ steps.plan_agent.outputs.plan_description }}" + metadata: + changes: "${{ steps.plan_agent.outputs.changes }}" + deployment_required: "${{ steps.plan_agent.outputs.deployment_required }}" + risk_level: "${{ steps.plan_agent.outputs.risk_level }}" + rollback_plan: "${{ steps.plan_agent.outputs.rollback_plan }}" + estimated_savings_usd_month: "${{ steps.plan_agent.outputs.estimated_savings_usd_month }}" + scope_id: "${{ steps.fetch_action_item.outputs.actionItem.metadata.scope_id }}" + application_id: "${{ steps.fetch_action_item.outputs.actionItem.metadata.application_id }}" + + - id: comment_plan + type: module + plugin_type: np-action-item-add-comment + name: "Post Plan Summary" + config: + actionItemId: "${{ workflow.inputs.action_item_id }}" + author: "agent:remediation-planner" + content: "Remediation plan created (suggestion ${{ steps.create_suggestion.outputs.suggestionId }}). Review and approve to apply automatically." + + - id: log_done + type: module + plugin_type: log + name: "Done" + config: + level: info + message: "Remediation plan created for AI ${{ workflow.inputs.action_item_id }}, suggestion ${{ steps.create_suggestion.outputs.suggestionId }}" + +connections: + - { id: c1, from: start, to: fetch_action_item } + - { id: c2, from: fetch_action_item, to: comment_start } + - { id: c3, from: comment_start, to: plan_agent } + - { id: c4, from: plan_agent, to: create_suggestion } + - { id: c5, from: create_suggestion, to: comment_plan } + - { id: c6, from: comment_plan, to: log_done } + +outputs: + suggestion_id: "${{ steps.create_suggestion.outputs.suggestionId }}" + plan: "${{ steps.plan_agent.outputs }}" diff --git a/legacy/cost-optimization/wf3-apply-and-deploy.yaml b/legacy/cost-optimization/wf3-apply-and-deploy.yaml new file mode 100644 index 0000000..5f1c07b --- /dev/null +++ b/legacy/cost-optimization/wf3-apply-and-deploy.yaml @@ -0,0 +1,327 @@ +# Cost Optimization — Apply Accepted Suggestion & Deploy +# +# Triggered when a user accepts a cost-optimization suggestion on an +# action item. Reads the approved suggestion, patches the scope's +# resource config (cpu/memory), waits for the resulting deployment to +# finish, then marks the suggestion as applied (or failed on error). +# +# Called as a sub-workflow from `on_action_item_event` via the +# `onSuggestionAccepted` port. + +id: apply_and_deploy +name: "Apply & Deploy (cost optimization)" +description: > + Applies an accepted cost-optimization suggestion: patches the scope's + resource configuration and waits for the deployment to complete. +path: "/nullplatform/cost-optimization" +semantic_version: 1.0.0 + +inputs: + action_item_id: + type: string + required: true + description: "Action item whose suggestion was accepted" + suggestion_id: + type: string + required: true + description: "Accepted suggestion ID" + nrn: + type: string + required: true + description: "NRN of the scope (e.g. organization=4:account=17:...scope=12345)" + np_api_base: + type: string + required: false + +secrets: + np_api_token: + key: "np.api.token" + +variables: + scope_id: { initialValue: null } + scope: { initialValue: null } + suggestion: { initialValue: null } + new_spec: { initialValue: null } + deployment_id: { initialValue: null } + +steps: + - id: start + type: trigger + plugin_type: manual + name: "Start Apply & Deploy" + config: + description: "Apply a cost-optimization suggestion and deploy the change." + inputs: + action_item_id: + type: string + required: true + suggestion_id: + type: string + required: true + nrn: + type: string + required: true + description: "Scope NRN (e.g. organization=4:account=17:...scope=12345)" + + # ── 0. Extract scope_id from NRN ───────────────────────────────────── + - id: extract_scope_id + type: module + plugin_type: code-exec + name: "Extract Scope ID" + inputs: + nrn: "${{ workflow.inputs.nrn }}" + config: + code: | + var nrn = inputs.nrn || ''; + var match = nrn.match(/scope=(\d+)/); + return { scope_id: match ? parseInt(match[1], 10) : null }; + + - id: stash_scope_id + type: module + plugin_type: set-variable + name: "Stash Scope ID" + config: + path: scope_id + value: "${{ steps.extract_scope_id.outputs.scope_id }}" + + # ── 1. Fetch current scope details ──────────────────────────────────── + - id: fetch_scope + type: module + plugin_type: http-request + name: "Fetch Scope" + config: + method: GET + url: "${{ (workflow.inputs.np_api_base || 'https://api.nullplatform.com') }}/scope/${{ variables.scope_id }}" + headers: + Authorization: "Bearer ${{ secrets.np_api_token }}" + + - id: stash_scope + type: module + plugin_type: set-variable + name: "Stash Scope" + config: + path: scope + value: "${{ steps.fetch_scope.outputs.body }}" + + # ── 2. Fetch the accepted suggestion ────────────────────────────────── + - id: fetch_suggestion + type: module + plugin_type: http-request + name: "Fetch Suggestion" + config: + method: GET + url: "${{ (workflow.inputs.np_api_base || 'https://api.nullplatform.com') }}/governance/action_item/${{ workflow.inputs.action_item_id }}/suggestions/${{ workflow.inputs.suggestion_id }}" + headers: + Authorization: "Bearer ${{ secrets.np_api_token }}" + + - id: stash_suggestion + type: module + plugin_type: set-variable + name: "Stash Suggestion" + config: + path: suggestion + value: "${{ steps.fetch_suggestion.outputs.body }}" + + # ── 3. Compute new spec from suggestion ─────────────────────────────── + - id: compute_spec + type: module + plugin_type: code-exec + name: "Compute New Spec" + inputs: + scope: "${{ variables.scope }}" + suggestion: "${{ variables.suggestion }}" + config: + code: | + var scope = inputs.scope || {}; + var suggestion = inputs.suggestion || {}; + var changes = suggestion.changes || suggestion.recommended_changes || {}; + var currentSpec = scope.specification || {}; + var currentResources = currentSpec.resources || {}; + var newResources = {}; + for (var key in currentResources) { + newResources[key] = currentResources[key]; + } + if (changes.cpu != null) newResources.cpu = changes.cpu; + if (changes.memory != null) newResources.memory = changes.memory; + if (changes.resources) { + if (changes.resources.cpu != null) newResources.cpu = changes.resources.cpu; + if (changes.resources.memory != null) newResources.memory = changes.resources.memory; + } + return { + specification: { + resources: newResources, + replicas: changes.replicas != null ? changes.replicas : (currentSpec.replicas || 1) + } + }; + + - id: stash_new_spec + type: module + plugin_type: set-variable + name: "Stash New Spec" + config: + path: new_spec + value: "${{ steps.compute_spec.outputs }}" + + # ── 4. Patch scope with new resources ───────────────────────────────── + - id: patch_scope + type: module + plugin_type: http-request + name: "Patch Scope" + config: + method: PATCH + url: "${{ (workflow.inputs.np_api_base || 'https://api.nullplatform.com') }}/scope/${{ variables.scope_id }}" + headers: + Authorization: "Bearer ${{ secrets.np_api_token }}" + Content-Type: "application/json" + body: "${{ variables.new_spec }}" + + # ── 5. Comment on action item that we're applying ───────────────────── + - id: comment_applying + type: module + plugin_type: np-action-item-add-comment + name: "Comment: Applying" + config: + actionItemId: "${{ workflow.inputs.action_item_id }}" + text: "Applying suggestion ${{ workflow.inputs.suggestion_id }}: patching scope ${{ variables.scope_id }} with new resource spec. Deployment will follow." + + # ── 6. Get the deployment that was triggered ────────────────────────── + # After PATCH, NP creates a deployment automatically. Fetch latest. + - id: get_deployment + type: module + plugin_type: http-request + name: "Get Latest Deployment" + config: + method: GET + url: "${{ (workflow.inputs.np_api_base || 'https://api.nullplatform.com') }}/scope/${{ variables.scope_id }}" + headers: + Authorization: "Bearer ${{ secrets.np_api_token }}" + + - id: extract_deployment_id + type: module + plugin_type: code-exec + name: "Extract Deployment ID" + inputs: + body: "${{ steps.get_deployment.outputs.body }}" + config: + code: | + var body = inputs.body || {}; + var deploymentId = body.active_deployment || body.current_active_deployment || null; + return { deployment_id: deploymentId }; + + - id: stash_deployment_id + type: module + plugin_type: set-variable + name: "Stash Deployment ID" + config: + path: deployment_id + value: "${{ steps.extract_deployment_id.outputs.deployment_id }}" + + # ── 7. Poll deployment until terminal ───────────────────────────────── + - id: wait_deployment + type: module + plugin_type: http-request + name: "Check Deployment Status" + config: + method: GET + url: "${{ (workflow.inputs.np_api_base || 'https://api.nullplatform.com') }}/deployment/${{ variables.deployment_id }}?include_messages=true" + headers: + Authorization: "Bearer ${{ secrets.np_api_token }}" + + - id: check_done + type: decider + plugin_type: conditional + name: "Deployment Done?" + config: + expression: "steps.wait_deployment.outputs.body.status == 'finalized' || steps.wait_deployment.outputs.body.status == 'rolled_back' || steps.wait_deployment.outputs.body.status == 'failed' || steps.wait_deployment.outputs.body.status == 'cancelled'" + + - id: wait_30s + type: module + plugin_type: delay + name: "Wait 30s" + config: + duration: "30s" + + # ── 8. Handle result ────────────────────────────────────────────────── + - id: check_success + type: decider + plugin_type: conditional + name: "Deployment Succeeded?" + config: + expression: "steps.wait_deployment.outputs.body.status == 'finalized'" + + - id: mark_applied + type: module + plugin_type: np-action-item-suggestion-update + name: "Mark Applied" + config: + actionItemId: "${{ workflow.inputs.action_item_id }}" + suggestionId: "${{ workflow.inputs.suggestion_id }}" + action: mark-applied + + - id: comment_success + type: module + plugin_type: np-action-item-add-comment + name: "Comment: Success" + config: + actionItemId: "${{ workflow.inputs.action_item_id }}" + text: "Deployment ${{ variables.deployment_id }} finalized successfully. Suggestion applied." + + - id: mark_failed + type: module + plugin_type: np-action-item-suggestion-update + name: "Mark Failed" + config: + actionItemId: "${{ workflow.inputs.action_item_id }}" + suggestionId: "${{ workflow.inputs.suggestion_id }}" + action: mark-failed + reason: "Deployment ${{ variables.deployment_id }} ended with status: ${{ steps.wait_deployment.outputs.body.status }}" + + - id: comment_failure + type: module + plugin_type: np-action-item-add-comment + name: "Comment: Failed" + config: + actionItemId: "${{ workflow.inputs.action_item_id }}" + text: "Deployment ${{ variables.deployment_id }} failed (status: ${{ steps.wait_deployment.outputs.body.status }}). Suggestion marked as failed." + + - id: log_done + type: module + plugin_type: log + name: "Done" + config: + level: info + message: "Apply & deploy complete for scope ${{ variables.scope_id }}, deployment ${{ variables.deployment_id }}" + +connections: + - { id: c0a, from: start, to: extract_scope_id } + - { id: c0b, from: extract_scope_id, to: stash_scope_id } + - { id: c1, from: stash_scope_id, to: fetch_scope } + - { id: c2, from: fetch_scope, to: stash_scope } + - { id: c3, from: stash_scope, to: fetch_suggestion } + - { id: c4, from: fetch_suggestion, to: stash_suggestion } + - { id: c5, from: stash_suggestion, to: compute_spec } + - { id: c6, from: compute_spec, to: stash_new_spec } + - { id: c7, from: stash_new_spec, to: patch_scope } + - { id: c8, from: patch_scope, to: comment_applying } + - { id: c9, from: comment_applying, to: get_deployment } + - { id: c10, from: get_deployment, to: extract_deployment_id } + - { id: c11, from: extract_deployment_id, to: stash_deployment_id } + - { id: c12, from: stash_deployment_id, to: wait_deployment } + # Poll loop: check → not done → wait 30s → poll again + - { id: c13, from: wait_deployment, to: check_done } + - { id: c14, from: check_done, to: wait_30s, source_port: "false" } + - { id: c15, from: wait_30s, to: wait_deployment } + # Terminal: done → success/failure routing + - { id: c16, from: check_done, to: check_success, source_port: "true" } + - { id: c17, from: check_success, to: mark_applied, source_port: "true" } + - { id: c18, from: mark_applied, to: comment_success } + - { id: c19, from: comment_success, to: log_done } + - { id: c20, from: check_success, to: mark_failed, source_port: "false" } + - { id: c21, from: mark_failed, to: comment_failure } + - { id: c22, from: comment_failure, to: log_done } + +outputs: + deployment_id: "${{ variables.deployment_id }}" + action_item_id: "${{ workflow.inputs.action_item_id }}" + suggestion_id: "${{ workflow.inputs.suggestion_id }}" + scope_id: "${{ variables.scope_id }}"