sofe 0.1.0__py3-none-any.whl
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.
- sofe/__init__.py +2 -0
- sofe/cli.py +121 -0
- sofe/collectors/__init__.py +152 -0
- sofe/engine/__init__.py +118 -0
- sofe/loader/__init__.py +54 -0
- sofe/models/__init__.py +109 -0
- sofe-0.1.0.dist-info/METADATA +374 -0
- sofe-0.1.0.dist-info/RECORD +11 -0
- sofe-0.1.0.dist-info/WHEEL +5 -0
- sofe-0.1.0.dist-info/entry_points.txt +2 -0
- sofe-0.1.0.dist-info/top_level.txt +1 -0
sofe/__init__.py
ADDED
sofe/cli.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""SOFE CLI ā sofe evaluate, sofe validate."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import click
|
|
5
|
+
from .loader import load_policies, validate_policies
|
|
6
|
+
from .engine import evaluate
|
|
7
|
+
from .models import Finding
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
@click.version_option(version="0.1.0")
|
|
12
|
+
def main():
|
|
13
|
+
"""SOFE ā Stairway Open FinOps Engine. Policies as Code for AWS."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@main.command()
|
|
18
|
+
@click.option("--policies", "-p", required=True, help="Path to policies directory or file")
|
|
19
|
+
@click.option("--format", "-f", "fmt", default="table", type=click.Choice(["table", "json", "markdown"]))
|
|
20
|
+
@click.option("--min-severity", default=None, type=click.Choice(["critical", "high", "medium", "low", "info"]))
|
|
21
|
+
@click.option("--fail-on", default=None, type=click.Choice(["critical", "high", "medium", "low"]))
|
|
22
|
+
@click.option("--profile", default=None, help="AWS profile to use")
|
|
23
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be evaluated without calling AWS")
|
|
24
|
+
def evaluate_cmd(policies, fmt, min_severity, fail_on, profile, dry_run):
|
|
25
|
+
"""Evaluate policies against live AWS resources."""
|
|
26
|
+
from .collectors import collect_all
|
|
27
|
+
|
|
28
|
+
click.echo(f"š Loading policies from: {policies}")
|
|
29
|
+
policy_list = load_policies(policies)
|
|
30
|
+
click.echo(f" Found {len(policy_list)} policies")
|
|
31
|
+
|
|
32
|
+
if dry_run:
|
|
33
|
+
click.echo("\nš DRY RUN ā would evaluate:")
|
|
34
|
+
for p in policy_list:
|
|
35
|
+
click.echo(f" ⢠{p.metadata.name} ({p.spec.severity.value}) ā {p.spec.scope.resource_types}")
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
click.echo(f"\nāļø Scanning AWS resources (profile: {profile or 'default'})...")
|
|
39
|
+
resources = collect_all(profile=profile, resource_types=_get_required_types(policy_list))
|
|
40
|
+
click.echo(f" Found {len(resources)} resources")
|
|
41
|
+
|
|
42
|
+
click.echo(f"\nā” Evaluating {len(policy_list)} policies against {len(resources)} resources...")
|
|
43
|
+
findings = evaluate(policy_list, resources)
|
|
44
|
+
|
|
45
|
+
# Filter by severity
|
|
46
|
+
if min_severity:
|
|
47
|
+
severity_order = ["critical", "high", "medium", "low", "info"]
|
|
48
|
+
min_idx = severity_order.index(min_severity)
|
|
49
|
+
findings = [f for f in findings if severity_order.index(f.severity.value) <= min_idx]
|
|
50
|
+
|
|
51
|
+
# Output
|
|
52
|
+
if fmt == "json":
|
|
53
|
+
click.echo(json.dumps([f.model_dump(mode="json") for f in findings], indent=2, default=str))
|
|
54
|
+
elif fmt == "markdown":
|
|
55
|
+
_output_markdown(findings)
|
|
56
|
+
else:
|
|
57
|
+
_output_table(findings)
|
|
58
|
+
|
|
59
|
+
# Exit code
|
|
60
|
+
if fail_on and findings:
|
|
61
|
+
severity_order = ["critical", "high", "medium", "low"]
|
|
62
|
+
fail_idx = severity_order.index(fail_on)
|
|
63
|
+
blocking = [f for f in findings if severity_order.index(f.severity.value) <= fail_idx]
|
|
64
|
+
if blocking:
|
|
65
|
+
raise SystemExit(1)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@main.command()
|
|
69
|
+
@click.option("--policies", "-p", required=True, help="Path to policies directory or file")
|
|
70
|
+
def validate(policies):
|
|
71
|
+
"""Validate policy YAML files without evaluating."""
|
|
72
|
+
results = validate_policies(policies)
|
|
73
|
+
all_valid = True
|
|
74
|
+
for r in results:
|
|
75
|
+
status = "ā
" if r["valid"] else "ā"
|
|
76
|
+
click.echo(f" {status} {r['file']}")
|
|
77
|
+
if r["error"]:
|
|
78
|
+
click.echo(f" {r['error']}")
|
|
79
|
+
all_valid = False
|
|
80
|
+
|
|
81
|
+
click.echo(f"\n{'All valid ā
' if all_valid else 'Some invalid ā'}")
|
|
82
|
+
if not all_valid:
|
|
83
|
+
raise SystemExit(1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _get_required_types(policies) -> list[str]:
|
|
87
|
+
types = set()
|
|
88
|
+
for p in policies:
|
|
89
|
+
types.update(p.spec.scope.resource_types)
|
|
90
|
+
return list(types)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _output_table(findings: list[Finding]):
|
|
94
|
+
if not findings:
|
|
95
|
+
click.echo("\nā
No violations found!")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
click.echo(f"\n{'ā'*80}")
|
|
99
|
+
click.echo(f"{'Severity':<10} {'Policy':<25} {'Resource':<20} {'Message':<25}")
|
|
100
|
+
click.echo(f"{'ā'*80}")
|
|
101
|
+
icons = {"critical": "š“", "high": "š ", "medium": "š”", "low": "šµ", "info": "āŖ"}
|
|
102
|
+
for f in findings:
|
|
103
|
+
icon = icons.get(f.severity.value, "āŖ")
|
|
104
|
+
click.echo(f"{icon} {f.severity.value:<8} {f.policy_name:<25} {f.resource_id:<20} {f.message:<25}")
|
|
105
|
+
|
|
106
|
+
total_savings = sum(f.estimated_savings or 0 for f in findings)
|
|
107
|
+
click.echo(f"{'ā'*80}")
|
|
108
|
+
click.echo(f"Summary: {len(findings)} findings | Potential savings: ${total_savings:.2f}/mo")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _output_markdown(findings: list[Finding]):
|
|
112
|
+
click.echo(f"# SOFE Evaluation Results\n")
|
|
113
|
+
click.echo(f"**Findings:** {len(findings)}\n")
|
|
114
|
+
click.echo("| Severity | Policy | Resource | Message |")
|
|
115
|
+
click.echo("|----------|--------|----------|---------|")
|
|
116
|
+
for f in findings:
|
|
117
|
+
click.echo(f"| {f.severity.value} | {f.policy_name} | {f.resource_id} | {f.message} |")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
main()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""AWS resource collectors for SOFE ā scan resources + fetch metrics."""
|
|
3
|
+
|
|
4
|
+
import boto3
|
|
5
|
+
from ..models import Resource
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def collect_all(profile: str = None, resource_types: list[str] = None, regions: list[str] = None) -> list[Resource]:
|
|
9
|
+
"""Collect resources from AWS using the given profile."""
|
|
10
|
+
session = boto3.Session(profile_name=profile) if profile else boto3.Session()
|
|
11
|
+
account_id = session.client('sts').get_caller_identity()['Account']
|
|
12
|
+
target_regions = regions or [session.region_name or 'us-east-1']
|
|
13
|
+
|
|
14
|
+
resources: list[Resource] = []
|
|
15
|
+
types = resource_types or ['aws.ec2', 'aws.s3', 'aws.lambda', 'aws.rds']
|
|
16
|
+
|
|
17
|
+
for region in target_regions:
|
|
18
|
+
if 'aws.ec2' in types:
|
|
19
|
+
resources.extend(_collect_ec2(session, region, account_id))
|
|
20
|
+
if 'aws.s3' in types:
|
|
21
|
+
resources.extend(_collect_s3(session, region, account_id))
|
|
22
|
+
if 'aws.lambda' in types:
|
|
23
|
+
resources.extend(_collect_lambda(session, region, account_id))
|
|
24
|
+
if 'aws.rds' in types:
|
|
25
|
+
resources.extend(_collect_rds(session, region, account_id))
|
|
26
|
+
|
|
27
|
+
# Fetch metrics for collected resources
|
|
28
|
+
_enrich_metrics(session, resources, target_regions[0])
|
|
29
|
+
|
|
30
|
+
# Add tag-based metrics
|
|
31
|
+
for r in resources:
|
|
32
|
+
for key in ['owner', 'env', 'costCenter', 'Environment', 'Name']:
|
|
33
|
+
r.metrics[f'has_tag:{key}'] = 1.0 if key in r.tags else 0.0
|
|
34
|
+
|
|
35
|
+
return resources
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _collect_ec2(session: boto3.Session, region: str, account_id: str) -> list[Resource]:
|
|
39
|
+
try:
|
|
40
|
+
ec2 = session.client('ec2', region_name=region)
|
|
41
|
+
resp = ec2.describe_instances(Filters=[{'Name': 'instance-state-name', 'Values': ['running']}])
|
|
42
|
+
resources = []
|
|
43
|
+
for res in resp.get('Reservations', []):
|
|
44
|
+
for inst in res.get('Instances', []):
|
|
45
|
+
tags = {t['Key']: t['Value'] for t in inst.get('Tags', [])}
|
|
46
|
+
resources.append(Resource(
|
|
47
|
+
resource_id=inst['InstanceId'],
|
|
48
|
+
resource_type='aws.ec2',
|
|
49
|
+
region=region,
|
|
50
|
+
account_id=account_id,
|
|
51
|
+
tags=tags,
|
|
52
|
+
properties={'instance_type': inst.get('InstanceType'), 'launch_time': str(inst.get('LaunchTime', ''))},
|
|
53
|
+
))
|
|
54
|
+
return resources
|
|
55
|
+
except Exception as e:
|
|
56
|
+
print(f" ā ļø EC2 scan failed in {region}: {e}")
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _collect_s3(session: boto3.Session, region: str, account_id: str) -> list[Resource]:
|
|
61
|
+
try:
|
|
62
|
+
s3 = session.client('s3', region_name=region)
|
|
63
|
+
buckets = s3.list_buckets().get('Buckets', [])
|
|
64
|
+
return [Resource(
|
|
65
|
+
resource_id=b['Name'],
|
|
66
|
+
resource_type='aws.s3',
|
|
67
|
+
region='global',
|
|
68
|
+
account_id=account_id,
|
|
69
|
+
properties={'creation_date': str(b.get('CreationDate', ''))},
|
|
70
|
+
) for b in buckets]
|
|
71
|
+
except Exception as e:
|
|
72
|
+
print(f" ā ļø S3 scan failed: {e}")
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _collect_lambda(session: boto3.Session, region: str, account_id: str) -> list[Resource]:
|
|
77
|
+
try:
|
|
78
|
+
client = session.client('lambda', region_name=region)
|
|
79
|
+
functions = client.list_functions().get('Functions', [])
|
|
80
|
+
return [Resource(
|
|
81
|
+
resource_id=fn['FunctionName'],
|
|
82
|
+
resource_type='aws.lambda',
|
|
83
|
+
region=region,
|
|
84
|
+
account_id=account_id,
|
|
85
|
+
properties={'runtime': fn.get('Runtime'), 'memory': fn.get('MemorySize')},
|
|
86
|
+
) for fn in functions]
|
|
87
|
+
except Exception as e:
|
|
88
|
+
print(f" ā ļø Lambda scan failed in {region}: {e}")
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _collect_rds(session: boto3.Session, region: str, account_id: str) -> list[Resource]:
|
|
93
|
+
try:
|
|
94
|
+
client = session.client('rds', region_name=region)
|
|
95
|
+
instances = client.describe_db_instances().get('DBInstances', [])
|
|
96
|
+
return [Resource(
|
|
97
|
+
resource_id=db['DBInstanceIdentifier'],
|
|
98
|
+
resource_type='aws.rds',
|
|
99
|
+
region=region,
|
|
100
|
+
account_id=account_id,
|
|
101
|
+
properties={'engine': db.get('Engine'), 'class': db.get('DBInstanceClass')},
|
|
102
|
+
) for db in instances]
|
|
103
|
+
except Exception as e:
|
|
104
|
+
print(f" ā ļø RDS scan failed in {region}: {e}")
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _enrich_metrics(session: boto3.Session, resources: list[Resource], region: str):
|
|
109
|
+
"""Fetch CloudWatch metrics for resources (CPU, cost)."""
|
|
110
|
+
from datetime import datetime, timedelta
|
|
111
|
+
try:
|
|
112
|
+
cw = session.client('cloudwatch', region_name=region)
|
|
113
|
+
ce = session.client('ce', region_name='us-east-1')
|
|
114
|
+
now = datetime.utcnow()
|
|
115
|
+
start = now - timedelta(days=30)
|
|
116
|
+
|
|
117
|
+
for r in resources:
|
|
118
|
+
if r.resource_type == 'aws.ec2':
|
|
119
|
+
try:
|
|
120
|
+
resp = cw.get_metric_statistics(
|
|
121
|
+
Namespace='AWS/EC2', MetricName='CPUUtilization',
|
|
122
|
+
Dimensions=[{'Name': 'InstanceId', 'Value': r.resource_id}],
|
|
123
|
+
StartTime=start, EndTime=now, Period=86400 * 30, Statistics=['Average'],
|
|
124
|
+
)
|
|
125
|
+
if resp['Datapoints']:
|
|
126
|
+
r.metrics['avg_cpu_utilization'] = round(resp['Datapoints'][0]['Average'], 2)
|
|
127
|
+
except:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
# Running days
|
|
131
|
+
if 'launch_time' in r.properties and r.properties['launch_time']:
|
|
132
|
+
try:
|
|
133
|
+
from dateutil.parser import parse
|
|
134
|
+
launch = parse(r.properties['launch_time'])
|
|
135
|
+
r.metrics['running_days'] = (now - launch.replace(tzinfo=None)).days
|
|
136
|
+
except:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
# Monthly cost (simplified: total / resource count as estimate)
|
|
140
|
+
try:
|
|
141
|
+
s = (now.replace(day=1) - timedelta(days=1)).replace(day=1).strftime('%Y-%m-%d')
|
|
142
|
+
e = now.replace(day=1).strftime('%Y-%m-%d')
|
|
143
|
+
resp = ce.get_cost_and_usage(TimePeriod={'Start': s, 'End': e}, Granularity='MONTHLY', Metrics=['UnblendedCost'])
|
|
144
|
+
total = float(resp['ResultsByTime'][0]['Total']['UnblendedCost']['Amount'])
|
|
145
|
+
if resources:
|
|
146
|
+
per_resource = total / len(resources)
|
|
147
|
+
for r in resources:
|
|
148
|
+
r.metrics['monthly_cost'] = round(per_resource, 2)
|
|
149
|
+
except:
|
|
150
|
+
pass
|
|
151
|
+
except:
|
|
152
|
+
pass
|
sofe/engine/__init__.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""Evaluation engine ā applies policies against resources."""
|
|
3
|
+
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from ..models import Policy, Resource, Finding, Operator, Severity
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def evaluate(policies: list[Policy], resources: list[Resource]) -> list[Finding]:
|
|
10
|
+
"""Evaluate all policies against all resources. Returns findings (violations)."""
|
|
11
|
+
findings: list[Finding] = []
|
|
12
|
+
|
|
13
|
+
for policy in policies:
|
|
14
|
+
matching = _filter_by_scope(resources, policy)
|
|
15
|
+
for resource in matching:
|
|
16
|
+
violation = _check_rule(resource, policy)
|
|
17
|
+
if violation:
|
|
18
|
+
findings.append(violation)
|
|
19
|
+
|
|
20
|
+
return findings
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _filter_by_scope(resources: list[Resource], policy: Policy) -> list[Resource]:
|
|
24
|
+
"""Filter resources by policy scope."""
|
|
25
|
+
scope = policy.spec.scope
|
|
26
|
+
result = []
|
|
27
|
+
|
|
28
|
+
for r in resources:
|
|
29
|
+
# Check resource type
|
|
30
|
+
if not _matches_list(r.resource_type, scope.resource_types):
|
|
31
|
+
continue
|
|
32
|
+
# Check region
|
|
33
|
+
if not _matches_list(r.region, scope.regions):
|
|
34
|
+
continue
|
|
35
|
+
# Check account
|
|
36
|
+
if not _matches_list(r.account_id, scope.accounts):
|
|
37
|
+
continue
|
|
38
|
+
# Check environment tag
|
|
39
|
+
if scope.environments != ["*"]:
|
|
40
|
+
env = r.tags.get("Environment", r.tags.get("env", ""))
|
|
41
|
+
if not _matches_list(env, scope.environments):
|
|
42
|
+
continue
|
|
43
|
+
# Check exclude
|
|
44
|
+
if scope.exclude:
|
|
45
|
+
exclude_tags = scope.exclude.get("tags", {})
|
|
46
|
+
if any(r.tags.get(k) == v for k, v in exclude_tags.items()):
|
|
47
|
+
continue
|
|
48
|
+
if r.resource_id in scope.exclude.get("resource_ids", []):
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
result.append(r)
|
|
52
|
+
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _check_rule(resource: Resource, policy: Policy) -> Finding | None:
|
|
57
|
+
"""Check if a resource violates the policy rule."""
|
|
58
|
+
rule = policy.spec.rule
|
|
59
|
+
metric_value = resource.metrics.get(rule.metric)
|
|
60
|
+
|
|
61
|
+
if metric_value is None:
|
|
62
|
+
return None # No data for this metric ā skip
|
|
63
|
+
|
|
64
|
+
# Apply operator
|
|
65
|
+
violated = _apply_operator(metric_value, rule.operator, rule.threshold)
|
|
66
|
+
if not violated:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
# Check additional conditions (AND logic)
|
|
70
|
+
for cond in rule.additional_conditions:
|
|
71
|
+
cond_value = resource.metrics.get(cond.field) or resource.properties.get(cond.field)
|
|
72
|
+
if cond_value is None:
|
|
73
|
+
return None # Missing data ā skip
|
|
74
|
+
if not _apply_operator(float(cond_value), cond.operator, float(cond.value)):
|
|
75
|
+
return None # Condition not met
|
|
76
|
+
|
|
77
|
+
# Build finding
|
|
78
|
+
recommendation = None
|
|
79
|
+
estimated_savings = None
|
|
80
|
+
for action in policy.spec.actions:
|
|
81
|
+
if action.type == "recommend" and action.suggestion:
|
|
82
|
+
recommendation = action.suggestion
|
|
83
|
+
if action.estimated_savings == "calc":
|
|
84
|
+
estimated_savings = resource.metrics.get("monthly_cost")
|
|
85
|
+
|
|
86
|
+
return Finding(
|
|
87
|
+
id=str(uuid.uuid4())[:8],
|
|
88
|
+
policy_name=policy.metadata.name,
|
|
89
|
+
severity=policy.spec.severity,
|
|
90
|
+
resource_id=resource.resource_id,
|
|
91
|
+
resource_type=resource.resource_type,
|
|
92
|
+
region=resource.region,
|
|
93
|
+
account_id=resource.account_id,
|
|
94
|
+
message=f"{rule.metric} = {metric_value} (threshold: {rule.operator.value}{rule.threshold})",
|
|
95
|
+
metric_name=rule.metric,
|
|
96
|
+
metric_value=metric_value,
|
|
97
|
+
threshold=rule.threshold,
|
|
98
|
+
estimated_savings=estimated_savings,
|
|
99
|
+
recommendation=recommendation,
|
|
100
|
+
remediation_eligible=policy.spec.remediation.auto_eligible if policy.spec.remediation else False,
|
|
101
|
+
timestamp=datetime.utcnow(),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _apply_operator(value: float, operator: Operator, threshold: float) -> bool:
|
|
106
|
+
if operator == Operator.lt: return value < threshold
|
|
107
|
+
elif operator == Operator.gt: return value > threshold
|
|
108
|
+
elif operator == Operator.lte: return value <= threshold
|
|
109
|
+
elif operator == Operator.gte: return value >= threshold
|
|
110
|
+
elif operator == Operator.eq: return value == threshold
|
|
111
|
+
elif operator == Operator.ne: return value != threshold
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _matches_list(value: str, allowed: list[str]) -> bool:
|
|
116
|
+
if "*" in allowed:
|
|
117
|
+
return True
|
|
118
|
+
return value in allowed
|
sofe/loader/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Load and validate YAML policy files."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import yaml
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from ..models import Policy
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_policies(path: str) -> list[Policy]:
|
|
10
|
+
"""Load all .yaml policy files from a directory or single file."""
|
|
11
|
+
policies = []
|
|
12
|
+
p = Path(path)
|
|
13
|
+
|
|
14
|
+
if p.is_file():
|
|
15
|
+
policies.append(_load_single(p))
|
|
16
|
+
elif p.is_dir():
|
|
17
|
+
for f in sorted(p.glob("*.yaml")):
|
|
18
|
+
policies.append(_load_single(f))
|
|
19
|
+
for f in sorted(p.glob("*.yml")):
|
|
20
|
+
policies.append(_load_single(f))
|
|
21
|
+
else:
|
|
22
|
+
raise FileNotFoundError(f"Policy path not found: {path}")
|
|
23
|
+
|
|
24
|
+
return policies
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_single(path: Path) -> Policy:
|
|
28
|
+
"""Load and validate a single policy file."""
|
|
29
|
+
with open(path) as f:
|
|
30
|
+
data = yaml.safe_load(f)
|
|
31
|
+
|
|
32
|
+
if not data:
|
|
33
|
+
raise ValueError(f"Empty policy file: {path}")
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
return Policy(**data)
|
|
37
|
+
except Exception as e:
|
|
38
|
+
raise ValueError(f"Invalid policy {path.name}: {e}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_policies(path: str) -> list[dict]:
|
|
42
|
+
"""Validate policies without evaluating. Returns list of {file, valid, error}."""
|
|
43
|
+
results = []
|
|
44
|
+
p = Path(path)
|
|
45
|
+
files = [p] if p.is_file() else list(p.glob("*.yaml")) + list(p.glob("*.yml"))
|
|
46
|
+
|
|
47
|
+
for f in files:
|
|
48
|
+
try:
|
|
49
|
+
_load_single(f)
|
|
50
|
+
results.append({"file": f.name, "valid": True, "error": None})
|
|
51
|
+
except Exception as e:
|
|
52
|
+
results.append({"file": f.name, "valid": False, "error": str(e)})
|
|
53
|
+
|
|
54
|
+
return results
|
sofe/models/__init__.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Core models for SOFE ā Policy, Resource, Finding."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from typing import Optional, Union
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Severity(str, Enum):
|
|
10
|
+
critical = "critical"
|
|
11
|
+
high = "high"
|
|
12
|
+
medium = "medium"
|
|
13
|
+
low = "low"
|
|
14
|
+
info = "info"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Operator(str, Enum):
|
|
18
|
+
lt = "<"
|
|
19
|
+
gt = ">"
|
|
20
|
+
lte = "<="
|
|
21
|
+
gte = ">="
|
|
22
|
+
eq = "=="
|
|
23
|
+
ne = "!="
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AdditionalCondition(BaseModel):
|
|
27
|
+
field: str
|
|
28
|
+
operator: Operator
|
|
29
|
+
value: Union[float, str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Scope(BaseModel):
|
|
33
|
+
environments: list[str] = ["*"]
|
|
34
|
+
resource_types: list[str]
|
|
35
|
+
regions: list[str] = ["*"]
|
|
36
|
+
accounts: list[str] = ["*"]
|
|
37
|
+
exclude: Optional[dict] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Rule(BaseModel):
|
|
41
|
+
metric: str
|
|
42
|
+
period: str = "30d"
|
|
43
|
+
operator: Operator
|
|
44
|
+
threshold: float
|
|
45
|
+
additional_conditions: list[AdditionalCondition] = []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Action(BaseModel):
|
|
49
|
+
type: str # finding, notify, recommend, block
|
|
50
|
+
channel: Optional[str] = None
|
|
51
|
+
suggestion: Optional[str] = None
|
|
52
|
+
estimated_savings: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Remediation(BaseModel):
|
|
56
|
+
auto_eligible: bool = False
|
|
57
|
+
action: Optional[str] = None
|
|
58
|
+
requires_approval_if: Optional[dict] = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PolicyMetadata(BaseModel):
|
|
62
|
+
name: str
|
|
63
|
+
description: str
|
|
64
|
+
author: Optional[str] = None
|
|
65
|
+
created: Optional[str] = None
|
|
66
|
+
tags: list[str] = []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PolicySpec(BaseModel):
|
|
70
|
+
scope: Scope
|
|
71
|
+
rule: Rule
|
|
72
|
+
severity: Severity
|
|
73
|
+
actions: list[Action] = []
|
|
74
|
+
remediation: Optional[Remediation] = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Policy(BaseModel):
|
|
78
|
+
apiVersion: str = "sofe/v1"
|
|
79
|
+
kind: str = "Policy"
|
|
80
|
+
metadata: PolicyMetadata
|
|
81
|
+
spec: PolicySpec
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Resource(BaseModel):
|
|
85
|
+
resource_id: str
|
|
86
|
+
resource_type: str
|
|
87
|
+
region: str
|
|
88
|
+
account_id: str
|
|
89
|
+
tags: dict[str, str] = {}
|
|
90
|
+
properties: dict = {}
|
|
91
|
+
metrics: dict[str, float] = {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Finding(BaseModel):
|
|
95
|
+
id: str
|
|
96
|
+
policy_name: str
|
|
97
|
+
severity: Severity
|
|
98
|
+
resource_id: str
|
|
99
|
+
resource_type: str
|
|
100
|
+
region: str
|
|
101
|
+
account_id: str
|
|
102
|
+
message: str
|
|
103
|
+
metric_name: str
|
|
104
|
+
metric_value: float
|
|
105
|
+
threshold: float
|
|
106
|
+
estimated_savings: Optional[float] = None
|
|
107
|
+
recommendation: Optional[str] = None
|
|
108
|
+
remediation_eligible: bool = False
|
|
109
|
+
timestamp: datetime
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sofe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Stairway Open FinOps Engine ā FinOps Policies as Code for AWS
|
|
5
|
+
Author-email: Carlos Cortez <carlos@cortez.cloud>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/breakingthecloud/sofe
|
|
8
|
+
Project-URL: Repository, https://github.com/breakingthecloud/sofe
|
|
9
|
+
Project-URL: Issues, https://github.com/breakingthecloud/sofe/issues
|
|
10
|
+
Keywords: finops,aws,cloud-governance,policy-as-code,cost-optimization
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: System :: Systems Administration
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: boto3>=1.34
|
|
21
|
+
Requires-Dist: pydantic>=2.0
|
|
22
|
+
Requires-Dist: pyyaml>=6.0
|
|
23
|
+
Requires-Dist: click>=8.0
|
|
24
|
+
Requires-Dist: rich>=13.0
|
|
25
|
+
|
|
26
|
+
# šļø SOFE ā Stairway Open FinOps Engine
|
|
27
|
+
|
|
28
|
+
**FinOps Policies as Code for AWS.**
|
|
29
|
+
|
|
30
|
+
SOFE evaluates declarative YAML policies against live AWS infrastructure and produces actionable findings ā idle resources, missing tags, governance violations, and cost savings opportunities.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
sofe evaluate --policies ./policies/ --profile production
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
38
|
+
Severity Policy Resource Message
|
|
39
|
+
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
40
|
+
š high no-idle-ec2 i-0abc123def avg_cpu = 2.1% (threshold: <5%)
|
|
41
|
+
š” medium require-cost-tags i-0def456ghi missing: costCenter, owner
|
|
42
|
+
š” medium no-unattached-ebs vol-789abc 180 days old, 500GB
|
|
43
|
+
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
44
|
+
Summary: 3 findings | Potential savings: $365.00/mo
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Why SOFE?
|
|
50
|
+
|
|
51
|
+
### The Problem
|
|
52
|
+
|
|
53
|
+
Teams today manage cloud costs **reactively** ā they see the bill spike, panic, then scramble to find what changed. Existing tools either:
|
|
54
|
+
|
|
55
|
+
- **Alert on total spend** (AWS Budgets) ā no root cause, no policy enforcement
|
|
56
|
+
- **Scan for security** (Prowler, ScoutSuite) ā not cost-focused
|
|
57
|
+
- **Estimate costs** (Infracost) ā pre-deploy only, no runtime enforcement
|
|
58
|
+
- **Lock you in** (Sentinel/HCP) ā vendor-specific, not portable
|
|
59
|
+
|
|
60
|
+
**No tool does:** declarative cost+governance policies that evaluate against **live** infrastructure and produce findings with dollar-amount savings.
|
|
61
|
+
|
|
62
|
+
### The Solution
|
|
63
|
+
|
|
64
|
+
SOFE fills this gap:
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
# policies/no-idle-production.yaml
|
|
68
|
+
apiVersion: sofe/v1
|
|
69
|
+
kind: Policy
|
|
70
|
+
metadata:
|
|
71
|
+
name: no-idle-production
|
|
72
|
+
description: "Flag idle EC2 in production (< 5% CPU for 30 days)"
|
|
73
|
+
spec:
|
|
74
|
+
scope:
|
|
75
|
+
environments: [production]
|
|
76
|
+
resource_types: [aws.ec2]
|
|
77
|
+
rule:
|
|
78
|
+
metric: avg_cpu_utilization
|
|
79
|
+
period: 30d
|
|
80
|
+
operator: "<"
|
|
81
|
+
threshold: 5
|
|
82
|
+
severity: high
|
|
83
|
+
actions:
|
|
84
|
+
- type: recommend
|
|
85
|
+
suggestion: "Rightsize or terminate"
|
|
86
|
+
estimated_savings: calc
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Write a policy once. Run it daily. Get findings with savings.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Who Should Use SOFE?
|
|
94
|
+
|
|
95
|
+
| Role | Why SOFE matters |
|
|
96
|
+
|------|-----------------|
|
|
97
|
+
| **Cloud/DevOps Engineers** | Automate governance checks in CI/CD. `sofe evaluate --fail-on high` blocks deploys that violate cost policies. |
|
|
98
|
+
| **FinOps Practitioners** | Define cost optimization rules as code. Track compliance across accounts. Quantify waste. |
|
|
99
|
+
| **Platform Engineers** | Enforce tagging standards, idle resource cleanup, and architecture best practices at scale. |
|
|
100
|
+
| **CTOs / Engineering Managers** | Visibility into cloud waste without manual audits. "We save $X/month because of these policies." |
|
|
101
|
+
| **AWS Partners / Consultants** | Deliver FinOps assessments faster with repeatable, auditable policy evaluations. |
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Why SOFE is Key for FinOps + Governance
|
|
106
|
+
|
|
107
|
+
### 1. FinOps: Cost Optimization as Code
|
|
108
|
+
|
|
109
|
+
Traditional FinOps is manual: someone opens Cost Explorer, finds waste, creates a ticket. SOFE automates this:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
Write policy ā sofe evaluate ā findings with $ savings ā action
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Every policy produces **quantified savings**: "$340/mo if you terminate this idle instance."
|
|
116
|
+
|
|
117
|
+
### 2. Cloud Governance: Policies that Actually Enforce
|
|
118
|
+
|
|
119
|
+
Tags, encryption, public access, budget limits ā every team has rules but no enforcement. SOFE makes them executable:
|
|
120
|
+
|
|
121
|
+
```yaml
|
|
122
|
+
- require-cost-tags ā "All resources must have owner + costCenter"
|
|
123
|
+
- s3-encryption-required ā "All S3 buckets must have encryption enabled"
|
|
124
|
+
- no-public-without-waf ā "No public-facing resource without WAF"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Not just documentation. Actual enforcement in CI/CD.
|
|
128
|
+
|
|
129
|
+
### 3. DevOps: Shift-Left Cost Awareness
|
|
130
|
+
|
|
131
|
+
Add `sofe evaluate --fail-on high` to your GitHub Action or CI pipeline. Developers see cost violations **before** merge, not after the bill arrives.
|
|
132
|
+
|
|
133
|
+
### 4. BYaML Integration: Architecture-Aware FinOps
|
|
134
|
+
|
|
135
|
+
SOFE uses [BYaML](https://byaml.org) component types (`aws.ec2`, `aws.s3`, etc.) ā the same type system used for architecture governance. This means:
|
|
136
|
+
|
|
137
|
+
- Policies reference the same types as your architecture definitions
|
|
138
|
+
- Findings map directly to BYaML components
|
|
139
|
+
- Cost data correlates with architecture versions
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Quick Start
|
|
144
|
+
|
|
145
|
+
### Install
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
pip install sofe
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Write Your First Policy
|
|
152
|
+
|
|
153
|
+
```yaml
|
|
154
|
+
# policies/require-tags.yaml
|
|
155
|
+
apiVersion: sofe/v1
|
|
156
|
+
kind: Policy
|
|
157
|
+
metadata:
|
|
158
|
+
name: require-cost-tags
|
|
159
|
+
description: "All resources must have owner and costCenter tags"
|
|
160
|
+
spec:
|
|
161
|
+
scope:
|
|
162
|
+
resource_types: [aws.ec2, aws.rds, aws.s3]
|
|
163
|
+
rule:
|
|
164
|
+
metric: has_tag:owner
|
|
165
|
+
operator: "=="
|
|
166
|
+
threshold: 0
|
|
167
|
+
severity: medium
|
|
168
|
+
actions:
|
|
169
|
+
- type: finding
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Validate
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
sofe validate --policies ./policies/
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Evaluate
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
# Against real AWS (uses your AWS profile)
|
|
182
|
+
sofe evaluate --policies ./policies/ --profile production
|
|
183
|
+
|
|
184
|
+
# Output as JSON (for automation)
|
|
185
|
+
sofe evaluate --policies ./policies/ --format json > findings.json
|
|
186
|
+
|
|
187
|
+
# CI/CD mode (exit code 1 if high/critical found)
|
|
188
|
+
sofe evaluate --policies ./policies/ --fail-on high
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## How It Works
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
āāāāāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāāāāāāāāāā
|
|
197
|
+
ā Policy Loader ā ā Collectors ā ā Evaluation Engine ā
|
|
198
|
+
ā ā ā ā ā ā
|
|
199
|
+
ā Reads YAML āāāāāā¶ā AWS APIs: āāāāāā¶ā For each policy: ā
|
|
200
|
+
ā Validates ā ā EC2, RDS ā ā match scope ā ā
|
|
201
|
+
ā schema ā ā S3, Lambda ā ā evaluate condition ā ā
|
|
202
|
+
ā ā ā CloudWatch ā ā if violated ā ā
|
|
203
|
+
āāāāāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāā ā generate finding ā
|
|
204
|
+
āāāāāāāāāāāā¬āāāāāāāāāāāā
|
|
205
|
+
ā
|
|
206
|
+
āāāāāāāāāāā¼āāāāāāāāāā
|
|
207
|
+
ā Output ā
|
|
208
|
+
ā ⢠Table (CLI) ā
|
|
209
|
+
ā ⢠JSON (CI/CD) ā
|
|
210
|
+
ā ⢠Markdown (PRs) ā
|
|
211
|
+
āāāāāāāāāāāāāāāāāāāāāā
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Supported Metrics
|
|
217
|
+
|
|
218
|
+
| Metric | Source | Resources |
|
|
219
|
+
|--------|--------|-----------|
|
|
220
|
+
| `avg_cpu_utilization` | CloudWatch (30d avg) | EC2, RDS |
|
|
221
|
+
| `monthly_cost` | Cost Explorer | All |
|
|
222
|
+
| `running_days` | LaunchTime | EC2, RDS |
|
|
223
|
+
| `has_tag:{key}` | Tags API | All |
|
|
224
|
+
| `storage_used_gb` | CloudWatch | S3, EBS |
|
|
225
|
+
| `connections` | CloudWatch | RDS |
|
|
226
|
+
| `invocations` | CloudWatch | Lambda |
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## Pre-Built Policies
|
|
231
|
+
|
|
232
|
+
| Policy | Type | Severity |
|
|
233
|
+
|--------|------|----------|
|
|
234
|
+
| `no-idle-ec2` | Cost Optimization | high |
|
|
235
|
+
| `no-idle-rds` | Cost Optimization | high |
|
|
236
|
+
| `require-cost-tags` | Governance | medium |
|
|
237
|
+
| `no-oversized-staging` | Cost Optimization | high |
|
|
238
|
+
| `s3-lifecycle-required` | Storage | medium |
|
|
239
|
+
| `s3-encryption-required` | Security/Cost | high |
|
|
240
|
+
| `no-unattached-ebs` | Storage | medium |
|
|
241
|
+
| `no-old-snapshots` | Storage | low |
|
|
242
|
+
| `budget-exceeded` | Budget | critical |
|
|
243
|
+
| `no-public-without-waf` | Security/Cost | high |
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## CI/CD Integration
|
|
248
|
+
|
|
249
|
+
### GitHub Actions
|
|
250
|
+
|
|
251
|
+
```yaml
|
|
252
|
+
- name: FinOps Policy Check
|
|
253
|
+
run: |
|
|
254
|
+
pip install sofe
|
|
255
|
+
sofe evaluate --policies ./policies/ --fail-on high --format json > findings.json
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Exit Codes
|
|
259
|
+
|
|
260
|
+
| Code | Meaning |
|
|
261
|
+
|:----:|---------|
|
|
262
|
+
| 0 | No violations (or below `--fail-on` threshold) |
|
|
263
|
+
| 1 | Violations found at or above `--fail-on` severity |
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## Comparison
|
|
268
|
+
|
|
269
|
+
| Tool | Cost Policies | Live Eval | Savings Calc | CI/CD | Open Source |
|
|
270
|
+
|------|:---:|:---:|:---:|:---:|:---:|
|
|
271
|
+
| **SOFE** | ā
| ā
| ā
| ā
| ā
|
|
|
272
|
+
| AWS Budgets | ā (alerts only) | ā | ā | ā | ā |
|
|
273
|
+
| Infracost | š” (pre-deploy) | ā | ā
| ā
| ā
|
|
|
274
|
+
| OPA/Rego | ā
(security) | ā | ā | ā
| ā
|
|
|
275
|
+
| Sentinel | ā
| ā | ā | ā
| ā (HCP only) |
|
|
276
|
+
| Prowler | ā (security only) | ā
| ā | ā
| ā
|
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## Ecosystem: Competitors & Complementary Tools
|
|
281
|
+
|
|
282
|
+
### Competitors (overlap with SOFE)
|
|
283
|
+
|
|
284
|
+
| Tool | Type | What it does | How SOFE differs |
|
|
285
|
+
|------|------|-------------|-----------------|
|
|
286
|
+
| **OPA / Rego** | OSS | General policy engine (security-focused) | SOFE is cost/FinOps-focused with savings calculations. OPA doesn't calculate $. |
|
|
287
|
+
| **HashiCorp Sentinel** | Proprietary | Policy-as-code for Terraform | Locked to HCP/Terraform Cloud. SOFE is runtime (evaluates live infra, not just plans). |
|
|
288
|
+
| **Infracost** | OSS | Cost estimation pre-deploy | Pre-deploy only. SOFE evaluates running infra + historical drift. Complementary. |
|
|
289
|
+
| **AWS Config Rules** | AWS Native | Compliance rules on AWS resources | Limited to AWS, no cost focus, no CI/CD output, no portability. |
|
|
290
|
+
| **Prowler** | OSS | Security & compliance scanning | Security-focused (CIS, PCI-DSS). Doesn't calculate cost savings or enforce FinOps. |
|
|
291
|
+
| **Checkov** | OSS | IaC static analysis (Terraform, CF) | Pre-deploy only (scans .tf files). SOFE scans live resources. |
|
|
292
|
+
| **Cloud Custodian** | OSS | Policy engine for cloud resources | Closest competitor. Actions (stop/terminate) built-in. SOFE is lighter, YAML-first, FinOps-focused. |
|
|
293
|
+
| **Kubecost** | OSS/Paid | Kubernetes cost monitoring | K8s only. SOFE covers all AWS services. |
|
|
294
|
+
| **Vantage** | SaaS | FinOps dashboard + alerts | Dashboard, not policy engine. No CI/CD. No custom rules. |
|
|
295
|
+
| **CloudZero** | SaaS | Cost intelligence platform | Enterprise SaaS ($$$). No self-hosted. No policies-as-code. |
|
|
296
|
+
| **Spot.io / NetApp** | SaaS | Cloud optimization + autoscaling | Optimization execution, not policy definition. Complementary. |
|
|
297
|
+
| **Apptio Cloudability** | SaaS | Enterprise FinOps platform | Enterprise-only, expensive. No CI/CD integration. No code-first approach. |
|
|
298
|
+
| **nOps** | SaaS | AWS cost optimization + scheduling | Automation focus. No declarative policies. |
|
|
299
|
+
| **CAST AI** | SaaS | K8s cost optimization | K8s-only autoscaling. Not a policy engine. |
|
|
300
|
+
|
|
301
|
+
### Complementary Tools (use alongside SOFE)
|
|
302
|
+
|
|
303
|
+
| Tool | How it complements SOFE |
|
|
304
|
+
|------|------------------------|
|
|
305
|
+
| **Infracost** | Pre-deploy cost estimation ā SOFE catches what slipped through post-deploy |
|
|
306
|
+
| **Terraform / OpenTofu** | Defines infra ā SOFE evaluates if running infra matches cost policies |
|
|
307
|
+
| **AWS Cost Explorer** | Data source ā SOFE collectors fetch from it |
|
|
308
|
+
| **CloudWatch** | Metrics source ā SOFE uses CPU, connections, invocations |
|
|
309
|
+
| **Steampipe** | SQL-based cloud inventory ā could be alternate data source for SOFE |
|
|
310
|
+
| **Prometheus + Grafana** | Monitoring ā SOFE could consume Prometheus metrics (future) |
|
|
311
|
+
| **BYaML** | Architecture definitions ā SOFE policies use same type system |
|
|
312
|
+
| **byaml-finops-mcp** | MCP tools for AI assistants ā SOFE findings feed into AI reasoning |
|
|
313
|
+
| **FinOptix** | AI model for FinOps ā explains SOFE findings in natural language |
|
|
314
|
+
| **GitHub Actions / GitLab CI** | CI/CD ā SOFE runs as pipeline step with `--fail-on` |
|
|
315
|
+
| **Slack / PagerDuty** | Notifications ā SOFE can webhook findings (future) |
|
|
316
|
+
| **Neo4j** | Graph DB ā SOFE findings + BYaML relationships = cost propagation graph (future) |
|
|
317
|
+
|
|
318
|
+
### The SOFE Position
|
|
319
|
+
|
|
320
|
+
```
|
|
321
|
+
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
322
|
+
ā Cloud Cost Lifecycle ā
|
|
323
|
+
āāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāā¤
|
|
324
|
+
ā PLAN ā DEPLOY ā RUN ā OPTIMIZE ā
|
|
325
|
+
ā ā ā ā ā
|
|
326
|
+
ā Infracost ā Sentinel ā ā
SOFE ā
ā Spot.io ā
|
|
327
|
+
ā Checkov ā OPA/Rego ā Cloud Custodian ā CAST AI ā
|
|
328
|
+
ā ā Checkov ā AWS Config ā nOps ā
|
|
329
|
+
ā ā ā Prowler ā Kubecost ā
|
|
330
|
+
āāāāāāāāāāāāāāā“āāāāāāāāāāāāāāā“āāāāāāāāāāāāāāāāāāā“āāāāāāāāāāāāāāāāāā¤
|
|
331
|
+
ā VISIBILITY: Vantage, CloudZero, Apptio, AWS Cost Explorer ā
|
|
332
|
+
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
333
|
+
|
|
334
|
+
SOFE lives in the RUN phase: evaluate LIVE infrastructure against
|
|
335
|
+
declarative FinOps policies. Produce findings with dollar savings.
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Why SOFE vs Cloud Custodian?
|
|
339
|
+
|
|
340
|
+
Cloud Custodian is the closest open source alternative. Key differences:
|
|
341
|
+
|
|
342
|
+
| | SOFE | Cloud Custodian |
|
|
343
|
+
|--|------|----------------|
|
|
344
|
+
| **Focus** | FinOps + cost governance | Security + compliance + ops |
|
|
345
|
+
| **Policy format** | Clean YAML (Pydantic-validated) | Complex YAML with filters/actions DSL |
|
|
346
|
+
| **Savings calculation** | Built-in ($ per finding) | Not included |
|
|
347
|
+
| **BYaML integration** | Native (same type system) | None |
|
|
348
|
+
| **AI reasoning** | FinOptix integration (future) | None |
|
|
349
|
+
| **CI/CD** | `--fail-on` exit code | Requires wrapper |
|
|
350
|
+
| **Scope** | AWS first, multi-cloud future | AWS + Azure + GCP |
|
|
351
|
+
| **Maturity** | New (2026) | Mature (2016+, Capital One) |
|
|
352
|
+
|
|
353
|
+
SOFE is opinionated toward **FinOps** ā every finding has a dollar amount. Cloud Custodian is a general-purpose policy engine that happens to work on cloud resources.
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## License
|
|
358
|
+
|
|
359
|
+
Apache 2.0 ā free to use, modify, and distribute.
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## Contributing
|
|
364
|
+
|
|
365
|
+
1. Fork the repo
|
|
366
|
+
2. Add a policy to `policies/` or a collector to `sofe/collectors/`
|
|
367
|
+
3. Submit a PR
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
## Built by
|
|
372
|
+
|
|
373
|
+
[Carlos Cortez](https://cortez.cloud) ā AWS Community Hero, CTO @ BWIT Solutions.
|
|
374
|
+
Part of the [BYaML](https://byaml.org) ecosystem for cloud architecture governance.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
sofe/__init__.py,sha256=lBrL-ij6gZimgN3fPaVJPU2fZNzurfBtMLzYgmMeGbw,92
|
|
2
|
+
sofe/cli.py,sha256=qjeY504zxfS8S8Kudu1TCBZxua9AX3ag_q8BqZHij4Q,4630
|
|
3
|
+
sofe/collectors/__init__.py,sha256=s65VhKot726K7IcWF_n6CkNQ-jMNRHAWINgMtw0F7EU,6355
|
|
4
|
+
sofe/engine/__init__.py,sha256=4l7RrJeIgCHdacNrVtnW3xbqkOi_qGseiUwUXTWQXWw,4258
|
|
5
|
+
sofe/loader/__init__.py,sha256=4k6ygsZ-0HFzcfZ2TJsTdQjpdNhOxM9s6ZgHp2Rgjgw,1503
|
|
6
|
+
sofe/models/__init__.py,sha256=PvfA6BFMyl9bviw8TumZkii4QqtR6ysCMX5ttUFUJM8,2277
|
|
7
|
+
sofe-0.1.0.dist-info/METADATA,sha256=XUWxXLWt67mphgbQp2VooTAN0DCBLvzr8iYFGSa2kZk,16061
|
|
8
|
+
sofe-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
sofe-0.1.0.dist-info/entry_points.txt,sha256=QOQi7OO-5oQUr2mJubvIVKmWusSj2D2SmPkk6iYt35Y,39
|
|
10
|
+
sofe-0.1.0.dist-info/top_level.txt,sha256=GCmO2On-zrWOiECzxOWg-dfI2hbObe0kddQjvuNET94,5
|
|
11
|
+
sofe-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sofe
|