lex-cost-scanner 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c65ca54b3c8ef026aa4fa0d3549159b63b00df80b3abc0f1a0b96b6586e4ac0a
4
+ data.tar.gz: a0b4e81649991adffaf07a5cf1fef039471a91839c8a285e8b1a088b3d98559d
5
+ SHA512:
6
+ metadata.gz: efadba80fb8343108fc6c231e7d7b801feddaba3080dc12f2fd8221ad23a22a9d2fda8abee23fb9c7c061446af607c73070c69a1b331df408f5de28ae7ba079b
7
+ data.tar.gz: e0befc34946b3ea011a04ffd026be4f3206bb781ce824d7c6c415f78fb46bb53ee4b29ffd9b6638ffeeb79a8e59a4fdc18e18bee0734c0bc38a20bab544aafa8
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-03-24
4
+
5
+ ### Added
6
+ - Resource classifier with LLM + rule-based fallback (idle < 5% CPU, oversized 5-20%)
7
+ - Findings store with deduplication by account + resource + finding type
8
+ - Scanner runner: `scan_account`, `scan_all`, `scan_stats`
9
+ - Reporter runner: `generate_report`, `format_slack_blocks`, `post_report`
10
+ - WeeklyScan interval actor (604800s / 1 week)
data/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # lex-cost-scanner
2
+
3
+ Cloud cost optimization scanner for LegionIO. Scans AWS/Azure accounts for idle and oversized resources, classifies findings via LLM (with rule-based fallback), deduplicates across scans, and delivers a weekly Slack Block Kit report.
4
+
5
+ ## Architecture
6
+
7
+ - **WeeklyScan actor** — interval actor fires `scan_all` every 604,800 seconds (1 week)
8
+ - **Scanner runner** — iterates configured accounts, fetches resources, classifies each one
9
+ - **Classifier helper** — uses `legion-llm` if available; falls back to CPU utilization rules
10
+ - **FindingsStore** — thread-safe in-memory store with dedup by `account_id:resource_id:finding_type`
11
+ - **Reporter runner** — generates Slack Block Kit weekly summary and posts via `lex-slack`
12
+
13
+ ## Settings
14
+
15
+ ```yaml
16
+ cost_scanner:
17
+ slack_webhook: "https://hooks.slack.com/services/..."
18
+ accounts:
19
+ - id: "123456789012"
20
+ cloud: "aws"
21
+ - id: "my-gcp-project"
22
+ cloud: "gcp"
23
+ ```
24
+
25
+ ## Classification Rules (rule-based fallback)
26
+
27
+ | CPU Avg | Finding | Severity |
28
+ |---------|---------|----------|
29
+ | < 5% | idle | high |
30
+ | 5–20% | oversized | medium |
31
+ | >= 20% | none | — |
32
+
33
+ Resources costing less than $50/month are skipped (`MIN_MONTHLY_COST`).
34
+
35
+ ## Development
36
+
37
+ ```bash
38
+ bundle install
39
+ bundle exec rspec
40
+ bundle exec rubocop
41
+ ```
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module CostScanner
6
+ module Actors
7
+ class WeeklyScan < Legion::Extensions::Actors::Every
8
+ def time
9
+ 604_800
10
+ end
11
+
12
+ def runner_class
13
+ 'Legion::Extensions::CostScanner::Runners::Scanner'
14
+ end
15
+
16
+ def runner_function
17
+ 'scan_all'
18
+ end
19
+
20
+ def run_now?
21
+ false
22
+ end
23
+
24
+ def check_subtask?
25
+ false
26
+ end
27
+
28
+ def generate_task?
29
+ false
30
+ end
31
+
32
+ def use_runner?
33
+ false
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module CostScanner
6
+ module Helpers
7
+ module Classifier
8
+ extend Constants
9
+
10
+ CLASSIFY_PROMPT = <<~PROMPT
11
+ Analyze this cloud resource and classify it. Reply with ONLY valid JSON:
12
+ {"finding_type":"idle|oversized|unused_reservation|orphaned_storage|rightsizing|none",
13
+ "severity":"critical|high|medium|low|info",
14
+ "estimated_monthly_savings":0.0,
15
+ "recommendation":"one sentence action"}
16
+
17
+ Resource: %<resource_type>s %<resource_id>s
18
+ Monthly cost: $%<monthly_cost>.2f
19
+ Utilization: %<utilization>s
20
+ PROMPT
21
+
22
+ module_function
23
+
24
+ def classify(resource_id:, resource_type:, monthly_cost:, utilization:)
25
+ if llm_available?
26
+ llm_classify(resource_id: resource_id, resource_type: resource_type,
27
+ monthly_cost: monthly_cost, utilization: utilization)
28
+ else
29
+ result = rule_based_classify(utilization: utilization, monthly_cost: monthly_cost)
30
+ result.merge(resource_id: resource_id, resource_type: resource_type, method: :rule_based)
31
+ end
32
+ end
33
+
34
+ def rule_based_classify(utilization:, monthly_cost:)
35
+ cpu = utilization[:cpu_avg] || 100.0
36
+
37
+ if cpu < Constants::IDLE_CPU_THRESHOLD
38
+ { finding_type: :idle, severity: :high,
39
+ estimated_monthly_savings: monthly_cost,
40
+ recommendation: 'Terminate or stop idle resource' }
41
+ elsif cpu < Constants::OVERSIZED_CPU_THRESHOLD
42
+ savings = monthly_cost * 0.4
43
+ { finding_type: :oversized, severity: :medium,
44
+ estimated_monthly_savings: savings,
45
+ recommendation: 'Rightsize to smaller instance type' }
46
+ else
47
+ { finding_type: :none, severity: :info,
48
+ estimated_monthly_savings: 0.0,
49
+ recommendation: 'Resource is adequately utilized' }
50
+ end
51
+ end
52
+
53
+ def llm_classify(resource_id:, resource_type:, monthly_cost:, utilization:)
54
+ prompt = format(CLASSIFY_PROMPT, resource_type: resource_type, resource_id: resource_id,
55
+ monthly_cost: monthly_cost, utilization: utilization.inspect)
56
+ response = Legion::LLM.chat(
57
+ message: prompt,
58
+ caller: { extension: 'lex-cost-scanner', function: 'classify' }
59
+ )
60
+ parsed = Legion::JSON.load(response)
61
+ parsed[:finding_type] = parsed[:finding_type].to_sym
62
+ parsed[:severity] = parsed[:severity].to_sym
63
+ parsed.merge(resource_id: resource_id, resource_type: resource_type, method: :llm)
64
+ rescue StandardError
65
+ result = rule_based_classify(utilization: utilization, monthly_cost: monthly_cost)
66
+ result.merge(resource_id: resource_id, resource_type: resource_type, method: :rule_based_fallback)
67
+ end
68
+
69
+ def llm_available?
70
+ defined?(Legion::LLM) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module CostScanner
6
+ module Helpers
7
+ module Constants
8
+ FINDING_TYPES = %i[idle oversized unused_reservation orphaned_storage rightsizing none].freeze
9
+ SEVERITIES = %i[critical high medium low info].freeze
10
+ IDLE_CPU_THRESHOLD = 5.0
11
+ OVERSIZED_CPU_THRESHOLD = 20.0
12
+ MIN_MONTHLY_COST = 50.0
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module CostScanner
6
+ module Helpers
7
+ module FindingsStore
8
+ @mutex = Mutex.new
9
+ @findings = {}
10
+
11
+ module_function
12
+
13
+ def dedup_key(account_id:, resource_id:, finding_type:, **)
14
+ "#{account_id}:#{resource_id}:#{finding_type}"
15
+ end
16
+
17
+ def record(account_id:, resource_id:, finding_type:, **attrs)
18
+ key = dedup_key(account_id: account_id, resource_id: resource_id, finding_type: finding_type)
19
+ now = Time.now
20
+
21
+ @mutex.synchronize do
22
+ if @findings.key?(key)
23
+ @findings[key][:last_seen] = now
24
+ @findings[key][:scan_count] += 1
25
+ return { new: false, key: key }
26
+ end
27
+
28
+ @findings[key] = {
29
+ account_id: account_id, resource_id: resource_id, finding_type: finding_type,
30
+ first_seen: now, last_seen: now, scan_count: 1, status: :new
31
+ }.merge(attrs)
32
+ { new: true, key: key }
33
+ end
34
+ end
35
+
36
+ def all
37
+ @mutex.synchronize { @findings.values.dup }
38
+ end
39
+
40
+ def new_since(time)
41
+ @mutex.synchronize { @findings.values.select { |f| f[:first_seen] >= time } }
42
+ end
43
+
44
+ def total_savings
45
+ @mutex.synchronize { @findings.values.sum { |f| f[:estimated_monthly_savings] || 0.0 } }
46
+ end
47
+
48
+ def top_by_savings(limit: 10)
49
+ @mutex.synchronize do
50
+ @findings.values
51
+ .sort_by { |f| -(f[:estimated_monthly_savings] || 0.0) }
52
+ .first(limit)
53
+ end
54
+ end
55
+
56
+ def stats
57
+ @mutex.synchronize do
58
+ { total: @findings.size,
59
+ total_savings: @findings.values.sum { |f| f[:estimated_monthly_savings] || 0.0 },
60
+ by_type: @findings.values.group_by { |f| f[:finding_type] }.transform_values(&:size) }
61
+ end
62
+ end
63
+
64
+ def reset!
65
+ @mutex.synchronize { @findings.clear }
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module CostScanner
6
+ module Runners
7
+ module Reporter
8
+ def generate_report(limit: 10)
9
+ top = Helpers::FindingsStore.top_by_savings(limit: limit)
10
+ stats = Helpers::FindingsStore.stats
11
+
12
+ { total_savings: stats[:total_savings],
13
+ findings_count: stats[:total],
14
+ by_type: stats[:by_type],
15
+ top_findings: top,
16
+ generated_at: Time.now }
17
+ end
18
+
19
+ def format_slack_blocks(report:)
20
+ blocks = []
21
+ savings = format('%.2f', report[:total_savings])
22
+
23
+ blocks << { type: 'header', text: { type: 'plain_text', text: 'Weekly Cost Optimization Report' } }
24
+ blocks << { type: 'section', text: { type: 'mrkdwn',
25
+ text: "*Total potential savings:* $#{savings}/month\n" \
26
+ "*Findings:* #{report[:findings_count]}" } }
27
+ blocks << { type: 'divider' }
28
+
29
+ (report[:top_findings] || []).each_with_index do |finding, i|
30
+ blocks << { type: 'section', text: { type: 'mrkdwn',
31
+ text: "*#{i + 1}. #{finding[:resource_id]}* (#{finding[:finding_type]})\n" \
32
+ "Savings: $#{format('%.2f',
33
+ finding[:estimated_monthly_savings] || 0)}/month\n" \
34
+ "#{finding[:recommendation]}" } }
35
+ end
36
+
37
+ blocks
38
+ end
39
+
40
+ def post_report(limit: 10)
41
+ report = generate_report(limit: limit)
42
+ format_slack_blocks(report: report)
43
+
44
+ webhook = report_webhook
45
+ if webhook && defined?(Legion::Extensions::Slack::Client)
46
+ message = "Cost Optimization: $#{format('%.2f', report[:total_savings])}/month in savings identified"
47
+ Legion::Extensions::Slack::Client.new.send_webhook(message: message, webhook: webhook)
48
+ end
49
+
50
+ { success: true, report: report }
51
+ rescue StandardError => e
52
+ { success: false, error: e.message }
53
+ end
54
+
55
+ private
56
+
57
+ def report_webhook
58
+ return nil unless defined?(Legion::Settings)
59
+
60
+ config = Legion::Settings[:cost_scanner] || {}
61
+ config[:slack_webhook]
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module CostScanner
6
+ module Runners
7
+ module Scanner
8
+ def scan_all
9
+ accounts = scanner_config[:accounts] || []
10
+ results = accounts.map { |acct| scan_account(account_id: acct[:id], cloud: acct[:cloud]) }
11
+
12
+ { success: true, accounts_scanned: results.size, results: results,
13
+ total_savings: Helpers::FindingsStore.total_savings }
14
+ end
15
+
16
+ def scan_account(account_id:, cloud: 'aws')
17
+ resources = fetch_resources(account_id: account_id, cloud: cloud)
18
+ findings_count = resources.count do |resource|
19
+ process_resource(account_id: account_id, resource: resource)
20
+ end
21
+
22
+ { success: true, account_id: account_id, scanned: resources.size,
23
+ findings: findings_count }
24
+ rescue StandardError => e
25
+ { success: false, account_id: account_id, error: e.message }
26
+ end
27
+
28
+ def scan_stats
29
+ Helpers::FindingsStore.stats
30
+ end
31
+
32
+ private
33
+
34
+ def scanner_config
35
+ return {} unless defined?(Legion::Settings)
36
+
37
+ Legion::Settings[:cost_scanner] || {}
38
+ end
39
+
40
+ def process_resource(account_id:, resource:)
41
+ return false if (resource[:monthly_cost] || 0) < Helpers::Constants::MIN_MONTHLY_COST
42
+
43
+ classification = Helpers::Classifier.classify(
44
+ resource_id: resource[:resource_id],
45
+ resource_type: resource[:resource_type],
46
+ monthly_cost: resource[:monthly_cost],
47
+ utilization: resource[:utilization] || {}
48
+ )
49
+ return false if classification[:finding_type] == :none
50
+
51
+ result = Helpers::FindingsStore.record(
52
+ account_id: account_id,
53
+ resource_id: resource[:resource_id],
54
+ resource_type: resource[:resource_type],
55
+ finding_type: classification[:finding_type],
56
+ severity: classification[:severity],
57
+ monthly_cost: resource[:monthly_cost],
58
+ estimated_monthly_savings: classification[:estimated_monthly_savings],
59
+ recommendation: classification[:recommendation]
60
+ )
61
+ result[:new]
62
+ end
63
+
64
+ def fetch_resources(_account_id:, _cloud: 'aws')
65
+ return [] unless defined?(Legion::Extensions::Http::Client)
66
+
67
+ []
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module CostScanner
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cost_scanner/version'
4
+ require_relative 'cost_scanner/helpers/constants'
5
+ require_relative 'cost_scanner/helpers/classifier'
6
+ require_relative 'cost_scanner/helpers/findings_store'
7
+ require_relative 'cost_scanner/runners/scanner'
8
+ require_relative 'cost_scanner/runners/reporter'
9
+
10
+ require_relative 'cost_scanner/actors/weekly_scan' if defined?(Legion::Extensions::Actors::Every)
11
+
12
+ module Legion
13
+ module Extensions
14
+ module CostScanner
15
+ extend Legion::Extensions::Core if defined?(Legion::Extensions::Core)
16
+ end
17
+ end
18
+ end
metadata ADDED
@@ -0,0 +1,150 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-cost-scanner
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Matthew Iverson
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: legion-cache
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 1.3.11
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 1.3.11
26
+ - !ruby/object:Gem::Dependency
27
+ name: legion-crypt
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 1.4.9
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 1.4.9
40
+ - !ruby/object:Gem::Dependency
41
+ name: legion-data
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 1.4.17
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 1.4.17
54
+ - !ruby/object:Gem::Dependency
55
+ name: legion-json
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 1.2.1
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 1.2.1
68
+ - !ruby/object:Gem::Dependency
69
+ name: legion-logging
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 1.3.2
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 1.3.2
82
+ - !ruby/object:Gem::Dependency
83
+ name: legion-settings
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 1.3.14
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 1.3.14
96
+ - !ruby/object:Gem::Dependency
97
+ name: legion-transport
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 1.3.9
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: 1.3.9
110
+ description: Scans AWS/Azure accounts for idle resources, classifies findings via
111
+ LLM, delivers weekly Slack reports
112
+ email:
113
+ - matt@legionio.dev
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - CHANGELOG.md
119
+ - README.md
120
+ - lib/legion/extensions/cost_scanner.rb
121
+ - lib/legion/extensions/cost_scanner/actors/weekly_scan.rb
122
+ - lib/legion/extensions/cost_scanner/helpers/classifier.rb
123
+ - lib/legion/extensions/cost_scanner/helpers/constants.rb
124
+ - lib/legion/extensions/cost_scanner/helpers/findings_store.rb
125
+ - lib/legion/extensions/cost_scanner/runners/reporter.rb
126
+ - lib/legion/extensions/cost_scanner/runners/scanner.rb
127
+ - lib/legion/extensions/cost_scanner/version.rb
128
+ homepage: https://github.com/LegionIO/lex-cost-scanner
129
+ licenses:
130
+ - MIT
131
+ metadata:
132
+ rubygems_mfa_required: 'true'
133
+ rdoc_options: []
134
+ require_paths:
135
+ - lib
136
+ required_ruby_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: '3.4'
141
+ required_rubygems_version: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ requirements: []
147
+ rubygems_version: 3.6.9
148
+ specification_version: 4
149
+ summary: Cloud cost optimization scanner for LegionIO
150
+ test_files: []