cloudcircuit 0.1.0__tar.gz
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.
- cloudcircuit-0.1.0/.github/workflows/ci.yml +55 -0
- cloudcircuit-0.1.0/.github/workflows/publish.yml +91 -0
- cloudcircuit-0.1.0/.gitignore +25 -0
- cloudcircuit-0.1.0/PKG-INFO +163 -0
- cloudcircuit-0.1.0/README.md +138 -0
- cloudcircuit-0.1.0/pyproject.toml +76 -0
- cloudcircuit-0.1.0/src/cloudcircuit/__init__.py +4 -0
- cloudcircuit-0.1.0/src/cloudcircuit/py.typed +0 -0
- cloudcircuit-0.1.0/src/cloudcircuit/safeguards.py +115 -0
- cloudcircuit-0.1.0/tests/conftest.py +9 -0
- cloudcircuit-0.1.0/tests/test_safeguards.py +87 -0
- cloudcircuit-0.1.0/tests/test_version.py +5 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
pull_request:
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
14
|
+
cancel-in-progress: true
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
test:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
timeout-minutes: 15
|
|
20
|
+
strategy:
|
|
21
|
+
fail-fast: false
|
|
22
|
+
matrix:
|
|
23
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
24
|
+
|
|
25
|
+
steps:
|
|
26
|
+
- name: Check out repository
|
|
27
|
+
uses: actions/checkout@v4
|
|
28
|
+
with:
|
|
29
|
+
persist-credentials: false
|
|
30
|
+
|
|
31
|
+
- name: Set up Python
|
|
32
|
+
uses: actions/setup-python@v5
|
|
33
|
+
with:
|
|
34
|
+
python-version: ${{ matrix.python-version }}
|
|
35
|
+
cache: pip
|
|
36
|
+
|
|
37
|
+
- name: Install dependencies
|
|
38
|
+
run: |
|
|
39
|
+
python -m pip install --upgrade pip
|
|
40
|
+
python -m pip install -e .[dev]
|
|
41
|
+
|
|
42
|
+
- name: Lint
|
|
43
|
+
run: python -m ruff check .
|
|
44
|
+
|
|
45
|
+
- name: Type check
|
|
46
|
+
run: python -m mypy src
|
|
47
|
+
|
|
48
|
+
- name: Run tests
|
|
49
|
+
run: python -m pytest
|
|
50
|
+
|
|
51
|
+
- name: Build distribution
|
|
52
|
+
run: python -m build
|
|
53
|
+
|
|
54
|
+
- name: Verify distribution metadata
|
|
55
|
+
run: python -m twine check dist/*
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: publish-${{ github.ref }}
|
|
13
|
+
cancel-in-progress: false
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
build:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
timeout-minutes: 15
|
|
19
|
+
permissions:
|
|
20
|
+
contents: read
|
|
21
|
+
steps:
|
|
22
|
+
- name: Check out repository
|
|
23
|
+
uses: actions/checkout@v4
|
|
24
|
+
with:
|
|
25
|
+
persist-credentials: false
|
|
26
|
+
|
|
27
|
+
- name: Set up Python
|
|
28
|
+
uses: actions/setup-python@v5
|
|
29
|
+
with:
|
|
30
|
+
python-version: "3.11"
|
|
31
|
+
cache: pip
|
|
32
|
+
|
|
33
|
+
- name: Install build tooling
|
|
34
|
+
run: |
|
|
35
|
+
python -m pip install --upgrade pip
|
|
36
|
+
python -m pip install build twine
|
|
37
|
+
|
|
38
|
+
- name: Build distribution
|
|
39
|
+
run: python -m build
|
|
40
|
+
|
|
41
|
+
- name: Verify distribution metadata
|
|
42
|
+
run: python -m twine check dist/*
|
|
43
|
+
|
|
44
|
+
- name: Upload distribution artifacts
|
|
45
|
+
uses: actions/upload-artifact@v4
|
|
46
|
+
with:
|
|
47
|
+
name: python-package-distributions
|
|
48
|
+
path: dist/
|
|
49
|
+
if-no-files-found: error
|
|
50
|
+
|
|
51
|
+
publish-trusted:
|
|
52
|
+
if: ${{ secrets.PYPI_API_TOKEN == '' }}
|
|
53
|
+
needs: build
|
|
54
|
+
runs-on: ubuntu-latest
|
|
55
|
+
timeout-minutes: 15
|
|
56
|
+
environment:
|
|
57
|
+
name: pypi
|
|
58
|
+
url: https://pypi.org/p/cloudcircuit
|
|
59
|
+
permissions:
|
|
60
|
+
id-token: write
|
|
61
|
+
steps:
|
|
62
|
+
- name: Download distribution artifacts
|
|
63
|
+
uses: actions/download-artifact@v4
|
|
64
|
+
with:
|
|
65
|
+
name: python-package-distributions
|
|
66
|
+
path: dist/
|
|
67
|
+
|
|
68
|
+
- name: Publish to PyPI with trusted publishing
|
|
69
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
70
|
+
|
|
71
|
+
publish-token:
|
|
72
|
+
if: ${{ secrets.PYPI_API_TOKEN != '' }}
|
|
73
|
+
needs: build
|
|
74
|
+
runs-on: ubuntu-latest
|
|
75
|
+
timeout-minutes: 15
|
|
76
|
+
environment:
|
|
77
|
+
name: pypi
|
|
78
|
+
url: https://pypi.org/p/cloudcircuit
|
|
79
|
+
permissions:
|
|
80
|
+
contents: read
|
|
81
|
+
steps:
|
|
82
|
+
- name: Download distribution artifacts
|
|
83
|
+
uses: actions/download-artifact@v4
|
|
84
|
+
with:
|
|
85
|
+
name: python-package-distributions
|
|
86
|
+
path: dist/
|
|
87
|
+
|
|
88
|
+
- name: Publish to PyPI with API token
|
|
89
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
90
|
+
with:
|
|
91
|
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Byte-compiled / cache
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
|
|
7
|
+
# Distribution / packaging
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
*.egg-info/
|
|
11
|
+
|
|
12
|
+
# Virtual envs
|
|
13
|
+
.venv/
|
|
14
|
+
venv/
|
|
15
|
+
|
|
16
|
+
# Test/cache
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
|
|
21
|
+
# OS/editor
|
|
22
|
+
.DS_Store
|
|
23
|
+
Thumbs.db
|
|
24
|
+
.vscode/
|
|
25
|
+
.idea/
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cloudcircuit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CloudCircuit Python package.
|
|
5
|
+
Author: CloudCircuit Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: build>=1.2.2; extra == 'dev'
|
|
20
|
+
Requires-Dist: mypy>=1.11.2; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=8.3.3; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff>=0.6.9; extra == 'dev'
|
|
23
|
+
Requires-Dist: twine>=5.1.1; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# cloudcircuit
|
|
27
|
+
|
|
28
|
+
Lightweight, deterministic safeguards you can embed into cloud spend enforcement:
|
|
29
|
+
|
|
30
|
+
- Budget usage checks (`check_budget`)
|
|
31
|
+
- Spend spike detection (`check_anomaly_spike`)
|
|
32
|
+
- Simple circuit-breaker decisioning (`evaluate_circuit_breaker`)
|
|
33
|
+
|
|
34
|
+
This package focuses on the core logic only. You bring your own metrics ingestion (billing exports, CloudWatch,
|
|
35
|
+
BigQuery, etc.) and enforcement (alerts, policy, throttling, kill-switches).
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
From PyPI (once published):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
python -m pip install cloudcircuit
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For local development from this repo:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
cd cloudcircuit
|
|
49
|
+
python -m pip install -e ".[dev]"
|
|
50
|
+
pytest
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
The public APIs currently live in `cloudcircuit.safeguards`:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from cloudcircuit.safeguards import (
|
|
59
|
+
check_anomaly_spike,
|
|
60
|
+
check_budget,
|
|
61
|
+
evaluate_circuit_breaker,
|
|
62
|
+
)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
All functions are deterministic and raise `ValueError` on invalid inputs (negative spend, invalid thresholds, etc.).
|
|
66
|
+
|
|
67
|
+
## Quickstart
|
|
68
|
+
|
|
69
|
+
### 1) Budget guardrail
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from cloudcircuit.safeguards import check_budget
|
|
73
|
+
|
|
74
|
+
result = check_budget(current_spend=920.0, budget_limit=1000.0, warning_ratio=0.9)
|
|
75
|
+
|
|
76
|
+
if result.is_breached:
|
|
77
|
+
# Hard stop: disable non-essential workloads, block expensive operations, etc.
|
|
78
|
+
print("BUDGET BREACHED:", result)
|
|
79
|
+
elif result.is_warning:
|
|
80
|
+
# Soft stop: alert, reduce concurrency, apply stricter policy.
|
|
81
|
+
print("BUDGET WARNING:", result)
|
|
82
|
+
else:
|
|
83
|
+
print("OK:", result)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### 2) Spike detection (latest point vs baseline)
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from cloudcircuit.safeguards import check_anomaly_spike
|
|
90
|
+
|
|
91
|
+
# Example: daily spend totals (or hourly), latest point last.
|
|
92
|
+
series = [120.0, 125.0, 118.0, 260.0]
|
|
93
|
+
|
|
94
|
+
result = check_anomaly_spike(series, spike_multiplier=1.5, min_baseline=0.0)
|
|
95
|
+
if result.is_spike:
|
|
96
|
+
print("SPEND SPIKE:", result.latest_spend, "threshold:", result.threshold)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 3) Circuit-breaker decisioning
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from cloudcircuit.safeguards import evaluate_circuit_breaker
|
|
103
|
+
|
|
104
|
+
decision = evaluate_circuit_breaker(
|
|
105
|
+
consecutive_failures=3,
|
|
106
|
+
failure_threshold=3,
|
|
107
|
+
cooldown_steps_remaining=0,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if not decision.allow_operation:
|
|
111
|
+
print("BREAKER OPEN; retry after steps:", decision.retry_after_steps)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Architecture
|
|
115
|
+
|
|
116
|
+
Current modules:
|
|
117
|
+
|
|
118
|
+
- `src/cloudcircuit/safeguards.py`: core dataclasses and guardrail functions:
|
|
119
|
+
- `BudgetCheckResult`, `check_budget`
|
|
120
|
+
- `AnomalyCheckResult`, `check_anomaly_spike`
|
|
121
|
+
- `CircuitBreakerDecision`, `evaluate_circuit_breaker`
|
|
122
|
+
- `tests/test_safeguards.py`: contract tests for edge cases and input validation.
|
|
123
|
+
|
|
124
|
+
Design goals:
|
|
125
|
+
|
|
126
|
+
- Deterministic, side-effect free logic (easy to unit-test and reason about)
|
|
127
|
+
- Minimal dependencies (safe to embed in CLIs, jobs, and services)
|
|
128
|
+
- Typed, explicit return objects (so callers can log/audit decisions)
|
|
129
|
+
|
|
130
|
+
Non-goals (by design):
|
|
131
|
+
|
|
132
|
+
- Cloud provider integrations (metrics ingestion / policy enforcement)
|
|
133
|
+
- Stateful breaker implementation (persistence, jitter/backoff, distributed locks)
|
|
134
|
+
|
|
135
|
+
## Deployment Guide
|
|
136
|
+
|
|
137
|
+
Typical deployment looks like a thin integration layer around `cloudcircuit`:
|
|
138
|
+
|
|
139
|
+
1. Collect spend and operational signals.
|
|
140
|
+
- Spend totals: billing exports, cost explorer, warehouse tables, or internal chargeback.
|
|
141
|
+
- Failure counters: request error rates, provider API failures, budget retrieval failures.
|
|
142
|
+
2. Evaluate guardrails.
|
|
143
|
+
- `check_budget()` for hard/soft budget thresholds.
|
|
144
|
+
- `check_anomaly_spike()` to catch sudden jumps early.
|
|
145
|
+
- `evaluate_circuit_breaker()` to gate high-cost operations when systems are unstable.
|
|
146
|
+
3. Enforce decisions.
|
|
147
|
+
- Alerting: Slack/email/PagerDuty with `*_Result` fields for audit context.
|
|
148
|
+
- Throttling: reduce concurrency, disable non-critical jobs, or switch to cheaper defaults.
|
|
149
|
+
- Kill-switch: block expensive actions when `is_breached` is true.
|
|
150
|
+
4. Persist state externally when needed.
|
|
151
|
+
- Store the last N spend points, consecutive failure counts, and cooldown counters in your DB/redis.
|
|
152
|
+
- Run the evaluator on a schedule (cron / Airflow / Cloud Scheduler) or inline per request.
|
|
153
|
+
|
|
154
|
+
Recommended patterns:
|
|
155
|
+
|
|
156
|
+
- Treat spend series as monotonic time buckets (hourly/daily) and pass the newest point last.
|
|
157
|
+
- Log the full result objects (they are small and serializable) so you can explain why a safeguard tripped.
|
|
158
|
+
- Prefer "fail closed" for high-risk operations: if your metrics pipeline is down, open the breaker or block
|
|
159
|
+
expensive actions until signals recover.
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT (see `pyproject.toml` for the current license declaration).
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# cloudcircuit
|
|
2
|
+
|
|
3
|
+
Lightweight, deterministic safeguards you can embed into cloud spend enforcement:
|
|
4
|
+
|
|
5
|
+
- Budget usage checks (`check_budget`)
|
|
6
|
+
- Spend spike detection (`check_anomaly_spike`)
|
|
7
|
+
- Simple circuit-breaker decisioning (`evaluate_circuit_breaker`)
|
|
8
|
+
|
|
9
|
+
This package focuses on the core logic only. You bring your own metrics ingestion (billing exports, CloudWatch,
|
|
10
|
+
BigQuery, etc.) and enforcement (alerts, policy, throttling, kill-switches).
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
From PyPI (once published):
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
python -m pip install cloudcircuit
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
For local development from this repo:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cd cloudcircuit
|
|
24
|
+
python -m pip install -e ".[dev]"
|
|
25
|
+
pytest
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
The public APIs currently live in `cloudcircuit.safeguards`:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from cloudcircuit.safeguards import (
|
|
34
|
+
check_anomaly_spike,
|
|
35
|
+
check_budget,
|
|
36
|
+
evaluate_circuit_breaker,
|
|
37
|
+
)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
All functions are deterministic and raise `ValueError` on invalid inputs (negative spend, invalid thresholds, etc.).
|
|
41
|
+
|
|
42
|
+
## Quickstart
|
|
43
|
+
|
|
44
|
+
### 1) Budget guardrail
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from cloudcircuit.safeguards import check_budget
|
|
48
|
+
|
|
49
|
+
result = check_budget(current_spend=920.0, budget_limit=1000.0, warning_ratio=0.9)
|
|
50
|
+
|
|
51
|
+
if result.is_breached:
|
|
52
|
+
# Hard stop: disable non-essential workloads, block expensive operations, etc.
|
|
53
|
+
print("BUDGET BREACHED:", result)
|
|
54
|
+
elif result.is_warning:
|
|
55
|
+
# Soft stop: alert, reduce concurrency, apply stricter policy.
|
|
56
|
+
print("BUDGET WARNING:", result)
|
|
57
|
+
else:
|
|
58
|
+
print("OK:", result)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 2) Spike detection (latest point vs baseline)
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from cloudcircuit.safeguards import check_anomaly_spike
|
|
65
|
+
|
|
66
|
+
# Example: daily spend totals (or hourly), latest point last.
|
|
67
|
+
series = [120.0, 125.0, 118.0, 260.0]
|
|
68
|
+
|
|
69
|
+
result = check_anomaly_spike(series, spike_multiplier=1.5, min_baseline=0.0)
|
|
70
|
+
if result.is_spike:
|
|
71
|
+
print("SPEND SPIKE:", result.latest_spend, "threshold:", result.threshold)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 3) Circuit-breaker decisioning
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from cloudcircuit.safeguards import evaluate_circuit_breaker
|
|
78
|
+
|
|
79
|
+
decision = evaluate_circuit_breaker(
|
|
80
|
+
consecutive_failures=3,
|
|
81
|
+
failure_threshold=3,
|
|
82
|
+
cooldown_steps_remaining=0,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if not decision.allow_operation:
|
|
86
|
+
print("BREAKER OPEN; retry after steps:", decision.retry_after_steps)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Architecture
|
|
90
|
+
|
|
91
|
+
Current modules:
|
|
92
|
+
|
|
93
|
+
- `src/cloudcircuit/safeguards.py`: core dataclasses and guardrail functions:
|
|
94
|
+
- `BudgetCheckResult`, `check_budget`
|
|
95
|
+
- `AnomalyCheckResult`, `check_anomaly_spike`
|
|
96
|
+
- `CircuitBreakerDecision`, `evaluate_circuit_breaker`
|
|
97
|
+
- `tests/test_safeguards.py`: contract tests for edge cases and input validation.
|
|
98
|
+
|
|
99
|
+
Design goals:
|
|
100
|
+
|
|
101
|
+
- Deterministic, side-effect free logic (easy to unit-test and reason about)
|
|
102
|
+
- Minimal dependencies (safe to embed in CLIs, jobs, and services)
|
|
103
|
+
- Typed, explicit return objects (so callers can log/audit decisions)
|
|
104
|
+
|
|
105
|
+
Non-goals (by design):
|
|
106
|
+
|
|
107
|
+
- Cloud provider integrations (metrics ingestion / policy enforcement)
|
|
108
|
+
- Stateful breaker implementation (persistence, jitter/backoff, distributed locks)
|
|
109
|
+
|
|
110
|
+
## Deployment Guide
|
|
111
|
+
|
|
112
|
+
Typical deployment looks like a thin integration layer around `cloudcircuit`:
|
|
113
|
+
|
|
114
|
+
1. Collect spend and operational signals.
|
|
115
|
+
- Spend totals: billing exports, cost explorer, warehouse tables, or internal chargeback.
|
|
116
|
+
- Failure counters: request error rates, provider API failures, budget retrieval failures.
|
|
117
|
+
2. Evaluate guardrails.
|
|
118
|
+
- `check_budget()` for hard/soft budget thresholds.
|
|
119
|
+
- `check_anomaly_spike()` to catch sudden jumps early.
|
|
120
|
+
- `evaluate_circuit_breaker()` to gate high-cost operations when systems are unstable.
|
|
121
|
+
3. Enforce decisions.
|
|
122
|
+
- Alerting: Slack/email/PagerDuty with `*_Result` fields for audit context.
|
|
123
|
+
- Throttling: reduce concurrency, disable non-critical jobs, or switch to cheaper defaults.
|
|
124
|
+
- Kill-switch: block expensive actions when `is_breached` is true.
|
|
125
|
+
4. Persist state externally when needed.
|
|
126
|
+
- Store the last N spend points, consecutive failure counts, and cooldown counters in your DB/redis.
|
|
127
|
+
- Run the evaluator on a schedule (cron / Airflow / Cloud Scheduler) or inline per request.
|
|
128
|
+
|
|
129
|
+
Recommended patterns:
|
|
130
|
+
|
|
131
|
+
- Treat spend series as monotonic time buckets (hourly/daily) and pass the newest point last.
|
|
132
|
+
- Log the full result objects (they are small and serializable) so you can explain why a safeguard tripped.
|
|
133
|
+
- Prefer "fail closed" for high-risk operations: if your metrics pipeline is down, open the breaker or block
|
|
134
|
+
expensive actions until signals recover.
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT (see `pyproject.toml` for the current license declaration).
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.26.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cloudcircuit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CloudCircuit Python package."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "CloudCircuit Contributors" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"build>=1.2.2",
|
|
30
|
+
"mypy>=1.11.2",
|
|
31
|
+
"pytest>=8.3.3",
|
|
32
|
+
"ruff>=0.6.9",
|
|
33
|
+
"twine>=5.1.1",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/cloudcircuit"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
minversion = "8.0"
|
|
41
|
+
addopts = ["-ra", "--strict-config", "--strict-markers"]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
target-version = "py310"
|
|
46
|
+
line-length = 100
|
|
47
|
+
src = ["src", "tests"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
select = ["E", "F", "I", "B", "UP", "N", "ANN", "C4", "SIM"]
|
|
51
|
+
ignore = ["ANN101", "ANN102"]
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint.per-file-ignores]
|
|
54
|
+
"tests/**/*.py" = ["ANN201", "ANN202", "ANN001", "ANN002", "ANN003"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff.format]
|
|
57
|
+
quote-style = "double"
|
|
58
|
+
indent-style = "space"
|
|
59
|
+
line-ending = "lf"
|
|
60
|
+
|
|
61
|
+
[tool.mypy]
|
|
62
|
+
python_version = "3.10"
|
|
63
|
+
packages = ["cloudcircuit"]
|
|
64
|
+
mypy_path = "src"
|
|
65
|
+
warn_return_any = true
|
|
66
|
+
warn_unused_configs = true
|
|
67
|
+
disallow_untyped_defs = true
|
|
68
|
+
disallow_incomplete_defs = true
|
|
69
|
+
check_untyped_defs = true
|
|
70
|
+
no_implicit_optional = true
|
|
71
|
+
strict_equality = true
|
|
72
|
+
show_error_codes = true
|
|
73
|
+
pretty = true
|
|
74
|
+
|
|
75
|
+
[tool.twine]
|
|
76
|
+
repository = "pypi"
|
|
File without changes
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Core cloud spend safeguard logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Sequence
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class BudgetCheckResult:
|
|
11
|
+
current_spend: float
|
|
12
|
+
budget_limit: float
|
|
13
|
+
usage_ratio: float
|
|
14
|
+
remaining_budget: float
|
|
15
|
+
is_warning: bool
|
|
16
|
+
is_breached: bool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class AnomalyCheckResult:
|
|
21
|
+
latest_spend: float
|
|
22
|
+
baseline_mean: float
|
|
23
|
+
threshold: float
|
|
24
|
+
is_spike: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class CircuitBreakerDecision:
|
|
29
|
+
is_open: bool
|
|
30
|
+
allow_operation: bool
|
|
31
|
+
retry_after_steps: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check_budget(
|
|
35
|
+
current_spend: float,
|
|
36
|
+
budget_limit: float,
|
|
37
|
+
warning_ratio: float = 0.9,
|
|
38
|
+
) -> BudgetCheckResult:
|
|
39
|
+
"""Evaluate budget usage against warning and breach thresholds."""
|
|
40
|
+
if budget_limit <= 0:
|
|
41
|
+
raise ValueError("budget_limit must be > 0")
|
|
42
|
+
if not (0 < warning_ratio <= 1):
|
|
43
|
+
raise ValueError("warning_ratio must be in (0, 1]")
|
|
44
|
+
if current_spend < 0:
|
|
45
|
+
raise ValueError("current_spend must be >= 0")
|
|
46
|
+
|
|
47
|
+
usage_ratio = current_spend / budget_limit
|
|
48
|
+
remaining_budget = budget_limit - current_spend
|
|
49
|
+
is_warning = usage_ratio >= warning_ratio
|
|
50
|
+
is_breached = current_spend >= budget_limit
|
|
51
|
+
return BudgetCheckResult(
|
|
52
|
+
current_spend=current_spend,
|
|
53
|
+
budget_limit=budget_limit,
|
|
54
|
+
usage_ratio=usage_ratio,
|
|
55
|
+
remaining_budget=remaining_budget,
|
|
56
|
+
is_warning=is_warning,
|
|
57
|
+
is_breached=is_breached,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_anomaly_spike(
|
|
62
|
+
spend_series: Sequence[float],
|
|
63
|
+
spike_multiplier: float = 1.5,
|
|
64
|
+
min_baseline: float = 0.0,
|
|
65
|
+
) -> AnomalyCheckResult:
|
|
66
|
+
"""
|
|
67
|
+
Detect whether the latest spend point is an anomalous spike.
|
|
68
|
+
|
|
69
|
+
Baseline is computed from all values except the latest.
|
|
70
|
+
"""
|
|
71
|
+
if len(spend_series) < 2:
|
|
72
|
+
raise ValueError("spend_series must contain at least 2 points")
|
|
73
|
+
if spike_multiplier <= 1.0:
|
|
74
|
+
raise ValueError("spike_multiplier must be > 1.0")
|
|
75
|
+
if min_baseline < 0:
|
|
76
|
+
raise ValueError("min_baseline must be >= 0")
|
|
77
|
+
if any(value < 0 for value in spend_series):
|
|
78
|
+
raise ValueError("spend_series values must be >= 0")
|
|
79
|
+
|
|
80
|
+
latest_spend = float(spend_series[-1])
|
|
81
|
+
history = spend_series[:-1]
|
|
82
|
+
baseline_mean = sum(history) / len(history)
|
|
83
|
+
effective_baseline = max(baseline_mean, min_baseline)
|
|
84
|
+
threshold = effective_baseline * spike_multiplier
|
|
85
|
+
is_spike = latest_spend > threshold
|
|
86
|
+
return AnomalyCheckResult(
|
|
87
|
+
latest_spend=latest_spend,
|
|
88
|
+
baseline_mean=baseline_mean,
|
|
89
|
+
threshold=threshold,
|
|
90
|
+
is_spike=is_spike,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def evaluate_circuit_breaker(
|
|
95
|
+
consecutive_failures: int,
|
|
96
|
+
failure_threshold: int = 3,
|
|
97
|
+
cooldown_steps_remaining: int = 0,
|
|
98
|
+
) -> CircuitBreakerDecision:
|
|
99
|
+
"""Return deterministic breaker state from failure and cooldown counters."""
|
|
100
|
+
if consecutive_failures < 0:
|
|
101
|
+
raise ValueError("consecutive_failures must be >= 0")
|
|
102
|
+
if failure_threshold <= 0:
|
|
103
|
+
raise ValueError("failure_threshold must be > 0")
|
|
104
|
+
if cooldown_steps_remaining < 0:
|
|
105
|
+
raise ValueError("cooldown_steps_remaining must be >= 0")
|
|
106
|
+
|
|
107
|
+
threshold_reached = consecutive_failures >= failure_threshold
|
|
108
|
+
is_open = threshold_reached or cooldown_steps_remaining > 0
|
|
109
|
+
allow_operation = not is_open
|
|
110
|
+
retry_after_steps = cooldown_steps_remaining if is_open else 0
|
|
111
|
+
return CircuitBreakerDecision(
|
|
112
|
+
is_open=is_open,
|
|
113
|
+
allow_operation=allow_operation,
|
|
114
|
+
retry_after_steps=retry_after_steps,
|
|
115
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from cloudcircuit.safeguards import (
|
|
4
|
+
check_anomaly_spike,
|
|
5
|
+
check_budget,
|
|
6
|
+
evaluate_circuit_breaker,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_budget_ok_without_warning() -> None:
|
|
11
|
+
result = check_budget(current_spend=40.0, budget_limit=100.0, warning_ratio=0.8)
|
|
12
|
+
assert result.usage_ratio == 0.4
|
|
13
|
+
assert result.remaining_budget == 60.0
|
|
14
|
+
assert result.is_warning is False
|
|
15
|
+
assert result.is_breached is False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_budget_warning_and_breach() -> None:
|
|
19
|
+
result = check_budget(current_spend=100.0, budget_limit=100.0, warning_ratio=0.8)
|
|
20
|
+
assert result.is_warning is True
|
|
21
|
+
assert result.is_breached is True
|
|
22
|
+
assert result.remaining_budget == 0.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.parametrize(
|
|
26
|
+
("current_spend", "budget_limit", "warning_ratio"),
|
|
27
|
+
[(-1.0, 100.0, 0.9), (10.0, 0.0, 0.9), (10.0, 100.0, 0.0)],
|
|
28
|
+
)
|
|
29
|
+
def test_budget_invalid_inputs(
|
|
30
|
+
current_spend: float, budget_limit: float, warning_ratio: float
|
|
31
|
+
) -> None:
|
|
32
|
+
with pytest.raises(ValueError):
|
|
33
|
+
check_budget(current_spend, budget_limit, warning_ratio)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_anomaly_spike_detected() -> None:
|
|
37
|
+
result = check_anomaly_spike([10.0, 10.0, 10.0, 20.0], spike_multiplier=1.5)
|
|
38
|
+
assert result.baseline_mean == 10.0
|
|
39
|
+
assert result.threshold == 15.0
|
|
40
|
+
assert result.is_spike is True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_anomaly_not_spike_with_min_baseline() -> None:
|
|
44
|
+
result = check_anomaly_spike([0.0, 0.0, 0.0, 0.4], spike_multiplier=2.0, min_baseline=0.5)
|
|
45
|
+
assert result.baseline_mean == 0.0
|
|
46
|
+
assert result.threshold == 1.0
|
|
47
|
+
assert result.is_spike is False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_anomaly_invalid_inputs() -> None:
|
|
51
|
+
with pytest.raises(ValueError):
|
|
52
|
+
check_anomaly_spike([1.0], spike_multiplier=1.5)
|
|
53
|
+
with pytest.raises(ValueError):
|
|
54
|
+
check_anomaly_spike([1.0, 2.0], spike_multiplier=1.0)
|
|
55
|
+
with pytest.raises(ValueError):
|
|
56
|
+
check_anomaly_spike([1.0, -1.0], spike_multiplier=2.0)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_circuit_breaker_allows_operation_below_threshold() -> None:
|
|
60
|
+
decision = evaluate_circuit_breaker(consecutive_failures=2, failure_threshold=3)
|
|
61
|
+
assert decision.is_open is False
|
|
62
|
+
assert decision.allow_operation is True
|
|
63
|
+
assert decision.retry_after_steps == 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_circuit_breaker_opens_at_threshold() -> None:
|
|
67
|
+
decision = evaluate_circuit_breaker(consecutive_failures=3, failure_threshold=3)
|
|
68
|
+
assert decision.is_open is True
|
|
69
|
+
assert decision.allow_operation is False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_circuit_breaker_stays_open_during_cooldown() -> None:
|
|
73
|
+
decision = evaluate_circuit_breaker(
|
|
74
|
+
consecutive_failures=0, failure_threshold=3, cooldown_steps_remaining=2
|
|
75
|
+
)
|
|
76
|
+
assert decision.is_open is True
|
|
77
|
+
assert decision.allow_operation is False
|
|
78
|
+
assert decision.retry_after_steps == 2
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_circuit_breaker_invalid_inputs() -> None:
|
|
82
|
+
with pytest.raises(ValueError):
|
|
83
|
+
evaluate_circuit_breaker(consecutive_failures=-1)
|
|
84
|
+
with pytest.raises(ValueError):
|
|
85
|
+
evaluate_circuit_breaker(consecutive_failures=0, failure_threshold=0)
|
|
86
|
+
with pytest.raises(ValueError):
|
|
87
|
+
evaluate_circuit_breaker(consecutive_failures=0, cooldown_steps_remaining=-1)
|