Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions legacy/cost-optimization/README.md
Original file line number Diff line number Diff line change
@@ -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.
567 changes: 567 additions & 0 deletions legacy/cost-optimization/analyze-scope.yaml

Large diffs are not rendered by default.

94 changes: 94 additions & 0 deletions legacy/cost-optimization/on-action-item-event.yaml
Original file line number Diff line number Diff line change
@@ -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 }
158 changes: 158 additions & 0 deletions legacy/cost-optimization/wf1-cost-scanner.yaml
Original file line number Diff line number Diff line change
@@ -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 }}"
Loading
Loading