@stacksjs/ts-cloud 0.7.108 → 0.7.109
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.
- package/dist/alerts/delivery.d.ts +10 -0
- package/dist/alerts/model.d.ts +1 -1
- package/dist/api/client.d.ts +53 -0
- package/dist/api/handler.d.ts +10 -0
- package/dist/api/service.d.ts +1 -0
- package/dist/aws/index.js +10 -10
- package/dist/aws/lambda.d.ts +19 -0
- package/dist/bin/cli.js +1677 -1369
- package/dist/bin/dashboard-server.js +927 -621
- package/dist/{chunk-fmt40tpw.js → chunk-53jatzaw.js} +2 -2
- package/dist/{chunk-ef2dwfrg.js → chunk-8kh577qh.js} +5 -5
- package/dist/{chunk-s9c2x5jv.js → chunk-9v2pwxv9.js} +2 -2
- package/dist/chunk-aa2dkn7b.js +205 -0
- package/dist/{chunk-eg8ck25v.js → chunk-cktwk9hy.js} +6935 -15274
- package/dist/{chunk-33sy72r8.js → chunk-d3vx2ekv.js} +71 -25
- package/dist/chunk-grx1rp09.js +12163 -0
- package/dist/{chunk-c6db5m63.js → chunk-t2s3gdq6.js} +253 -8
- package/dist/chunk-va2yd85b.js +200 -0
- package/dist/{chunk-3g4r09dd.js → chunk-wmbep3b5.js} +1 -1
- package/dist/{chunk-ybcz6sxc.js → chunk-x6yzgv7y.js} +33 -0
- package/dist/chunk-xrtwv3v2.js +420 -0
- package/dist/control-plane/types.d.ts +1 -1
- package/dist/deploy/dashboard-protection.test.d.ts +1 -0
- package/dist/deploy/dashboard-spend.test.d.ts +1 -0
- package/dist/deploy/index.js +11 -8
- package/dist/deploy/local-dashboard-server.d.ts +2 -0
- package/dist/drivers/index.js +4 -2
- package/dist/drivers/shared/protection.d.ts +43 -0
- package/dist/drivers/shared/protection.test.d.ts +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +364 -102
- package/dist/protection/controls.d.ts +113 -0
- package/dist/protection/controls.test.d.ts +1 -0
- package/dist/protection/ddos.d.ts +130 -0
- package/dist/protection/ddos.test.d.ts +1 -0
- package/dist/protection/index.d.ts +11 -0
- package/dist/protection/index.js +87 -0
- package/dist/protection/ratelimit.d.ts +154 -0
- package/dist/protection/ratelimit.test.d.ts +1 -0
- package/dist/protection/waf.d.ts +143 -0
- package/dist/protection/waf.test.d.ts +1 -0
- package/dist/spend/anomaly-config.d.ts +130 -0
- package/dist/spend/anomaly-config.test.d.ts +1 -0
- package/dist/spend/anomaly.d.ts +114 -0
- package/dist/spend/anomaly.test.d.ts +1 -0
- package/dist/spend/api.d.ts +42 -0
- package/dist/spend/api.test.d.ts +1 -0
- package/dist/spend/appliers.d.ts +126 -0
- package/dist/spend/docs.test.d.ts +1 -0
- package/dist/spend/enforcement.d.ts +111 -0
- package/dist/spend/enforcement.integration.test.d.ts +1 -0
- package/dist/spend/evaluator.d.ts +70 -0
- package/dist/spend/evaluator.test.d.ts +1 -0
- package/dist/spend/focus.d.ts +93 -0
- package/dist/spend/focus.test.d.ts +1 -0
- package/dist/spend/gate.d.ts +96 -0
- package/dist/spend/index.d.ts +20 -0
- package/dist/spend/index.js +173 -0
- package/dist/spend/lease.d.ts +54 -0
- package/dist/spend/lease.test.d.ts +1 -0
- package/dist/spend/meter.d.ts +70 -0
- package/dist/spend/meter.test.d.ts +1 -0
- package/dist/spend/model.d.ts +228 -0
- package/dist/spend/notifications.d.ts +58 -0
- package/dist/spend/pricing.d.ts +109 -0
- package/dist/spend/pricing.test.d.ts +1 -0
- package/dist/spend/projection.d.ts +36 -0
- package/dist/spend/runner.d.ts +130 -0
- package/dist/spend/service.d.ts +129 -0
- package/dist/spend/signals.d.ts +110 -0
- package/dist/spend/signals.test.d.ts +1 -0
- package/dist/spend/sms-sdk.test.d.ts +1 -0
- package/dist/spend/spend.integration.test.d.ts +1 -0
- package/dist/spend/store.d.ts +166 -0
- package/dist/spend/transports.d.ts +169 -0
- package/dist/spend/transports.test.d.ts +1 -0
- package/dist/spend/window.d.ts +52 -0
- package/dist/spend/window.test.d.ts +1 -0
- package/dist/ui/404.html +1 -0
- package/dist/ui/access-denied.html +2 -2
- package/dist/ui/account/automation.html +3 -3
- package/dist/ui/account/security.html +2 -2
- package/dist/ui/applications/compose.html +3 -3
- package/dist/ui/applications/new.html +2 -2
- package/dist/ui/data/backups.html +4 -4
- package/dist/ui/data/services.html +4 -4
- package/dist/ui/data/volumes.html +4 -4
- package/dist/ui/index.html +4 -4
- package/dist/ui/infrastructure/topology.html +4 -4
- package/dist/ui/integrations.html +1 -1
- package/dist/ui/operations/alerts.html +4 -4
- package/dist/ui/operations/configuration.html +4 -4
- package/dist/ui/operations/jobs.html +4 -4
- package/dist/ui/operations/maintenance.html +4 -4
- package/dist/ui/operations/observability.html +4 -4
- package/dist/ui/operations/previews.html +4 -4
- package/dist/ui/operations/queue.html +4 -4
- package/dist/ui/operations/regions.html +4 -4
- package/dist/ui/operations/releases.html +4 -4
- package/dist/ui/operations/spend.html +1634 -0
- package/dist/ui/operations/workloads.html +4 -4
- package/dist/ui/security.html +2 -2
- package/dist/ui/server/actions.html +4 -4
- package/dist/ui/server/activity.html +2 -2
- package/dist/ui/server/capacity.html +4 -4
- package/dist/ui/server/database.html +4 -4
- package/dist/ui/server/deployments.html +4 -4
- package/dist/ui/server/diagnostics.html +2 -2
- package/dist/ui/server/firewall.html +100 -161
- package/dist/ui/server/fleet.html +4 -4
- package/dist/ui/server/logs.html +4 -4
- package/dist/ui/server/metrics.html +4 -4
- package/dist/ui/server/security.html +2 -2
- package/dist/ui/server/services.html +2 -2
- package/dist/ui/server/sites.html +3 -3
- package/dist/ui/server/ssh-keys.html +4 -4
- package/dist/ui/server/team.html +4 -4
- package/dist/ui/server/terminal.html +2 -2
- package/dist/ui/serverless/alarms.html +4 -4
- package/dist/ui/serverless/assets.html +2 -2
- package/dist/ui/serverless/cost.html +2 -2
- package/dist/ui/serverless/data.html +4 -4
- package/dist/ui/serverless/deployments.html +1 -1
- package/dist/ui/serverless/firewall.html +2 -2
- package/dist/ui/serverless/functions.html +4 -4
- package/dist/ui/serverless/logs.html +4 -4
- package/dist/ui/serverless/metrics.html +2 -2
- package/dist/ui/serverless/queues.html +4 -4
- package/dist/ui/serverless/secrets.html +4 -4
- package/dist/ui/serverless/traces.html +4 -4
- package/dist/ui/serverless.html +4 -4
- package/dist/ui-src/pages/operations/spend.stx +225 -0
- package/dist/ui-src/pages/partials/nav.stx +2 -0
- package/dist/ui-src/pages/server/firewall.stx +89 -0
- package/package.json +11 -3
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spend management: the shared vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* A cap is only as good as the meter behind it. Cloud bills arrive late (AWS
|
|
5
|
+
* Cost Explorer lags a day and does not exist at all for Hetzner or a local
|
|
6
|
+
* box), so a budget that waits for the provider's number cannot stop a runaway
|
|
7
|
+
* loop - it can only describe one after the money is gone. Everything here is
|
|
8
|
+
* therefore built on *usage we observe ourselves*, priced locally through a
|
|
9
|
+
* price book, with the provider's invoice used later to true the estimate up.
|
|
10
|
+
*
|
|
11
|
+
* Money is integer cents throughout. Floating-point dollars accumulate rounding
|
|
12
|
+
* error across millions of usage records, and a cap that is off by a cent in
|
|
13
|
+
* the wrong direction is a cap that does not fire.
|
|
14
|
+
*/
|
|
15
|
+
import type { JsonValue } from '../control-plane';
|
|
16
|
+
/** The physical unit a meter counts in. */
|
|
17
|
+
export type MeterUnit = 'requests' | 'invocations' | 'gb' | 'gb_hours' | 'gb_seconds' | 'hours' | 'minutes' | 'count';
|
|
18
|
+
/**
|
|
19
|
+
* Provider-neutral usage meters.
|
|
20
|
+
*
|
|
21
|
+
* These are deliberately *not* provider SKUs. `edge.egress_gb` is a gigabyte
|
|
22
|
+
* leaving the edge whether that edge is CloudFront, a Hetzner box's NIC, or
|
|
23
|
+
* nginx on a laptop; the price book is what turns it into money. Keeping the
|
|
24
|
+
* meter neutral is what lets one budget cover a mixed-provider project.
|
|
25
|
+
*/
|
|
26
|
+
export declare const METER_KEYS: readonly ['edge.requests', 'edge.egress_gb', 'function.invocations', 'function.gb_seconds', 'build.minutes', 'compute.instance_hours', 'storage.gb_hours', 'object.egress_gb', 'object.requests', 'database.gb_hours', 'database.io_requests', 'telemetry.ingest_gb', 'image.transformations'];
|
|
27
|
+
export type MeterKey = (typeof METER_KEYS)[number] | (string & {});
|
|
28
|
+
export interface MeterDefinition {
|
|
29
|
+
key: MeterKey;
|
|
30
|
+
unit: MeterUnit;
|
|
31
|
+
label: string;
|
|
32
|
+
/** Human-facing precision when rendering a quantity. */
|
|
33
|
+
precision: number;
|
|
34
|
+
}
|
|
35
|
+
export declare const METERS: Readonly<Record<string, MeterDefinition>>;
|
|
36
|
+
/** Where a usage record came from and what it should be billed against. */
|
|
37
|
+
export interface UsageScope {
|
|
38
|
+
organizationId: string;
|
|
39
|
+
projectId?: string;
|
|
40
|
+
environmentId?: string;
|
|
41
|
+
resourceId?: string;
|
|
42
|
+
provider?: string;
|
|
43
|
+
region?: string;
|
|
44
|
+
}
|
|
45
|
+
/** A single observed increment of usage. Deltas, never totals - see the store. */
|
|
46
|
+
export interface UsageDelta extends UsageScope {
|
|
47
|
+
meter: MeterKey;
|
|
48
|
+
quantity: number;
|
|
49
|
+
/** When the usage happened (not when we noticed it). */
|
|
50
|
+
timestamp: string;
|
|
51
|
+
/** Free-form provenance, e.g. `{ source: 'edge-collector', host: 'web-1' }`. */
|
|
52
|
+
attributes?: Record<string, JsonValue>;
|
|
53
|
+
}
|
|
54
|
+
/** An hourly usage rollup as persisted. Quantities are additive; costs are derived. */
|
|
55
|
+
export interface UsageRollup extends Required<Pick<UsageScope, 'organizationId'>> {
|
|
56
|
+
projectId: string;
|
|
57
|
+
environmentId: string;
|
|
58
|
+
resourceId: string;
|
|
59
|
+
provider: string;
|
|
60
|
+
region: string;
|
|
61
|
+
meter: MeterKey;
|
|
62
|
+
/** Inclusive start of the hour bucket, ISO-8601. */
|
|
63
|
+
bucketStart: string;
|
|
64
|
+
quantity: number;
|
|
65
|
+
costCents: number;
|
|
66
|
+
sampleCount: number;
|
|
67
|
+
updatedAt: string;
|
|
68
|
+
}
|
|
69
|
+
export type BudgetPeriod = 'daily' | 'weekly' | 'monthly';
|
|
70
|
+
/**
|
|
71
|
+
* What a cap does when it is reached.
|
|
72
|
+
*
|
|
73
|
+
* Ordered from least to most disruptive. Enforcement never destroys data and
|
|
74
|
+
* every action is reversible; `suspend_project` parks traffic, it does not
|
|
75
|
+
* delete a thing.
|
|
76
|
+
*/
|
|
77
|
+
export declare const ENFORCEMENT_ACTIONS: readonly ['notify', 'block_builds', 'block_deployments', 'throttle_requests', 'suspend_functions', 'serve_static', 'suspend_project'];
|
|
78
|
+
export type EnforcementAction = (typeof ENFORCEMENT_ACTIONS)[number];
|
|
79
|
+
/** Disruption ranking, used to order a plan and to pick the strongest active action. */
|
|
80
|
+
export declare const ENFORCEMENT_SEVERITY: Readonly<Record<EnforcementAction, number>>;
|
|
81
|
+
/**
|
|
82
|
+
* One rung of the ladder: at `atPercent` of the limit, take these actions.
|
|
83
|
+
*
|
|
84
|
+
* Percent is of the *hard* limit when one is set, otherwise of the soft limit,
|
|
85
|
+
* so a single ladder reads consistently however the budget is configured.
|
|
86
|
+
*/
|
|
87
|
+
export interface BudgetThreshold {
|
|
88
|
+
atPercent: number;
|
|
89
|
+
actions: EnforcementAction[];
|
|
90
|
+
/** Fire on the forecast rather than on actual spend. Catches a spike early. */
|
|
91
|
+
onProjection?: boolean;
|
|
92
|
+
}
|
|
93
|
+
export interface Budget {
|
|
94
|
+
id: string;
|
|
95
|
+
organizationId: string;
|
|
96
|
+
projectId?: string;
|
|
97
|
+
environmentId?: string;
|
|
98
|
+
name: string;
|
|
99
|
+
period: BudgetPeriod;
|
|
100
|
+
/** IANA timezone the period boundaries are computed in. */
|
|
101
|
+
timezone: string;
|
|
102
|
+
currency: string;
|
|
103
|
+
/** Warn-only ceiling. Never enforces on its own; it drives `notify` rungs. */
|
|
104
|
+
softLimitCents?: number;
|
|
105
|
+
/** Enforcing ceiling. Reaching it runs the top of the ladder. */
|
|
106
|
+
hardLimitCents?: number;
|
|
107
|
+
thresholds: BudgetThreshold[];
|
|
108
|
+
/**
|
|
109
|
+
* Only these meters count toward the budget. Empty means every meter.
|
|
110
|
+
* Lets you cap "egress" without capping the whole project.
|
|
111
|
+
*/
|
|
112
|
+
meters: MeterKey[];
|
|
113
|
+
/** Seconds a breach must persist before enforcement runs. Absorbs a blip. */
|
|
114
|
+
graceSeconds: number;
|
|
115
|
+
/**
|
|
116
|
+
* Percent below the trigger that spend must fall back to before an action is
|
|
117
|
+
* lifted. Without it, a scope sitting exactly on the line flaps.
|
|
118
|
+
*/
|
|
119
|
+
hysteresisPercent: number;
|
|
120
|
+
/** Evaluate but never enforce. The way to roll a cap out safely. */
|
|
121
|
+
dryRun: boolean;
|
|
122
|
+
enabled: boolean;
|
|
123
|
+
version: number;
|
|
124
|
+
createdAt: string;
|
|
125
|
+
updatedAt: string;
|
|
126
|
+
}
|
|
127
|
+
export type SpendLevel = 'ok' | 'warning' | 'soft_capped' | 'hard_capped';
|
|
128
|
+
/** The window a budget is currently being measured over. */
|
|
129
|
+
export interface BudgetWindow {
|
|
130
|
+
start: string;
|
|
131
|
+
end: string;
|
|
132
|
+
elapsedMs: number;
|
|
133
|
+
totalMs: number;
|
|
134
|
+
label: string;
|
|
135
|
+
}
|
|
136
|
+
/** Forecast for the remainder of the window. */
|
|
137
|
+
export interface SpendProjection {
|
|
138
|
+
/** Spend so far this window, cents. */
|
|
139
|
+
actualCents: number;
|
|
140
|
+
/** Cents per millisecond, from the observed burn. */
|
|
141
|
+
burnRateCentsPerMs: number;
|
|
142
|
+
/** Where the window lands if the current burn holds. */
|
|
143
|
+
projectedCents: number;
|
|
144
|
+
/** Fraction of the window elapsed, 0-1. Low values make the forecast noisy. */
|
|
145
|
+
elapsedFraction: number;
|
|
146
|
+
/**
|
|
147
|
+
* How much to trust `projectedCents`, 0-1. Grows with elapsed time and with
|
|
148
|
+
* the number of buckets observed; a forecast from 10 minutes of a month is
|
|
149
|
+
* arithmetic, not information.
|
|
150
|
+
*/
|
|
151
|
+
confidence: number;
|
|
152
|
+
/** ISO timestamp the limit is projected to be hit, if it is hit in-window. */
|
|
153
|
+
exhaustionAt?: string;
|
|
154
|
+
/** Milliseconds until `exhaustionAt`. */
|
|
155
|
+
timeToExhaustionMs?: number;
|
|
156
|
+
}
|
|
157
|
+
/** A threshold that has been crossed. */
|
|
158
|
+
export interface ThresholdBreach {
|
|
159
|
+
atPercent: number;
|
|
160
|
+
actions: EnforcementAction[];
|
|
161
|
+
/** True when the breach is against the forecast rather than actual spend. */
|
|
162
|
+
projected: boolean;
|
|
163
|
+
observedPercent: number;
|
|
164
|
+
}
|
|
165
|
+
/** The evaluator's verdict for one budget at one instant. */
|
|
166
|
+
export interface SpendDecision {
|
|
167
|
+
budgetId: string;
|
|
168
|
+
level: SpendLevel;
|
|
169
|
+
window: BudgetWindow;
|
|
170
|
+
projection: SpendProjection;
|
|
171
|
+
/** Percent of the governing limit actually spent. */
|
|
172
|
+
usedPercent: number;
|
|
173
|
+
/** Percent of the governing limit the forecast lands at. */
|
|
174
|
+
projectedPercent: number;
|
|
175
|
+
breaches: ThresholdBreach[];
|
|
176
|
+
/** Actions the ladder calls for, deduped and ordered by severity. */
|
|
177
|
+
actions: EnforcementAction[];
|
|
178
|
+
/** Actions currently in force that should now be lifted. */
|
|
179
|
+
releases: EnforcementAction[];
|
|
180
|
+
/** True when `dryRun` suppressed real enforcement. */
|
|
181
|
+
simulated: boolean;
|
|
182
|
+
reason: string;
|
|
183
|
+
evaluatedAt: string;
|
|
184
|
+
}
|
|
185
|
+
export type EnforcementState = 'pending' | 'active' | 'releasing' | 'released' | 'failed';
|
|
186
|
+
/** An enforcement action as applied to a scope, with what it takes to undo it. */
|
|
187
|
+
export interface EnforcementRecord {
|
|
188
|
+
id: string;
|
|
189
|
+
budgetId: string;
|
|
190
|
+
organizationId: string;
|
|
191
|
+
projectId?: string;
|
|
192
|
+
environmentId?: string;
|
|
193
|
+
action: EnforcementAction;
|
|
194
|
+
state: EnforcementState;
|
|
195
|
+
reason: string;
|
|
196
|
+
/** Whatever the applier needs to restore the prior state. Never secrets. */
|
|
197
|
+
restore: Record<string, JsonValue>;
|
|
198
|
+
triggeredAtPercent: number;
|
|
199
|
+
simulated: boolean;
|
|
200
|
+
appliedAt?: string;
|
|
201
|
+
releasedAt?: string;
|
|
202
|
+
error?: string;
|
|
203
|
+
createdAt: string;
|
|
204
|
+
updatedAt: string;
|
|
205
|
+
}
|
|
206
|
+
export type AnomalyDirection = 'spike' | 'drop';
|
|
207
|
+
/** A point that does not fit its own history. */
|
|
208
|
+
export interface SpendAnomaly {
|
|
209
|
+
id: string;
|
|
210
|
+
organizationId: string;
|
|
211
|
+
projectId?: string;
|
|
212
|
+
environmentId?: string;
|
|
213
|
+
/** Meter key, or `cost` for the priced aggregate. */
|
|
214
|
+
signal: string;
|
|
215
|
+
direction: AnomalyDirection;
|
|
216
|
+
observed: number;
|
|
217
|
+
/** What the seasonal baseline expected. */
|
|
218
|
+
expected: number;
|
|
219
|
+
/** Robust z-score (MAD-based). */
|
|
220
|
+
score: number;
|
|
221
|
+
/** Plain-language delta, e.g. `+412%`. */
|
|
222
|
+
deltaPercent: number;
|
|
223
|
+
severity: 'info' | 'warning' | 'critical';
|
|
224
|
+
bucketStart: string;
|
|
225
|
+
evidence: Record<string, JsonValue>;
|
|
226
|
+
acknowledgedAt?: string;
|
|
227
|
+
createdAt: string;
|
|
228
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { AlertStore, NotificationDelivery } from '../alerts';
|
|
2
|
+
import type { Budget, EnforcementAction, SpendAnomaly, SpendDecision } from './model';
|
|
3
|
+
export type SpendEventType = 'spend.threshold' | 'spend.enforced' | 'spend.released' | 'spend.anomaly';
|
|
4
|
+
/**
|
|
5
|
+
* Channel kinds worth waking someone for.
|
|
6
|
+
*
|
|
7
|
+
* A threshold crossing goes everywhere the routes point. Reaching the limit -
|
|
8
|
+
* where enforcement starts - is the one that should also reach a phone, which
|
|
9
|
+
* is why `sms` exists as a channel kind at all.
|
|
10
|
+
*/
|
|
11
|
+
export declare const URGENT_SPEND_EVENTS: readonly SpendEventType[];
|
|
12
|
+
/** Money as an operator reads it, not as the database stores it. */
|
|
13
|
+
export declare function formatCents(cents: number, currency?: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* A one-line summary an operator can act on from a phone.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately leads with the money and the budget name rather than the
|
|
18
|
+
* percentage: "83% of Production" means nothing without knowing the limit.
|
|
19
|
+
*/
|
|
20
|
+
export declare function spendNotificationText(budget: Budget, decision: SpendDecision, event: SpendEventType): string;
|
|
21
|
+
export interface SpendNotificationResult {
|
|
22
|
+
deliveries: NotificationDelivery[];
|
|
23
|
+
/** Routes that matched but were inside quiet hours. */
|
|
24
|
+
suppressed: string[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Routes spend events to notification channels.
|
|
28
|
+
*
|
|
29
|
+
* Two rules borrowed from the alert router, for the same reasons:
|
|
30
|
+
*
|
|
31
|
+
* - **Quiet hours never suppress a release.** Waking someone to say a cap
|
|
32
|
+
* lifted is unnecessary; leaving them to think a cap is still on is worse.
|
|
33
|
+
* - **Per-route rate limits apply.** A budget re-evaluated every minute must
|
|
34
|
+
* not become a per-minute page, and the idempotency key does most of that
|
|
35
|
+
* work already by collapsing repeats within a window.
|
|
36
|
+
*/
|
|
37
|
+
export declare class SpendNotificationRouter {
|
|
38
|
+
private readonly store;
|
|
39
|
+
private readonly options;
|
|
40
|
+
constructor(store: AlertStore, options?: {
|
|
41
|
+
now?: () => Date;
|
|
42
|
+
});
|
|
43
|
+
private now;
|
|
44
|
+
private channelsFor;
|
|
45
|
+
private dispatch;
|
|
46
|
+
/**
|
|
47
|
+
* Notify about a budget decision.
|
|
48
|
+
*
|
|
49
|
+
* The idempotency key includes the window and the crossed threshold, so the
|
|
50
|
+
* same breach re-evaluated every minute produces one delivery per threshold
|
|
51
|
+
* per window - not one per cycle.
|
|
52
|
+
*/
|
|
53
|
+
notifyDecision(budget: Budget, decision: SpendDecision): SpendNotificationResult;
|
|
54
|
+
/** Notify that enforcement was lifted. */
|
|
55
|
+
notifyRelease(budget: Budget, decision: SpendDecision, released: readonly EnforcementAction[]): SpendNotificationResult;
|
|
56
|
+
/** Notify about a detected anomaly. One delivery per anomaly, ever. */
|
|
57
|
+
notifyAnomaly(anomaly: SpendAnomaly, currency?: string): SpendNotificationResult;
|
|
58
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The price book: usage in, cents out.
|
|
3
|
+
*
|
|
4
|
+
* Caps have to be enforceable on any provider, including ones with no billing
|
|
5
|
+
* API at all. A Hetzner box has a fixed monthly price and an included traffic
|
|
6
|
+
* allowance; a local box costs nothing until it egresses. Neither will ever
|
|
7
|
+
* answer a Cost Explorer query, so the platform prices usage itself and treats
|
|
8
|
+
* the provider invoice as a later correction rather than the source of truth.
|
|
9
|
+
*
|
|
10
|
+
* Rates are cents per unit and are stored as `microCentsPerUnit` (cents x 1e6)
|
|
11
|
+
* because per-request prices are genuinely tiny - CloudFront charges about
|
|
12
|
+
* 0.0000075 cents per request, which rounds to zero in any coarser unit and
|
|
13
|
+
* would silently make request floods free.
|
|
14
|
+
*/
|
|
15
|
+
import type { MeterKey } from './model';
|
|
16
|
+
/** Cents x 1e6, so sub-cent unit prices survive integer storage. */
|
|
17
|
+
export declare const MICRO_CENTS = 1000000;
|
|
18
|
+
/**
|
|
19
|
+
* A graduated pricing tier.
|
|
20
|
+
*
|
|
21
|
+
* `upToQuantity` is the cumulative top of the tier, `null` for the final,
|
|
22
|
+
* unbounded one. Graduated (not flat-rate) semantics: quantity inside each tier
|
|
23
|
+
* is charged at that tier's rate, matching how every cloud actually bills.
|
|
24
|
+
*/
|
|
25
|
+
export interface PriceTier {
|
|
26
|
+
upToQuantity: number | null;
|
|
27
|
+
microCentsPerUnit: number;
|
|
28
|
+
}
|
|
29
|
+
export interface PriceEntry {
|
|
30
|
+
meter: MeterKey;
|
|
31
|
+
provider: string;
|
|
32
|
+
/** Omit for a provider-wide default; a region-specific entry wins over it. */
|
|
33
|
+
region?: string;
|
|
34
|
+
/** Quantity billed at zero before tiers apply (a free allowance). */
|
|
35
|
+
includedQuantity?: number;
|
|
36
|
+
/** Flat rate. Ignored when `tiers` is present. */
|
|
37
|
+
microCentsPerUnit?: number;
|
|
38
|
+
tiers?: PriceTier[];
|
|
39
|
+
/**
|
|
40
|
+
* Charged once per window regardless of usage (a box's monthly rental).
|
|
41
|
+
* Applied by the caller for the whole window, not per usage record.
|
|
42
|
+
*/
|
|
43
|
+
fixedCentsPerPeriod?: number;
|
|
44
|
+
}
|
|
45
|
+
export interface PriceBook {
|
|
46
|
+
currency: string;
|
|
47
|
+
entries: PriceEntry[];
|
|
48
|
+
}
|
|
49
|
+
export interface PricedUsage {
|
|
50
|
+
meter: MeterKey;
|
|
51
|
+
provider: string;
|
|
52
|
+
region?: string;
|
|
53
|
+
quantity: number;
|
|
54
|
+
/** Quantity that fell inside the free allowance. */
|
|
55
|
+
includedQuantity: number;
|
|
56
|
+
billableQuantity: number;
|
|
57
|
+
costCents: number;
|
|
58
|
+
/** No price entry matched; cost is 0 and the caller should surface this. */
|
|
59
|
+
unpriced: boolean;
|
|
60
|
+
}
|
|
61
|
+
export interface PricedTotal {
|
|
62
|
+
currency: string;
|
|
63
|
+
totalCents: number;
|
|
64
|
+
lines: PricedUsage[];
|
|
65
|
+
/** Meters that had usage but no matching price entry. */
|
|
66
|
+
unpricedMeters: string[];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Cost of `quantity` units under a graduated tier list, in micro-cents.
|
|
70
|
+
*
|
|
71
|
+
* Tiers are sorted defensively: a book assembled from several sources should
|
|
72
|
+
* not price differently because someone listed the unbounded tier first.
|
|
73
|
+
*/
|
|
74
|
+
export declare function tieredMicroCents(quantity: number, tiers: PriceTier[]): number;
|
|
75
|
+
/** Most specific match wins: exact region, then provider default, then wildcard provider. */
|
|
76
|
+
export declare function findPriceEntry(book: PriceBook, meter: MeterKey, provider?: string, region?: string): PriceEntry | undefined;
|
|
77
|
+
export interface UsageLine {
|
|
78
|
+
meter: MeterKey;
|
|
79
|
+
quantity: number;
|
|
80
|
+
provider?: string;
|
|
81
|
+
region?: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Price one usage line.
|
|
85
|
+
*
|
|
86
|
+
* `alreadyUsedQuantity` lets a caller price incrementally across a window while
|
|
87
|
+
* still honouring a free allowance: pass the quantity already billed this
|
|
88
|
+
* period and the allowance is consumed once, not once per call.
|
|
89
|
+
*/
|
|
90
|
+
export declare function priceUsage(book: PriceBook, line: UsageLine, alreadyUsedQuantity?: number): PricedUsage;
|
|
91
|
+
/** Price a set of lines, one allowance per (meter, provider, region). */
|
|
92
|
+
export declare function priceUsageLines(book: PriceBook, lines: UsageLine[]): PricedTotal;
|
|
93
|
+
/** Round a cent amount to whole cents for storage or display. */
|
|
94
|
+
export declare function roundCents(cents: number): number;
|
|
95
|
+
/**
|
|
96
|
+
* Merge price books, later entries winning on an exact (meter, provider,
|
|
97
|
+
* region) match. This is how an operator overrides a shipped default without
|
|
98
|
+
* forking the whole book.
|
|
99
|
+
*/
|
|
100
|
+
export declare function mergePriceBooks(base: PriceBook, ...overrides: PriceBook[]): PriceBook;
|
|
101
|
+
/**
|
|
102
|
+
* Shipped defaults, in US cents, list price, us-east-1 / eu-central.
|
|
103
|
+
*
|
|
104
|
+
* These are estimates for *forecasting and capping*, not an invoice. They are
|
|
105
|
+
* intentionally slightly conservative (rounded up) so a cap trips a little
|
|
106
|
+
* early rather than a little late - the failure mode of a cap that fires at
|
|
107
|
+
* 101% is a surprised user, and the whole point is not to surprise them.
|
|
108
|
+
*/
|
|
109
|
+
export declare const DEFAULT_PRICE_BOOK: PriceBook;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forecasting: where does this window land if nothing changes?
|
|
3
|
+
*
|
|
4
|
+
* A cap that only reacts to actual spend is always late - by the time a
|
|
5
|
+
* monthly budget reads 100%, the money is gone. The useful signal is the
|
|
6
|
+
* forecast, and the hard part about a forecast is knowing when to distrust it.
|
|
7
|
+
* Ten minutes into a month, a burn rate extrapolates to a number that is
|
|
8
|
+
* arithmetically correct and completely meaningless.
|
|
9
|
+
*
|
|
10
|
+
* So every projection carries a confidence, and the evaluator refuses to
|
|
11
|
+
* enforce on a low-confidence one. Two things build confidence: elapsed time
|
|
12
|
+
* (a longer sample is a better sample) and the number of buckets that actually
|
|
13
|
+
* carried spend (one huge hour is a spike, not a trend).
|
|
14
|
+
*/
|
|
15
|
+
import type { BudgetWindow, SpendProjection } from './model';
|
|
16
|
+
export interface ProjectionInput {
|
|
17
|
+
window: BudgetWindow;
|
|
18
|
+
actualCents: number;
|
|
19
|
+
/** Hourly cost buckets in the window, ascending. Optional but sharpens the estimate. */
|
|
20
|
+
series?: ReadonlyArray<{
|
|
21
|
+
bucketStart: string;
|
|
22
|
+
costCents: number;
|
|
23
|
+
}>;
|
|
24
|
+
/** The limit to project exhaustion against, in cents. */
|
|
25
|
+
limitCents?: number;
|
|
26
|
+
/**
|
|
27
|
+
* How much weight recent buckets get, 0-1. Higher reacts faster to a spike;
|
|
28
|
+
* 0 falls back to the flat window average.
|
|
29
|
+
*/
|
|
30
|
+
recencyWeight?: number;
|
|
31
|
+
}
|
|
32
|
+
export declare function projectSpend(input: ProjectionInput): SpendProjection;
|
|
33
|
+
/** Percent of `limitCents` that `cents` represents. A zero limit is treated as fully used. */
|
|
34
|
+
export declare function percentOfLimit(cents: number, limitCents?: number): number;
|
|
35
|
+
/** Human-readable time-to-cap, e.g. `2d 4h`. Empty when the cap is not projected to be hit. */
|
|
36
|
+
export declare function formatTimeToExhaustion(ms?: number): string;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The spend loop: the thing that actually runs on a timer.
|
|
3
|
+
*
|
|
4
|
+
* Everything else in this module is a component - a store, a pure evaluator, a
|
|
5
|
+
* set of handlers. This is the composition, and it is deliberately the only
|
|
6
|
+
* place that knows the order:
|
|
7
|
+
*
|
|
8
|
+
* ingest -> evaluate -> enforce -> notify -> detect anomalies -> prune
|
|
9
|
+
*
|
|
10
|
+
* Ingest first, because a decision made against last cycle's usage is a
|
|
11
|
+
* decision made a minute late, and a minute is a lot of invocations. Prune
|
|
12
|
+
* last, because pruning is the only step that is safe to skip if the process
|
|
13
|
+
* dies partway through.
|
|
14
|
+
*
|
|
15
|
+
* Cadence matters more than it looks. The loop is designed to run every minute:
|
|
16
|
+
* usage rolls up hourly, so a more frequent cycle re-reads the same numbers,
|
|
17
|
+
* and a less frequent one widens the window in which a runaway loop bills
|
|
18
|
+
* unchecked. One minute is the shortest interval at which every step is cheap.
|
|
19
|
+
*/
|
|
20
|
+
import type { AlertStore } from '../alerts';
|
|
21
|
+
import type { ControlPlaneStore } from '../control-plane';
|
|
22
|
+
import type { TelemetryRecord } from '../telemetry';
|
|
23
|
+
import type { SpendEnforcementTransport } from './appliers';
|
|
24
|
+
import type { EnforcementPolicy } from './enforcement';
|
|
25
|
+
import type { EnforcementAction, SpendAnomaly, SpendDecision } from './model';
|
|
26
|
+
import type { SpendStore } from './store';
|
|
27
|
+
import { SpendGate } from './gate';
|
|
28
|
+
import { SpendLoopLease } from './lease';
|
|
29
|
+
import { SpendService } from './service';
|
|
30
|
+
export interface SpendRunnerOptions {
|
|
31
|
+
controlPlane: ControlPlaneStore;
|
|
32
|
+
store: SpendStore;
|
|
33
|
+
/** Reuses the alert channels and routes an operator already configured. */
|
|
34
|
+
alerts?: AlertStore;
|
|
35
|
+
transport?: SpendEnforcementTransport;
|
|
36
|
+
policy?: EnforcementPolicy;
|
|
37
|
+
/** Days of hourly rollups to keep. Default 400, so year-over-year works. */
|
|
38
|
+
retentionDays?: number;
|
|
39
|
+
now?: () => Date;
|
|
40
|
+
}
|
|
41
|
+
export interface SpendRunnerScope {
|
|
42
|
+
organizationId: string;
|
|
43
|
+
projectId?: string;
|
|
44
|
+
environmentId?: string;
|
|
45
|
+
environmentKind?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface SpendRunResult {
|
|
48
|
+
scope: SpendRunnerScope;
|
|
49
|
+
ingested: {
|
|
50
|
+
applied: number;
|
|
51
|
+
duplicates: number;
|
|
52
|
+
costCents: number;
|
|
53
|
+
unpricedMeters: string[];
|
|
54
|
+
};
|
|
55
|
+
decisions: SpendDecision[];
|
|
56
|
+
applied: EnforcementAction[];
|
|
57
|
+
released: EnforcementAction[];
|
|
58
|
+
failed: Array<{
|
|
59
|
+
action: EnforcementAction;
|
|
60
|
+
error: string;
|
|
61
|
+
}>;
|
|
62
|
+
withheld: EnforcementAction[];
|
|
63
|
+
anomalies: SpendAnomaly[];
|
|
64
|
+
notificationsSent: number;
|
|
65
|
+
ranAt: string;
|
|
66
|
+
durationMs: number;
|
|
67
|
+
/** Anything that went wrong without stopping the cycle. */
|
|
68
|
+
warnings: string[];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Runs one spend cycle for one scope.
|
|
72
|
+
*
|
|
73
|
+
* Every step is wrapped: a failure in notification must not stop enforcement,
|
|
74
|
+
* and a failure in anomaly detection must not stop either. The alternative -
|
|
75
|
+
* one throw aborting the cycle - means a broken Slack webhook disables the
|
|
76
|
+
* spend cap, which is precisely backwards.
|
|
77
|
+
*/
|
|
78
|
+
export declare class SpendRunner {
|
|
79
|
+
private readonly options;
|
|
80
|
+
readonly gate: SpendGate;
|
|
81
|
+
readonly service: SpendService;
|
|
82
|
+
private readonly notifications?;
|
|
83
|
+
constructor(options: SpendRunnerOptions);
|
|
84
|
+
private now;
|
|
85
|
+
/** Meter a telemetry batch. Safe to call from a collector on its own cadence. */
|
|
86
|
+
ingest(organizationId: string, records: readonly TelemetryRecord[], defaultProvider?: string): SpendRunResult['ingested'];
|
|
87
|
+
/**
|
|
88
|
+
* Run one full cycle.
|
|
89
|
+
*
|
|
90
|
+
* `telemetry` is optional: a deployment where collectors POST usage directly
|
|
91
|
+
* to the API has already ingested by the time this runs, and passing an
|
|
92
|
+
* empty batch simply skips step one.
|
|
93
|
+
*/
|
|
94
|
+
run(scope: SpendRunnerScope, telemetry?: readonly TelemetryRecord[]): Promise<SpendRunResult>;
|
|
95
|
+
/**
|
|
96
|
+
* Drop gate entries whose budget is gone or disabled.
|
|
97
|
+
*
|
|
98
|
+
* This is the failure mode that would otherwise be invisible: a cap applied,
|
|
99
|
+
* then the budget deleted, leaves deployments blocked by a rule nobody can
|
|
100
|
+
* find. Reconciling here makes the gate converge on the budgets that exist.
|
|
101
|
+
*/
|
|
102
|
+
reconcileGate(scope: SpendRunnerScope): EnforcementAction[];
|
|
103
|
+
/** Run every organization the control plane knows about. The cron entry point. */
|
|
104
|
+
runAll(telemetry?: readonly TelemetryRecord[]): Promise<SpendRunResult[]>;
|
|
105
|
+
}
|
|
106
|
+
/** How often the loop should run, in seconds. See the note at the top of the file. */
|
|
107
|
+
export declare const SPEND_CYCLE_SECONDS = 60;
|
|
108
|
+
/**
|
|
109
|
+
* Start the loop on an interval.
|
|
110
|
+
*
|
|
111
|
+
* Returns a stop function. Cycles never overlap: a slow cycle delays the next
|
|
112
|
+
* one rather than running two evaluations against the same usage, which would
|
|
113
|
+
* double-apply enforcement and double-notify.
|
|
114
|
+
*/
|
|
115
|
+
export interface SpendLoopOptions {
|
|
116
|
+
intervalSeconds?: number;
|
|
117
|
+
/**
|
|
118
|
+
* Only run when this lease can be held. Pass one whenever the loop might be
|
|
119
|
+
* started in more than one process - a dashboard server and a `spend:work`
|
|
120
|
+
* worker on the same box is the ordinary case.
|
|
121
|
+
*/
|
|
122
|
+
lease?: SpendLoopLease;
|
|
123
|
+
/** Run one cycle immediately instead of waiting out the first interval. */
|
|
124
|
+
immediate?: boolean;
|
|
125
|
+
onResult?: (results: SpendRunResult[]) => void;
|
|
126
|
+
/** Called when a cycle was skipped because another process holds the lease. */
|
|
127
|
+
onSkip?: (holder: string) => void;
|
|
128
|
+
onError?: (error: unknown) => void;
|
|
129
|
+
}
|
|
130
|
+
export declare function startSpendLoop(runner: SpendRunner, options?: SpendLoopOptions): () => void;
|