diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a923c59..01626a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,3 +26,16 @@ jobs: - uses: azure/setup-helm@v4 - run: helm lint deploy/helm/vbilling - run: helm template vbilling deploy/helm/vbilling --set adapters='{stripe}' --set stripe.existingSecret=vbilling-stripe > /dev/null + + gke-stripe: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check for accidentally committed demo credentials + run: python3 scripts/check-demo-secrets.py --tracked + - name: Test the secret check with synthetic fixtures + run: python3 scripts/check-demo-secrets.py --self-test + - name: Test demo helpers without cloud credentials + run: python3 -m unittest discover -s examples/gke-stripe/tests -v + - name: Check local launcher syntax + run: bash -n examples/gke-stripe/scripts/run-local.sh diff --git a/.gitignore b/.gitignore index 014f4a5..d34ee04 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,24 @@ stripe-webhook-secret metronome-token lago-api-key webhook-secret + +# Local demo credentials and generated runtime state (never commit) +.env.* +!.env.example +*.kubeconfig +*.kubeconfig.* +kubeconfig +api-token +private-tenants.json +/examples/gke-gpu/join.txt +/examples/gke-gpu/startup.sh +/examples/gke-gpu/agent.yaml +/examples/gke-stripe/.runtime/ + +# Demo helper bytecode and CLI authentication files +__pycache__/ +*.py[cod] +vcluster-cli.json +platform-cli.json +private-join.txt +private-startup.sh diff --git a/README.md b/README.md index 250ef14..456523c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ vBilling is the **metering pipe**, not the billing engine. It turns what runs in > vBilling lives in `vClusterLabs-Experiments`: it is an experimental project, not a supported vCluster Labs product. +**[GPU metering with Stripe](examples/gke-stripe/README.md)** provides a step-by-step setup for shared L4 GPUs, optional private A100 nodes, usage delivery to Stripe test mode, and resource cleanup. Credentials and runtime data stay outside the repository. + ## What's new in v0.2 - **Stripe and Metronome adapters.** Use Stripe Billing Meters on their own, or Metronome for rating with Stripe for payment, invoicing and tax. vBilling creates the Stripe customer and links it in Metronome. @@ -290,7 +292,7 @@ Override any mapping with `ENFORCEMENT_RULES`, for example `alerts.spend_thresho - **`annotate`**: also annotate the tenant cluster, label its namespace `vbilling.vcluster.com/billing-state`, and emit a Kubernetes event. - **`enforce`**: also label suspended namespaces `vbilling.vcluster.com/suspended=true`. With `enforcement.admissionPolicy.enabled=true` (Kubernetes 1.30+), a ValidatingAdmissionPolicy then denies **new** pods in those namespaces. Running workloads keep running, the tenant sees the reason as a sync error inside their tenant cluster, and a paid invoice lifts it automatically. The policy acts in the control plane cluster, so it holds tenant clusters on shared or dedicated nodes; tenant clusters with their own nodes (Private Nodes, Auto Nodes, Standalone) run their pods only inside the tenant cluster, so act on their billing state with your own automation. -Payment failures on invoices that Stripe collects arrive through Stripe's webhooks, so in Metronome + Stripe mode, send Stripe's webhooks to vBilling as well. Billing states are enabled once `STRIPE_WEBHOOK_SECRET` or `METRONOME_WEBHOOK_SECRET` is set. +Payment failures on invoices that Stripe collects arrive through Stripe's webhooks, so in Metronome + Stripe mode, send Stripe's webhooks to vBilling as well. Billing states are always available: Stripe and Metronome webhooks set them once `STRIPE_WEBHOOK_SECRET` or `METRONOME_WEBHOOK_SECRET` is set, and `PUT /api/v1/billing-states/{tenant}` sets them from anything else, such as Lago or a custom billing platform. ## Custom usage: tokens, Slurm, storage, network diff --git a/cmd/vbilling/main.go b/cmd/vbilling/main.go index bb1b1cc..74a9b82 100644 --- a/cmd/vbilling/main.go +++ b/cmd/vbilling/main.go @@ -198,11 +198,11 @@ func main() { log.Println("vBilling stopped") } -// newEnforcer enables billing-state enforcement when a webhook secret is set. +// newEnforcer sets up billing states. They are always available: Stripe and +// Metronome webhooks set them once their secrets are configured, and +// PUT /api/v1/billing-states/{tenant} sets them from anywhere else, such as +// Lago or a custom billing platform. func newEnforcer(cfg *config.Config, kube kubernetes.Interface, ctrl *controller.Controller, dests []destinations.Destination, tel *telemetry.Registry) *enforcement.Enforcer { - if cfg.StripeWebhookSecret == "" && cfg.MetronomeWebhookSecret == "" { - return nil - } rules, err := enforcement.ParseRules(cfg.EnforcementRules) if err != nil { log.Fatalf("ENFORCEMENT_RULES: %v", err) @@ -225,7 +225,8 @@ func newEnforcer(cfg *config.Config, kube kubernetes.Interface, ctrl *controller if err != nil { log.Fatalf("open billing state: %v", err) } - log.Printf("Billing-state enforcement enabled (mode=%s)", cfg.EnforcementMode) + log.Printf("Billing states: mode=%s, Stripe webhooks %s, Metronome webhooks %s", cfg.EnforcementMode, + onOff(cfg.StripeWebhookSecret != ""), onOff(cfg.MetronomeWebhookSecret != "")) return enforcement.New(kube, enforcement.Options{ Mode: cfg.EnforcementMode, StripeWebhookSecret: cfg.StripeWebhookSecret, @@ -269,3 +270,10 @@ func getKubeConfig() (*rest.Config, error) { log.Printf("Using kubeconfig (%s)", os.Getenv("KUBECONFIG")) return cfg, nil } + +func onOff(b bool) string { + if b { + return "on" + } + return "off" +} diff --git a/docs/custom-adapter.html b/docs/custom-adapter.html index 6800698..0b7cf1a 100644 --- a/docs/custom-adapter.html +++ b/docs/custom-adapter.html @@ -378,7 +378,7 @@
id. A batch can arrive twice: after a timeout, a restart before the cursor was saved, a dead-letter replay or a cursor rewind. A usage event keeps its ID for the same window, so upsert on it. Tenant and catalog IDs carry a timestamp: keep the latest. Return 2xx for types you ignore, because vBilling holds all delivery to the endpoint until it accepts the catalog event.id. A batch can arrive twice: after a timeout, a restart before the cursor was saved, a dead-letter replay or a cursor rewind. A usage event keeps its ID for the same window, so upsert on it. Tenant and catalog IDs carry a timestamp: keep the latest. Return 2xx for types you ignore. A catalog event your endpoint rejects with 400, 413 or 422 is logged and skipped; any other failure holds delivery to the endpoint until the catalog event is accepted.Dead letters stay on the ledger volume until a replay clears them. Add -H "Authorization: Bearer $API_TOKEN" when the API is protected:
curl -s localhost:8080/api/v1/destinations/webhook/dead-letters \
@@ -436,7 +436,7 @@ Go adapter
EventFilterAccepts(metric string) boolSkip metrics the platform should not see. destinations.BillableOnly drops the informational ones. Skipped events are counted, never retried.
ReconcilerRecordedTotals(ctx context.Context, q TotalsQuery) ([]Total, error)Report what the platform recorded per tenant and metric, so GET /api/v1/reconcile can compare it with the ledger.
- CustomerResolverTenantForCustomer(ctx context.Context, customerID string) (string, bool)Map a platform customer ID back to a tenant for inbound billing webhooks. Only the Stripe and Metronome adapters' webhooks (/webhooks/stripe, /webhooks/metronome) consult it today.
+ CustomerResolverTenantForCustomer(ctx context.Context, customerID string) (string, bool)Map a platform customer ID back to a tenant for inbound billing webhooks. Only the Stripe and Metronome adapters' webhooks (/webhooks/stripe, /webhooks/metronome) consult it today; any other billing platform sets billing states with PUT /api/v1/billing-states/{tenant}.
Both vCluster tenancy models on GPUs, metered by vBilling and invoiced by Stripe test mode, from an empty Google Cloud project. The manifests, values files and commands are the ones from a recorded run, with every key, token and account ID left out.
+For a setup without Prometheus, see GPU metering with Stripe. It uses two L4 sharing slots, local vBilling, and paused Stripe test subscriptions. This example uses four slots, Prometheus/DCGM, and automatic subscriptions.
+vcluster platform login https://<your-platform-host> # browser login, no key on the command line
+ vcluster platform login https://your-platform.example # browser login, no key on the command line
vcluster create shared-l4 -n shared-l4 -f 02-tenant-shared.yaml --connect=false
vcluster create private-a100 -n private-a100 -f 03-tenant-private.yaml --connect=false
When the CLI is logged in, vcluster create registers each tenant cluster with the Platform, which is what licenses Private Nodes. A tenant cluster created before logging in is registered with vcluster platform add vcluster private-a100 -n private-a100 --project default. The private tenant cluster's exportKubeConfig.additionalSecrets writes the read-only Secret vbilling-kubeconfig that vBilling reads (see Private Nodes).
- Tell vBilling who the tenants are (the emails use the reserved .example domain):
+ Optionally set stable customer IDs and display names. Without these annotations, vBilling derives an identity per tenant cluster. The emails below use the reserved .example domain:
kubectl annotate ns shared-l4 vbilling.vcluster.com/tenant=acme-ai "vbilling.vcluster.com/display-name=Acme AI" \
vbilling.vcluster.com/email=billing@acme-ai.example vbilling.vcluster.com/plan=gpu-cloud
kubectl annotate ns private-a100 vbilling.vcluster.com/tenant=initech "vbilling.vcluster.com/display-name=Initech" \
@@ -342,15 +344,24 @@ 3. Tenant clusters
4. A private GPU node
Create a join token, put the join command it prints into the startup script, and boot the machine. The VM must reach the tenant cluster's internal load balancer, so keep it in the same VPC and region.
- vcluster connect private-a100 -n private-a100 -- vcluster token create --expires 2h | grep node/join > join.txt
-awk -v cmd="$(cat join.txt)" '$0 == "JOIN_COMMAND" { print cmd; next } { print }' 04-node-startup.sh > startup.sh
-gcloud compute instances create gpu-node-1 --zone $ZONE --machine-type a2-highgpu-1g \
+ set -euo pipefail
+set +x # do not trace commands while handling credentials
+umask 077
+JOIN_DIR=$(mktemp -d "/tmp/vbilling-private-node.XXXXXX")
+chmod 700 "$JOIN_DIR"
+vcluster connect private-a100 -n private-a100 -- vcluster token create --expires 2h | grep node/join > "$JOIN_DIR/join.txt"
+# awk reads the token from the file; the token is never a process argument.
+awk 'FNR == NR { cmd = $0; next } $0 == "JOIN_COMMAND" { print cmd; next } { print }' \
+ "$JOIN_DIR/join.txt" 04-node-startup.sh > "$JOIN_DIR/startup.sh"
+gcloud compute instances create gpu-node-1 --zone $ZONE --machine-type a2-highgpu-1g \
--provisioning-model SPOT --instance-termination-action STOP --maintenance-policy TERMINATE \
--image-family ubuntu-2204-lts --image-project ubuntu-os-cloud --boot-disk-size 150GB \
- --no-service-account --no-scopes --metadata-from-file startup-script=startup.sh
+ --no-service-account --no-scopes --metadata-from-file "startup-script=$JOIN_DIR/startup.sh"
Once gpu-node-1 is Ready in the tenant cluster, remove the token from the instance and from disk, and label the node so it gets an SKU:
gcloud compute instances remove-metadata gpu-node-1 --zone $ZONE --keys startup-script
-rm join.txt startup.sh
+rm -f "$JOIN_DIR/join.txt" "$JOIN_DIR/startup.sh"
+rmdir "$JOIN_DIR"
+unset JOIN_DIR
vcluster connect private-a100 -n private-a100 -- kubectl label node gpu-node-1 \
node.kubernetes.io/instance-type=a2-highgpu-1g vbilling.vcluster.com/capacity-type=spot
@@ -372,14 +383,25 @@ 6. vBilling
helm install vbilling ../../deploy/helm/vbilling -n vbilling -f 07-vbilling-values.yaml
vcluster connect shared-l4 -n shared-l4 -- kubectl apply -f workloads/shared-inference.yaml
vcluster connect private-a100 -n private-a100 -- kubectl apply -f workloads/private-training.yaml
- vBilling creates the Stripe customers and meters on the first windows. Give the meters prices tagged with the plan, then restart vBilling so it subscribes the tenants:
- export STRIPE_API_KEY="$(cat ~/.stripe-test-key)"
-curl -s https://api.stripe.com/v1/billing/meters -u "$STRIPE_API_KEY:" | grep -E '"(id|event_name)"'
-curl -s https://api.stripe.com/v1/prices -u "$STRIPE_API_KEY:" \
+ vBilling creates the Stripe customers and meters on the first windows. The operator creates the Product and Price. This example enables automatic subscription creation; prices must be tagged with the plan. The following creates a Product and Price together, so run it once for each intended meter and reuse the existing Price on retries. Then restart vBilling so it subscribes the tenants. For paused test subscriptions and repeatable setup helpers, use the GPU example.
+ set +x # keep the key out of shell trace output
+stripe_test() {
+ local key
+ key=$(cat "$HOME/.stripe-test-key") || return 1
+ if [[ ! "$key" =~ ^sk_test_[A-Za-z0-9]+$ ]]; then
+ printf '%s\n' 'Expected a Stripe test secret key.' >&2
+ return 1
+ fi
+ # printf is a shell builtin: only curl's stdin receives the credential.
+ printf 'user = "%s:"\n' "$key" | curl --fail-with-body --silent --show-error --config - "$@"
+}
+stripe_test https://api.stripe.com/v1/billing/meters | grep -E '"(id|event_name)"'
+stripe_test https://api.stripe.com/v1/prices \
-d currency=usd -d unit_amount_decimal=220 \
-d "recurring[interval]=month" -d "recurring[usage_type]=metered" -d "recurring[meter]=<meter id>" \
-d "product_data[name]=A100 40GB GPU-hour" -d "metadata[vbilling_plan]=gpu-cloud"
-kubectl -n vbilling rollout restart statefulset/vbilling
+unset -f stripe_test
+kubectl -n vbilling rollout restart statefulset/vbilling
7. Check it
kubectl -n vbilling port-forward svc/vbilling 8080:8080 &
diff --git a/docs/lago.html b/docs/lago.html
index 5f1dd0c..a492e2b 100644
--- a/docs/lago.html
+++ b/docs/lago.html
@@ -362,7 +362,7 @@ Offboarding
Good to know
GET /api/v1/reconcile covers Stripe and Metronome. For Lago, compare the ledger (GET /api/v1/usage) with Lago's current usage for the subscription.
- - Billing states and enforcement come from Stripe and Metronome webhooks, and are only enabled when one of their webhook secrets is set. A Lago-only setup has no billing states.
+ - Lago sends no billing signals to vBilling. Set billing states from your own automation with
PUT /api/v1/billing-states/{tenant}, for example {"state": "suspended", "reason": "invoice overdue"}.
- Lago also receives
vcluster_gpu_utilization, an informational metric that v0.1 created in Lago and existing plans may still reference. Leave it unpriced.
diff --git a/docs/metronome.html b/docs/metronome.html
index 48b000a..cd1a4b9 100644
--- a/docs/metronome.html
+++ b/docs/metronome.html
@@ -369,8 +369,8 @@ Contracts
When a tenant has a rate card, vBilling creates one contract per tenant and rate card. The rate card is the tenant's vbilling.vcluster.com/plan, or metronome.rateCard when the tenant has none. A UUID is used as the rate card ID, anything else as its alias.
- The contract starts at UTC midnight on the day vBilling creates it, with monthly usage statements on the first of the month.
- - Its uniqueness key is
vbilling-<tenant>-<rate card>, so a retry or a second replica never creates a second contract.
- - Moving a tenant to another rate card creates a contract on the new one. vBilling never ends contracts, so end the old one in Metronome.
+ - Its uniqueness key is
vbilling:<rate card>@<start date>:<tenant hash>, so a retry or a second replica never creates a second contract, and a tenant that loses its state adopts its open contract instead of creating one. Contracts from v0.2.0 (vbilling-<tenant>-<rate card>) are recognized and adopted too.
+ - Moving a tenant to another rate card creates a contract on the new one from UTC midnight and ends the previous vBilling contract at that moment, so usage is never rated twice. A contract that would not have started by then (the plan changed back the same day) is archived instead. Moving back to an earlier rate card starts a new contract on it. Contracts vBilling did not create are never touched.
- Without a rate card, vBilling creates the customer only and you manage its contracts in Metronome.
@@ -378,7 +378,7 @@ Stripe link
With metronome.stripeLink=true, vBilling finds or creates the tenant's Stripe customer the same way the Stripe adapter does (by the metadata vbilling_tenant_id, or the customer pinned with vbilling.vcluster.com/stripe-customer-id). It then creates the Metronome customer with Stripe as its billing provider, delivery method direct_to_billing_provider and your collection method. Contracts carry the same billing provider. No Stripe meters or prices are created in this mode.
- The link is set when vBilling creates the Metronome customer. A customer that already exists in Metronome, found by its alias or pinned by ID, is used as it is: link it to Stripe in Metronome yourself.
+ A customer that already exists in Metronome, found by its alias or pinned by ID, gets the same Stripe configuration if it has none, and is left as it is if it already has one.
Pricing on dimensions
@@ -436,7 +436,7 @@ Delivery
curl -s -X POST localhost:8080/api/v1/destinations/metronome/cursor -d '{"seq": 0}'
Reconciliation
- GET /api/v1/reconcile?destination=metronome recomputes each tenant's usage from the ledger and compares it with what Metronome's usage API (/v1/usage) recorded. Metronome reports whole UTC days only, so from and to must be UTC midnights (YYYY-MM-DD works). Without them, the previous full UTC day is checked. Narrow it with tenant, metric, project, region, sku or cluster.
+ GET /api/v1/reconcile?destination=metronome recomputes each tenant's usage from the ledger and compares it with what Metronome's usage API (/v1/usage) recorded. Metronome reports whole UTC days only, so from and to must be UTC midnights (YYYY-MM-DD works). Without them, the previous full UTC day is checked. Narrow it with tenant or metric. project, region, sku and cluster pick the tenants and metrics with such usage, and each row still compares all of that tenant's usage of the metric, because Metronome reports totals per customer.
curl -s 'localhost:8080/api/v1/reconcile?destination=metronome&from=2026-10-04&to=2026-10-05' | jq
{
"from": "2026-10-04T00:00:00Z",
@@ -454,7 +454,7 @@ Reconciliation
expected is the ledger total minus dead letters, and a row matches within 0.1%. Reconcile once a day has closed and lag_records on /api/v1/destinations is back to 0, so no events are still in flight.
Webhooks and billing state
- Create a webhook in Metronome pointing at https://<your vBilling host>/webhooks/metronome and add its secret to the Secret as webhook-secret. The Service is ClusterIP; expose only the /webhooks/ paths to the internet. Billing-state enforcement turns on when a webhook secret is set.
+ Create a webhook in Metronome pointing at https://<your vBilling host>/webhooks/metronome and add its secret to the Secret as webhook-secret. The Service is ClusterIP; expose only the /webhooks/ paths to the internet. Billing states are always available: Metronome's webhooks set them once webhook-secret is set.
kubectl -n vbilling-system create secret generic vbilling-metronome \
--from-file=api-token=./metronome-token --from-file=webhook-secret=./metronome-webhook-secret \
--dry-run=client -o yaml | kubectl apply -f -
diff --git a/docs/stripe.html b/docs/stripe.html
index 8e4bcba..74951ea 100644
--- a/docs/stripe.html
+++ b/docs/stripe.html
@@ -419,7 +419,7 @@ Automatic subscriptions
- The plan is the tenant's
vbilling.vcluster.com/plan, or billing.defaultPlanCode (vcluster-standard).
- A plan's prices are the active metered prices on vBilling's meters tagged
metadata[vbilling_plan]=<plan>, in the tenant's currency (vbilling.vcluster.com/currency, or billing.currency).
- - A plan holds up to 20 prices, Stripe's limit per subscription.
+ - A plan holds up to 20 prices, Stripe's limit per subscription. A tenant on a larger plan is logged as rejected and not subscribed, and its usage keeps reaching its meters.
- An existing vBilling subscription gets missing prices added. Prices are never removed from it.
- New prices reach new tenants within five minutes, and existing tenants when vBilling restarts or their labels and annotations change.
@@ -456,7 +456,7 @@ Delivery and idempotency
{"replayed":12}
Reconciliation
- GET /api/v1/reconcile recomputes each tenant's usage per metric from the ledger, subtracts what was dead-lettered, and compares it with Stripe's meter event summaries for the tenant's customer, summed over every split meter of the metric. A row matches within 0.1%. The default range is the previous full UTC day; from and to take RFC 3339 times or dates, and tenant and metric narrow it down.
+ GET /api/v1/reconcile recomputes each tenant's usage per metric from the ledger, subtracts what was dead-lettered, and compares it with Stripe's meter event summaries for the tenant's customer, summed over every split meter of the metric. A row matches within 0.1%. The default range is the previous full UTC day; from and to take RFC 3339 times or dates. tenant and metric narrow it down; sku, region, project and cluster pick the tenants and metrics with such usage, and each row still compares all of that tenant's usage of the metric, because Stripe reports totals per customer.
curl -s 'localhost:8080/api/v1/reconcile?destination=stripe&from=2026-10-05&to=2026-10-06' | jq
{
"from": "2026-10-05T00:00:00Z",
@@ -513,7 +513,7 @@ Webhooks and billing states
Events fired with stripe trigger belong to new test customers that vBilling does not manage, so they come back with "applied": false. To see a state change, act on one of vBilling's customers, for example by paying its invoice with one of Stripe's declining test cards.
What a billing state does
- Billing states are on once webhook-secret is set. They are recorded in every mode and shown by GET /api/v1/billing-states, the metrics and the dashboard. enforcement.mode decides what else happens:
+ Stripe's webhooks set billing states once webhook-secret is set. States are recorded in every mode and shown by GET /api/v1/billing-states, the metrics and the dashboard. enforcement.mode decides what else happens:
Mode Effect
@@ -539,7 +539,7 @@ Stripe limits
Meter events at most 35 days old Older events are dead-lettered as older than Stripe's 35-day meter event window. Normal backfills (billing.maxBackfill, default 6h) stay far inside it.
Identifiers unique for at least 24 hours Acknowledged events are never resent. Keep cursor rewinds within 24 hours.
Event names up to 100 characters Longer split meter names end in a hash.
- 20 prices per subscription Automatic subscriptions refuse a plan with more than 20 prices: split it into several plans.
+ 20 prices per subscription Automatic subscriptions refuse a plan with more than 20 prices and log the tenant as rejected, without holding up anyone's usage. Split the plan.
Request rate (sandboxes share 25 requests/s) 20 requests/s for test keys and 200 for live keys, adjustable with maxRPS. 429s are retried.
No pricing on dimensions within a meter Meters are split by splitMetersBy. For pricing on many dimensions, use Metronome.
Customer search is not offered to every account vBilling lists customers instead, still requiring an exact metadata[vbilling_tenant_id] match.
diff --git a/examples/gke-gpu/README.md b/examples/gke-gpu/README.md
index e65b481..11cc95d 100644
--- a/examples/gke-gpu/README.md
+++ b/examples/gke-gpu/README.md
@@ -8,6 +8,8 @@ A complete setup with both vCluster tenancy models on GPUs, metered by vBilling
- **Prometheus** reading DCGM: GKE's exporter on shared nodes, and a Prometheus agent that remote-writes from the private node.
- **vBilling** sending usage to Stripe: customers, one meter per metric and SKU, subscriptions, invoices.
+**For a setup without Prometheus, see [GPU metering with Stripe](../gke-stripe/README.md).** It uses two L4 sharing slots, local vBilling, and paused Stripe test subscriptions. This example uses four slots, Prometheus/DCGM, and automatic subscription creation.
+
The walkthrough is also on the docs site: [Example: GKE with GPUs](https://vclusterlabs-experiments.github.io/vbilling/example-gke.html).
| File | What it is |
@@ -57,14 +59,14 @@ INGEST_IP=$(kubectl -n monitoring get svc prometheus-ingest -o jsonpath='{.statu
## 3. Tenant clusters
```bash
-vcluster platform login https:// # browser login; no key on the command line
+vcluster platform login https://your-platform.example # browser login; no key on the command line
vcluster create shared-l4 -n shared-l4 -f 02-tenant-shared.yaml --connect=false
vcluster create private-a100 -n private-a100 -f 03-tenant-private.yaml --connect=false
```
When the CLI is logged in, `vcluster create` registers each tenant cluster with the Platform, which is what licenses Private Nodes. A tenant cluster created before logging in is registered with `vcluster platform add vcluster private-a100 -n private-a100 --project default`.
-Tell vBilling who the tenants are. The emails use the reserved `.example` domain:
+Optionally set stable customer IDs and display names. Without these annotations, vBilling derives an identity per tenant cluster. The emails below use the reserved `.example` domain:
```bash
kubectl annotate ns shared-l4 vbilling.vcluster.com/tenant=acme-ai "vbilling.vcluster.com/display-name=Acme AI" \
@@ -76,19 +78,28 @@ kubectl annotate ns private-a100 vbilling.vcluster.com/tenant=initech "vbilling.
## 4. A private GPU node
```bash
-vcluster connect private-a100 -n private-a100 -- vcluster token create --expires 2h | grep node/join > join.txt
-awk -v cmd="$(cat join.txt)" '$0 == "JOIN_COMMAND" { print cmd; next } { print }' 04-node-startup.sh > startup.sh
+set -euo pipefail
+set +x # do not trace commands while handling credentials
+umask 077
+JOIN_DIR=$(mktemp -d "/tmp/vbilling-private-node.XXXXXX")
+chmod 700 "$JOIN_DIR"
+vcluster connect private-a100 -n private-a100 -- vcluster token create --expires 2h | grep node/join > "$JOIN_DIR/join.txt"
+# awk reads the token from the file; the token is never a process argument.
+awk 'FNR == NR { cmd = $0; next } $0 == "JOIN_COMMAND" { print cmd; next } { print }' \
+ "$JOIN_DIR/join.txt" 04-node-startup.sh > "$JOIN_DIR/startup.sh"
gcloud compute instances create gpu-node-1 --zone $ZONE --machine-type a2-highgpu-1g \
--provisioning-model SPOT --instance-termination-action STOP --maintenance-policy TERMINATE \
--image-family ubuntu-2204-lts --image-project ubuntu-os-cloud --boot-disk-size 150GB \
- --no-service-account --no-scopes --metadata-from-file startup-script=startup.sh
+ --no-service-account --no-scopes --metadata-from-file "startup-script=$JOIN_DIR/startup.sh"
```
Once `gpu-node-1` is Ready in the tenant cluster, remove the token from the instance and from disk, and label the node so it gets an SKU:
```bash
gcloud compute instances remove-metadata gpu-node-1 --zone $ZONE --keys startup-script
-rm join.txt startup.sh
+rm -f "$JOIN_DIR/join.txt" "$JOIN_DIR/startup.sh"
+rmdir "$JOIN_DIR"
+unset JOIN_DIR
vcluster connect private-a100 -n private-a100 -- kubectl label node gpu-node-1 \
node.kubernetes.io/instance-type=a2-highgpu-1g vbilling.vcluster.com/capacity-type=spot
```
@@ -115,15 +126,26 @@ vcluster connect shared-l4 -n shared-l4 -- kubectl apply -f workloads/shared-inf
vcluster connect private-a100 -n private-a100 -- kubectl apply -f workloads/private-training.yaml
```
-vBilling creates the Stripe customers and meters on the first windows. Give the meters prices tagged with the plan, then restart vBilling so it subscribes the tenants:
+vBilling creates the Stripe customers and meters on the first windows. The operator creates the Product and Price. This example enables automatic subscription creation; prices must be tagged with the plan. The following creates a Product and Price together, so run it once for each intended meter and reuse the existing Price on retries. Then restart vBilling so it subscribes the tenants. For paused test subscriptions and repeatable setup helpers, use [the GPU example](../gke-stripe/README.md).
```bash
-export STRIPE_API_KEY="$(cat ~/.stripe-test-key)"
-curl -s https://api.stripe.com/v1/billing/meters -u "$STRIPE_API_KEY:" | grep -E '"(id|event_name)"'
-curl -s https://api.stripe.com/v1/prices -u "$STRIPE_API_KEY:" \
+set +x # keep the key out of shell trace output
+stripe_test() {
+ local key
+ key=$(cat "$HOME/.stripe-test-key") || return 1
+ if [[ ! "$key" =~ ^sk_test_[A-Za-z0-9]+$ ]]; then
+ printf '%s\n' 'Expected a Stripe test secret key.' >&2
+ return 1
+ fi
+ # printf is a shell builtin: only curl's stdin receives the credential.
+ printf 'user = "%s:"\n' "$key" | curl --fail-with-body --silent --show-error --config - "$@"
+}
+stripe_test https://api.stripe.com/v1/billing/meters | grep -E '"(id|event_name)"'
+stripe_test https://api.stripe.com/v1/prices \
-d currency=usd -d unit_amount_decimal=220 \
-d "recurring[interval]=month" -d "recurring[usage_type]=metered" -d "recurring[meter]=" \
-d "product_data[name]=A100 40GB GPU-hour" -d "metadata[vbilling_plan]=gpu-cloud"
+unset -f stripe_test
kubectl -n vbilling rollout restart statefulset/vbilling
```
diff --git a/examples/gke-stripe/PRIVATE-NODES.md b/examples/gke-stripe/PRIVATE-NODES.md
new file mode 100644
index 0000000..a7e5957
--- /dev/null
+++ b/examples/gke-stripe/PRIVATE-NODES.md
@@ -0,0 +1,215 @@
+# Optional: a real A100 as a private worker
+
+Complete the shared-node [README](README.md) first. This adds a separate paid A100 VM. The shared L4 setup works independently of this extension. Obtain a budget and cleanup deadline before running the creation commands.
+
+The private vCluster's control plane runs on the host GKE CPU node. The A100 joins the private vCluster directly; it is not a GKE worker. Its pods are visible through its own Kubernetes API. vBilling uses a read-only tenant kubeconfig to read them and the node. `PRIVATE_NODE_BILLING=node` measures the whole dedicated node, including idle allocation.
+
+The reference configuration uses an A100-SXM4-40GB, whole-node GPU accounting, and a paused Stripe test subscription. It does not configure MIG or Prometheus. See the [validation scope](README.md#validation-and-limits).
+
+## 1. Prepare Platform access and create the tenant
+
+Use the same shell variables and isolated `KUBECONFIG` as the main README. Choose an A100-capable zone in the **same region** as the existing subnet. A100 capacity varies by zone; check availability before provisioning.
+
+```bash
+export PRIVATE='private-a100'
+export PRIVATE_NS="$RUN_ID-private"
+export PRIVATE_ZONE='us-central1-f'
+export PLATFORM_URL='https://your-vcluster-platform.example'
+export PLATFORM_PROJECT='your-demo-platform-project'
+
+vcluster platform login "$PLATFORM_URL" --config "$RUN_DIR/platform-cli.json"
+vcluster create "$PRIVATE" -n "$PRIVATE_NS" --driver=helm \
+ --chart-version=0.36.1 --connect=false --add=true --project "$PLATFORM_PROJECT" \
+ --config "$RUN_DIR/platform-cli.json" -f "$DEMO/manifests/private-vcluster.yaml"
+kubectl -n "$PRIVATE_NS" rollout status statefulset/"$PRIVATE" --timeout=10m
+kubectl -n "$PRIVATE_NS" get services,pods,pvc
+```
+
+Login uses the browser; never put a Platform key in this command or in Git. Private Nodes needs the correct Platform/license entitlement and permission to register a virtual cluster. A scoped credential may authenticate but still lack registration permission. Chart/CLI 0.36.1 is the reference version; upgrading requires checking Platform compatibility.
+
+Check the selected Platform project. If the create command did not register the tenant in the intended project, add this exact tenant explicitly:
+
+```bash
+vcluster platform add vcluster "$PRIVATE" -n "$PRIVATE_NS" \
+ --project "$PLATFORM_PROJECT" --import-name "$RUN_ID-private" \
+ --config "$RUN_DIR/platform-cli.json"
+```
+
+Do not blindly repeat registration when the tenant is already present. Registration may restart the control plane. Wait for it to become Ready before creating a join token. The shared tenant can also be registered for Platform management, but that is optional and independent of shared-node measurement.
+
+The private API Service should receive an internal load-balancer address in `10.240.0.0/24`. Wait until it has an address. The manifest allows that subnet and uses separate pod/service ranges `172.20.0.0/16` and `172.21.0.0/16`; change the ranges before installation if they overlap your environment.
+
+```bash
+pk() {
+ vcluster connect "$PRIVATE" -n "$PRIVATE_NS" --driver=helm \
+ --background-proxy=false --config "$RUN_DIR/platform-cli.json" -- kubectl "$@"
+}
+pk get nodes
+```
+
+An empty node list at this point is expected: no worker has joined yet.
+
+## 2. Create a short-lived join token and the A100 VM
+
+The token is a credential. Write it only to the private run directory. Do not expose it in logs, screenshots, or shared files. The generated startup script contains that credential until it is removed after the node joins. The preparation helper checks that the join URL points to either this private Service endpoint or your configured Platform host. If it rejects another endpoint, inspect the Platform/private-service configuration; do not weaken the guard.
+
+```bash
+vcluster connect "$PRIVATE" -n "$PRIVATE_NS" --driver=helm \
+ --background-proxy=false --config "$RUN_DIR/platform-cli.json" -- \
+ vcluster token create --config "$RUN_DIR/platform-cli.json" --expires=2h > "$RUN_DIR/private-join.txt"
+
+export PRIVATE_API_HOST="$(kubectl -n "$PRIVATE_NS" get service "$PRIVATE" \
+ -o jsonpath='{.status.loadBalancer.ingress[0].ip}')"
+export PLATFORM_HOST="$(python3 -c 'import os, urllib.parse; print(urllib.parse.urlsplit(os.environ["PLATFORM_URL"]).hostname or "")')"
+python3 "$DEMO/scripts/private-startup.py" \
+ --join-file "$RUN_DIR/private-join.txt" \
+ --allowed-host "$PRIVATE_API_HOST" --allowed-host "$PLATFORM_HOST" \
+ --output "$RUN_DIR/private-startup.sh"
+
+gcloud compute instances create "$RUN_ID-a100" --project "$PROJECT" \
+ --zone "$PRIVATE_ZONE" --machine-type=a2-highgpu-1g \
+ --maintenance-policy=TERMINATE --restart-on-failure \
+ --image-family=ubuntu-2204-lts --image-project=ubuntu-os-cloud \
+ --boot-disk-size=100GB --boot-disk-type=pd-balanced \
+ --network "$NETWORK" --subnet "$SUBNET" \
+ --no-service-account --no-scopes \
+ --labels="purpose=vbilling-demo,run=$RUN_ID" \
+ --metadata-from-file="startup-script=$RUN_DIR/private-startup.sh"
+```
+
+Expected: one A100 VM. It boots and joins the private tenant. Wait for the node to appear and become Ready:
+
+```bash
+pk get nodes -o wide
+pk wait --for=condition=Ready node/"$RUN_ID-a100" --timeout=15m
+pk label node "$RUN_ID-a100" \
+ node.kubernetes.io/instance-type=a2-highgpu-1g \
+ vbilling.vcluster.com/capacity-type=on-demand
+
+gcloud compute instances remove-metadata "$RUN_ID-a100" --project "$PROJECT" \
+ --zone "$PRIVATE_ZONE" --keys=startup-script
+rm "$RUN_DIR/private-join.txt" "$RUN_DIR/private-startup.sh"
+```
+
+If the actual node name differs, obtain it from `pk get nodes` and use that exact name. If it never appears, inspect this VM's startup/serial logs privately, the join token age, Platform/license status, internal endpoint and network access. Never copy a token-bearing startup log into a public issue. Do not repeatedly create new GPU VMs to diagnose the same problem.
+
+Record the private VM alongside the other resources. The cleanup helper must be able to verify this VM's immutable ID before deleting it:
+
+```bash
+python3 "$DEMO/scripts/cleanup-gcp.py" --state "$RUN_DIR/gcp-cleanup-private.json" capture \
+ --project "$PROJECT" --run "$RUN_ID" --zone "$ZONE" --region "$REGION" \
+ --private-zone "$PRIVATE_ZONE"
+```
+
+Use this private-inclusive state file for final cleanup, rather than the earlier shared-only file.
+
+## 3. Install the driver and run CUDA
+
+GKE manages its L4 driver in the main demo. This independent A100 worker needs a driver and device plugin. GPU Operator installs them inside the private tenant.
+
+```bash
+helm repo add nvidia https://helm.ngc.nvidia.com/nvidia
+helm repo update nvidia
+vcluster connect "$PRIVATE" -n "$PRIVATE_NS" --driver=helm \
+ --background-proxy=false --config "$RUN_DIR/platform-cli.json" -- \
+ helm upgrade --install gpu-operator nvidia/gpu-operator --version=v26.7.2 \
+ -n gpu-operator --create-namespace -f "$DEMO/manifests/gpu-operator-values.yaml"
+pk -n gpu-operator get pods
+pk get nodes -o 'custom-columns=NAME:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpu,PRODUCT:.metadata.labels.nvidia\.com/gpu\.product'
+```
+
+Wait for the driver/operator pods to become healthy and for node GPU capacity to report **1**. Compiling the driver can take several minutes. Do not configure MIG for this demo.
+
+```bash
+pk apply -f "$DEMO/manifests/private-workload.yaml"
+pk -n gpu-demo rollout status deployment/private-a100-live --timeout=10m
+pk -n gpu-demo logs deployment/private-a100-live \
+ > "$RUN_DIR/evidence/private-cuda.txt"
+cat "$RUN_DIR/evidence/private-cuda.txt"
+```
+
+Expected: a Ready workload and real A100 identity plus successful CUDA output. The sample runs a calculation and keeps its existing GPU allocation; it is not an ML model or a utilization benchmark. Check the manifest's readiness behavior and logs rather than assuming pod phase proves success.
+
+## 4. Give local vBilling a read-only connection
+
+The private manifest exports `vbilling-kubeconfig` in the host namespace. It is a service-account credential restricted to reading nodes, pods, PVCs, Services and relevant resource/metrics objects. Do not export an admin kubeconfig as the collector credential.
+
+```bash
+python3 "$DEMO/scripts/private-reader.py" \
+ --host-kubeconfig "$KUBECONFIG" --namespace "$PRIVATE_NS" --name "$PRIVATE" \
+ --tenant "$RUN_ID-private" --region "$REGION" --run-dir "$RUN_DIR"
+```
+
+The helper saves private-reader.kubeconfig and private-tenants.json with restrictive permissions, without printing credentials. It preserves TLS verification and uses the private tenant's DNS identity. Because the Mac/laptop cannot directly reach the internal endpoint, start this tunnel in a separate terminal with the same variables:
+
+```bash
+kubectl --kubeconfig "$RUN_DIR/host.kubeconfig" -n "$PRIVATE_NS" \
+ port-forward --address=127.0.0.1 svc/"$PRIVATE" 18446:443
+```
+
+Leave that terminal running. Verify the reader, without displaying the kubeconfig:
+
+```bash
+kubectl --kubeconfig "$RUN_DIR/private-reader.kubeconfig" get nodes -o wide
+kubectl --kubeconfig "$RUN_DIR/private-reader.kubeconfig" auth can-i list nodes
+kubectl --kubeconfig "$RUN_DIR/private-reader.kubeconfig" auth can-i delete nodes
+```
+
+Expected: node Ready; `list nodes` says yes; `delete nodes` says no. Restarting a lost tunnel uses the same port and service. Do not set insecure TLS to make a failed connection work.
+
+Stop the existing foreground vBilling process with Ctrl-C. Keep its data directory. Set the optional tenant file and restart the same Go application:
+
+```bash
+export PRIVATE_TENANTS_FILE="$RUN_DIR/private-tenants.json"
+"$DEMO/scripts/run-local.sh"
+```
+
+The launcher enables tenant API access and whole-node billing for this entry. Host discovery remains restricted to the shared namespace, so the private tenant is added once through its explicit file. This avoids collecting it through two discovery paths.
+
+Expected: a second tenant with ID `$RUN_ID-private`. One full 30-second private window includes:
+
+| Metric | Expected quantity | Basis |
+|---|---|---|
+| `vcluster_gpu_hours` | `0.008333333` | One full A100 × 30/3600 |
+| `vcluster_private_node_hours` | `0.008333333` | One private node × 30/3600 |
+| `vcluster_cpu_core_hours` | `0.1` | 12 node cores × 30/3600 |
+| `vcluster_memory_gb_hours` | Depends on reported node capacity | Kubernetes capacity, not rounded marketing memory |
+
+The GPU SKU should be `NVIDIA-A100-SXM4-40GB`. The L4 and A100 GPU quantities happen to be equal in this example; tenant IDs and SKUs distinguish them. Only the GPU-hour Meter receives a selling Price in the next step. Other recorded capacity does not automatically become a separate charge.
+
+## 5. Add a separate A100 test price
+
+```bash
+stripe_private() {
+ python3 "$DEMO/scripts/stripe-test.py" \
+ --key-file "$STRIPE_KEY_FILE" --state "$RUN_DIR/stripe-private.json" \
+ --run "$RUN_ID" --tenant "$RUN_ID-private" \
+ --event vcluster_gpu_hours__nvidia_a100_sxm4_40gb "$@"
+}
+stripe_private catalog
+stripe_private create-and-pause
+stripe_private inspect
+# After full windows and Stripe aggregation:
+stripe_private preview > "$RUN_DIR/evidence/stripe-private-preview.json"
+```
+
+The helper uses a separate Product/Price/Subscription for the private customer's A100 Meter. Its USD 2.50 rate is an example, not A100 cloud cost. The two-request create/pause caveat and test-only restrictions in the main README apply here too.
+
+## 6. Clean up the private addition and the host
+
+After saving evidence, stop vBilling and the tunnel. Cancel both demo subscriptions using their respective saved states. Do not cancel another tenant's subscription.
+
+```bash
+stripe_private cancel
+stripe_demo cancel
+kubectl delete namespace "$PRIVATE_NS" "$SHARED_NS" --wait=true --timeout=10m
+kubectl get pv
+kubectl get services --all-namespaces
+python3 "$DEMO/scripts/cleanup-gcp.py" --state "$RUN_DIR/gcp-cleanup-private.json" plan
+python3 "$DEMO/scripts/cleanup-gcp.py" --state "$RUN_DIR/gcp-cleanup-private.json" \
+ execute --confirm-run "$RUN_ID" --host-kubeconfig "$KUBECONFIG"
+```
+
+Use the state file that includes the A100. The helper will delete the captured A100 VM and the shared host resources after ownership/dependency checks. Review its residual-resource report, including disks and load balancers. If a VM deadline already deleted the A100, verify that fact rather than creating a replacement.
+
+Finally remove this run's imported virtual-cluster entry from the Platform project through the Platform UI. Select the exact registration created for this run, verify the run ID and confirm the infrastructure has already been removed. Do not delete a shared host registration or another virtual cluster. Retain only the evidence you need in the private run directory; do not commit kubeconfigs, Platform auth files or generated join scripts.
diff --git a/examples/gke-stripe/README.md b/examples/gke-stripe/README.md
new file mode 100644
index 0000000..29e3ca1
--- /dev/null
+++ b/examples/gke-stripe/README.md
@@ -0,0 +1,370 @@
+# GPU metering on GKE with Stripe
+
+This guide configures a shared L4 GPU on GKE, runs two CUDA workloads in a vCluster, and sends allocated GPU-hours to Stripe test mode. The optional [Private Nodes guide](PRIVATE-NODES.md) adds a dedicated A100 worker.
+
+This example uses real, paid GCP hardware and synthetic CUDA workloads. Stripe is test-only. It demonstrates allocated GPU-hours, not GPU utilization or real payment collection.
+
+The workflow covers cluster provisioning, metering configuration, test pricing, usage verification, and cleanup. See [validation and limits](#validation-and-limits) for the scope of testing.
+
+## What you will build
+
+```text
+Your computer GCP
+┌──────────────────────────────────┐ ┌───────────────────────────┐
+│ One Go vBilling application │ ← reads API ─ │ GKE host │
+│ collector → ledger → dispatcher │ │ CPU: shared-l4 API │
+│ ↓ │ │ L4: 2 tenant CUDA pods │
+│ Stripe adapter │ │ 2 slots / 1 real GPU │
+│ API + dashboard: 127.0.0.1:18090 │ └───────────────────────────┘
+└───────────────────────┬──────────┘
+ ↓ quantity, customer, time, event ID
+ Stripe test mode
+ Customer + Meter + Product + Price + Subscription
+```
+
+The collector and Stripe adapter are parts of the same Go process. The shell launcher starts that binary. The Python Stripe helper only sets up test objects and reads previews; it is not the vBilling application.
+
+This simpler example differs from [examples/gke-gpu](../gke-gpu/README.md): two L4 slots instead of four, local vBilling instead of Helm, no Prometheus, no DCGM query and explicit test pricing with auto-subscription disabled. Do not mix their slot counts or expected quantities.
+
+## Before starting
+
+You need installed and authenticated `gcloud` (including the GKE auth plugin), `kubectl`, `helm`, `vcluster`, Go 1.22 or later, Python 3 and a shell. This guide does not install them. You need permission to create GKE, VPC/subnet, a node service account and its IAM binding, and sufficient L4 quota/capacity. Private Nodes also needs A100 quota and a compatible licensed vCluster Platform.
+
+Set a resource budget and cleanup deadline before provisioning. GPU, disk, IP and load-balancer resources cost money while idle. GCP budgets are alerts, not hard spending caps. This guide does not schedule deletion automatically.
+
+Use a dedicated Stripe test account/sandbox and an `sk_test_` secret stored outside this checkout. Do not use a live key. Do not put a key in a command argument, YAML, terminal recording or Git. Never run these scripts with shell tracing (`set -x`).
+
+Run from the repository root:
+
+```bash
+export REPO="$PWD"
+export DEMO="$REPO/examples/gke-stripe"
+export PROJECT='your-demo-project'
+export REGION='us-central1'
+export ZONE='us-central1-a'
+export RUN_ID="vbdemo-$(date -u +%Y%m%d-%H%M%S)"
+export RUN_DIR="$HOME/.local/state/vbilling-demo/$RUN_ID"
+export STRIPE_KEY_FILE="$HOME/.config/vbilling/stripe-test-key"
+export CLIENT_CIDR='your-current-public-ip/32'
+export SHARED='shared-l4'
+export SHARED_NS="$RUN_ID"
+export NETWORK="$RUN_ID-net"
+export SUBNET="$RUN_ID-subnet"
+export NODE_SA="$RUN_ID-node@$PROJECT.iam.gserviceaccount.com"
+umask 077
+mkdir -p "$RUN_DIR/evidence"
+export KUBECONFIG="$RUN_DIR/host.kubeconfig"
+```
+
+Replace the project, Stripe key-file path and public-IP placeholder. Put the key in its file using your secret manager or editor, then `chmod 600 "$STRIPE_KEY_FILE"`. The run directory contains credentials and must remain outside the checkout. Save your nonsecret variable values privately so a new terminal can resume this exact run; do not generate a new `RUN_ID` while resuming.
+
+Read-only preflight:
+
+```bash
+gcloud auth list --filter=status:ACTIVE --format='value(account)'
+gcloud projects describe "$PROJECT" --format='value(projectId)'
+gcloud services list --project "$PROJECT" --enabled \
+ --filter='name:(container.googleapis.com OR compute.googleapis.com)' --format='value(name)'
+gcloud container get-server-config --project "$PROJECT" --zone "$ZONE" \
+ --format='value(validMasterVersions)'
+kubectl version --client
+vcluster version
+helm version --short
+go version
+python3 --version
+```
+
+Both Compute Engine and Kubernetes Engine APIs must be enabled by the project owner. Check quota and current pricing in the GCP console. The reference configuration uses vCluster chart **0.36.1**, tenant Kubernetes **v1.34.1**, GKE **1.34.11-gke.1209000**, and GPU Operator **v26.7.2**. The commands below use GKE's regular channel because an old patch may no longer be offered; check compatibility before provisioning. Do not assume a newer vCluster CLI/chart works with an older Platform.
+
+## 1. Create an isolated GKE host and L4 pool
+
+These commands create paid resources. The CIDRs below must not overlap networks you need to reach. All names belong only to this run. Stop on an error; inspect the failed step before retrying it.
+
+```bash
+gcloud iam service-accounts create "$RUN_ID-node" --project "$PROJECT" \
+ --display-name="vBilling demo $RUN_ID"
+gcloud projects add-iam-policy-binding "$PROJECT" \
+ --member="serviceAccount:$NODE_SA" \
+ --role=roles/container.defaultNodeServiceAccount --condition=None
+
+gcloud compute networks create "$NETWORK" --project "$PROJECT" \
+ --subnet-mode=custom --bgp-routing-mode=regional --description="vbilling-demo:$RUN_ID"
+gcloud compute networks subnets create "$SUBNET" --project "$PROJECT" \
+ --region "$REGION" --network "$NETWORK" --range=10.240.0.0/24 \
+ --secondary-range=pods=10.244.0.0/16,services=10.245.0.0/20 \
+ --enable-private-ip-google-access --description="vbilling-demo:$RUN_ID"
+
+gcloud container clusters create "$RUN_ID" --project "$PROJECT" --zone "$ZONE" \
+ --node-locations "$ZONE" --release-channel regular \
+ --num-nodes=1 --machine-type=e2-standard-2 --image-type=COS_CONTAINERD \
+ --disk-type=pd-balanced --disk-size=50 \
+ --network "$NETWORK" --subnetwork "$SUBNET" --enable-ip-alias \
+ --cluster-secondary-range-name=pods --services-secondary-range-name=services \
+ --enable-master-authorized-networks --master-authorized-networks "$CLIENT_CIDR" \
+ --service-account "$NODE_SA" --scopes=cloud-platform \
+ --workload-pool "$PROJECT.svc.id.goog" --workload-metadata=GKE_METADATA \
+ --addons=GcePersistentDiskCsiDriver --logging=SYSTEM --monitoring=SYSTEM \
+ --no-enable-managed-prometheus --max-surge-upgrade=0 --max-unavailable-upgrade=1 \
+ --labels="purpose=vbilling-demo,run=$RUN_ID"
+
+gcloud container node-pools create l4-shared --project "$PROJECT" \
+ --cluster "$RUN_ID" --zone "$ZONE" --node-locations "$ZONE" \
+ --num-nodes=1 --machine-type=g2-standard-4 \
+ --accelerator=type=nvidia-l4,count=1,gpu-sharing-strategy=time-sharing,max-shared-clients-per-gpu=2,gpu-driver-version=default \
+ --image-type=COS_CONTAINERD --disk-type=pd-balanced --disk-size=50 \
+ --service-account "$NODE_SA" --scopes=cloud-platform --workload-metadata=GKE_METADATA \
+ --node-taints=nvidia.com/gpu=present:NoSchedule \
+ --max-surge-upgrade=0 --max-unavailable-upgrade=1 --reservation-affinity=none
+
+gcloud container clusters get-credentials "$RUN_ID" --project "$PROJECT" --zone "$ZONE"
+kubectl wait --for=condition=Ready nodes --all --timeout=15m
+kubectl get nodes -o wide
+kubectl get nodes -l cloud.google.com/gke-nodepool=l4-shared \
+ -o 'custom-columns=NAME:.metadata.name,GPU-SLOTS:.status.allocatable.nvidia\.com/gpu'
+```
+
+Expected: one Ready CPU node and one Ready GPU node. The GPU node eventually advertises **2** slots. This is one physical L4, not two cards. Driver/device-plugin startup can take additional minutes after the Node first becomes Ready. Do not proceed until both slots are visible.
+
+Capture resource identities for later guarded cleanup:
+
+```bash
+python3 "$DEMO/scripts/cleanup-gcp.py" --state "$RUN_DIR/gcp-cleanup.json" capture \
+ --project "$PROJECT" --run "$RUN_ID" --zone "$ZONE" --region "$REGION"
+```
+
+## 2. Create the shared vCluster
+
+Use the Helm driver and an isolated CLI config. Shared nodes do not need Platform registration for this demo.
+
+```bash
+vcluster create "$SHARED" --namespace "$SHARED_NS" --driver=helm \
+ --chart-version=0.36.1 --add=false --connect=false \
+ --config "$RUN_DIR/vcluster-cli.json" -f "$DEMO/manifests/shared-vcluster.yaml"
+kubectl -n "$SHARED_NS" rollout status statefulset/"$SHARED" --timeout=10m
+kubectl -n "$SHARED_NS" get pods,pvc -o wide
+
+# A predictable demo identity; required by this helper's ownership guard.
+kubectl annotate namespace "$SHARED_NS" \
+ "vbilling.vcluster.com/tenant=$RUN_ID" \
+ 'vbilling.vcluster.com/display-name=Shared L4 example - Stripe test mode'
+
+tk() {
+ vcluster connect "$SHARED" -n "$SHARED_NS" --driver=helm \
+ --background-proxy=false --config "$RUN_DIR/vcluster-cli.json" -- kubectl "$@"
+}
+tk get namespaces
+```
+
+Expected: the vCluster control-plane pod is Ready on the CPU pool, its PVC is Bound, and the tenant API responds. The annotation is optional in the product, but keep it for this recipe: the test helper intentionally accepts only this run ID or this run ID plus `-private`. Without an annotation, the product derives `vcluster--`. The helper accepts explicit run IDs to keep its Stripe operations scoped to this example.
+
+## 3. Run two real CUDA workloads
+
+```bash
+tk apply -f "$DEMO/manifests/shared-workload.yaml"
+tk -n gpu-demo rollout status deployment/gpu-nbody --timeout=10m
+tk -n gpu-demo get pods -o wide
+tk -n gpu-demo logs -l app=gpu-nbody --all-containers=true --prefix --tail=40 \
+ > "$RUN_DIR/evidence/shared-cuda.txt"
+cat "$RUN_DIR/evidence/shared-cuda.txt"
+for POD in $(tk -n gpu-demo get pods -l app=gpu-nbody -o name); do
+ printf '%s\n' "$POD"
+ tk -n gpu-demo exec "$POD" -- nvidia-smi -L
+done
+kubectl -n "$SHARED_NS" get pods -o wide
+```
+
+Expected: two Ready workload pods, real L4 device output, successful CUDA sample output and the same GPU UUID when queried in both pods. The workload fails if CUDA fails; it does not hide errors. The synced pods run on the host's L4 node while the vCluster control plane stays on CPU.
+
+## 4. Build and start the Go application
+
+Build outside the repository so no binary or runtime data becomes a Git change:
+
+```bash
+cd "$REPO"
+go build -o "$RUN_DIR/vbilling" ./cmd/vbilling
+```
+
+The launcher takes environment variables and no arguments. Its required inputs are the binary, host kubeconfig, shared namespace, run directory and Stripe test-key file for this Stripe demo. The launcher keeps the UI on loopback, creates a separate API token in the run directory and uses the measurement settings below. It starts **one Go process**. Leave its terminal running; use another terminal with the same saved variables for the checks and Stripe setup.
+
+```bash
+export HOST_KUBECONFIG="$RUN_DIR/host.kubeconfig"
+export WATCH_NAMESPACE="$SHARED_NS"
+export CLUSTER_NAME="$RUN_ID"
+"$DEMO/scripts/run-local.sh"
+```
+
+Expected startup: `destination: stripe` and `http://127.0.0.1:18090/`. If it says `noop`, the Stripe key-file variable was not set; set the variable and restart to enable Stripe delivery. The ledger is `$RUN_DIR/ledger-data`. The launcher prevents a second process for the same run directory; it cannot detect a different run directory collecting the same cluster.
+
+The application uses these measurement and delivery settings:
+
+| Setting | Example value | Why |
+|---|---|---|
+| `ADAPTERS` | `stripe` | Send records to Stripe |
+| `STRIPE_API_KEY` | Loaded privately from the test key file | Authenticate; never print it |
+| `STRIPE_API_BASE` | `https://api.stripe.com` | Stripe endpoint |
+| `STRIPE_API_VERSION` | `2026-09-30.endive` | Repository's API version |
+| `STRIPE_SPLIT_METERS_BY` | `sku` | Separate L4 and A100 meters |
+| `STRIPE_AUTO_SUBSCRIBE` | `false` | The helper creates test pricing and the subscription |
+| `STRIPE_CANCEL_ON_REMOVE` | `false` | Deleting a tenant does not cancel unrelated billing setup |
+| `COLLECTION_INTERVAL` | `30s` | Collect a usage window every 30 seconds |
+| `CPU_MEMORY_BASIS` | `requests` | Requested CPU/memory, not consumption |
+| `METER_CONTROL_PLANE` | `false` | Exclude vCluster control-plane resources |
+| `PROMETHEUS_URL` | Empty | No Prometheus input for this demo |
+| `WATCH_NAMESPACES` | The shared host namespace only | Limit host discovery |
+| `ENFORCEMENT_MODE` | `observe` | No billing-driven suspension |
+
+The test key authorizes Stripe. The API token protects the local dashboard and HTTP API. They are different secrets; do not paste the Stripe key into the dashboard token field.
+
+In the second terminal, define the read helper and query the application:
+
+```bash
+api() {
+ python3 "$DEMO/scripts/api-read.py" --token-file "$RUN_DIR/api-token" --path "$1"
+}
+curl --fail http://127.0.0.1:18090/healthz
+api /api/v1/status
+api /api/v1/tenants
+api '/api/v1/events?metric=vcluster_gpu_hours&limit=10'
+api '/api/v1/usage?group_by=tenant,sku&metric=vcluster_gpu_hours'
+api /api/v1/ledger/verify
+api /api/v1/destinations
+api '/api/v1/events?metric=vcluster_gpu_hours&limit=10' > "$RUN_DIR/evidence/gpu-events.json"
+```
+
+Open **http://127.0.0.1:18090/**. The dashboard asks for the token in `$RUN_DIR/api-token`. Read that private file locally and enter its value in the dashboard. Do not include the token in logs, screenshots, or shared files.
+
+Expected: `/healthz` responds successfully; `/api/v1/status` reports readiness; the tenant appears; the Stripe destination is healthy; ledger verification passes. After two complete windows, GPU events should show `vcluster_gpu_hours`, SKU `nvidia-l4-timeslice-2`, quantity **0.008333333** per full window:
+
+```text
+2 pods × 0.5 GPU each × 30 seconds / 3,600 = 0.008333333 GPU-hours
+```
+
+An initial or final partial window can be smaller. Kubernetes gives vBilling resource allocation and lifecycle facts. It does not give a GPU utilization percentage in this flow.
+
+## 5. Set the selling price in Stripe test mode
+
+vBilling creates/finds the Customer and Meter. The helper discovers those exact objects and creates a Product and a Price of USD 2.50 per GPU-hour. It then creates a Subscription and pauses collection. The Price points to the Meter; this is how incoming usage connects to money.
+
+```bash
+stripe_demo() {
+ python3 "$DEMO/scripts/stripe-test.py" \
+ --key-file "$STRIPE_KEY_FILE" --state "$RUN_DIR/stripe-shared.json" \
+ --run "$RUN_ID" --tenant "$RUN_ID" \
+ --event vcluster_gpu_hours__nvidia_l4_timeslice_2 "$@"
+}
+stripe_demo catalog
+stripe_demo create-and-pause
+stripe_demo inspect
+```
+
+Expected: only test objects, the correct tenant Customer, the L4 Meter, a Product, a monthly metered Price of 250 US cents and a paused Subscription. The helper keeps created IDs in the private state file, checks ownership and refuses a live key. **Creating and pausing are two separate API requests.** An initial zero-value invoice may exist before the pause. If the pause fails, preserve the state file and rerun `stripe_demo create-and-pause`: it uses the saved Subscription ID to retry the pause. Alternatively run `stripe_demo cancel` to cancel that exact Subscription. Do not delete state or create another subscription.
+
+In the Stripe Dashboard, use test mode and search for the IDs reported by the helper. Meter settings should be SUM, customer payload key `stripe_customer_id`, and value payload key `value`. Product means the service's name; Price means the unit rate; Subscription connects this Customer to that Price.
+
+Wait at least a few full windows **after the Subscription starts**, then:
+
+```bash
+stripe_demo preview > "$RUN_DIR/evidence/stripe-shared-preview.json"
+cat "$RUN_DIR/evidence/stripe-shared-preview.json"
+```
+
+Expected: `livemode=false`, a paused Subscription, fractional GPU-hour usage and a calculated amount. Stripe aggregates asynchronously; a first preview can be empty or zero. `quantity_decimal` is important when quantity is below one; an integer field can show zero. Compare identical customer/meter/time ranges. The total depends on the measured time period; it is not a fixed test expectation.
+
+## 6. Verify usage delivery
+
+Check that both workload pods are Ready and their logs confirm successful CUDA execution on the same physical L4. Compare a complete 30-second GPU-hour event in vBilling with the corresponding Stripe customer and meter. Match the time range when comparing totals; Stripe aggregation can lag behind the local ledger.
+
+The Stripe adapter transforms each GPU-hours record into these request fields:
+
+| Stripe field | Source |
+|---|---|
+| `event_name` | Metric and SKU, such as `vcluster_gpu_hours__nvidia_l4_timeslice_2` |
+| `payload[stripe_customer_id]` | Stripe Customer mapped to the vBilling tenant ID |
+| `payload[value]` | Measured GPU-hour quantity |
+| `timestamp` | Window start in Unix seconds |
+| `identifier` | Stable vBilling event ID used for deduplication |
+
+The adapter creates or finds Customers and Meters. The setup helper separately creates the Product, Price, and paused test Subscription. The Price references the Meter, so Stripe can apply USD 2.50 per GPU-hour to that customer's accumulated usage. The selling price and Price ID are not needed in the vBilling process to deliver usage.
+
+Keep diagnostic exports in `$RUN_DIR/evidence`. The ledger and adapter state remain under `$RUN_DIR/ledger-data`; reuse this directory when restarting the same collector. Do not run overlapping collectors for the same cluster scope.
+
+## 7. Clean up
+
+First save the final evidence and let the latest complete window be delivered. Stop the foreground Go process with Ctrl-C and stop any demo port-forward terminal. Preserve its data and state locally if you need to inspect the run later. Do not stop another person's process with a broad `pkill`.
+
+Cancel only this demo's test subscription after saving its preview:
+
+```bash
+stripe_demo cancel
+```
+
+The helper does not delete shared/reused meters or send an invoice. Test objects retained for audit do not mean GCP resources were cleaned up.
+
+If you used the optional Helm alternative, delete its `vbilling` namespace too and wait for its PVC to disappear. Remove only the namespaces created for this demo, then wait for GKE-managed disks and load-balancer resources to be removed. If Private Nodes was used, also follow its cleanup section.
+
+```bash
+kubectl delete namespace "$SHARED_NS" --wait=true --timeout=10m
+kubectl get pv
+kubectl get services --all-namespaces
+python3 "$DEMO/scripts/cleanup-gcp.py" --state "$RUN_DIR/gcp-cleanup.json" plan
+python3 "$DEMO/scripts/cleanup-gcp.py" --state "$RUN_DIR/gcp-cleanup.json" \
+ execute --confirm-run "$RUN_ID" --host-kubeconfig "$KUBECONFIG"
+```
+
+The cleanup helper checks the captured cloud identities and refuses to delete a GKE cluster while PVs or LoadBalancer Services remain. Do not remove finalizers to make the check pass. Inspect the exact remaining resource and its owner; finish its normal deletion first. The helper deletes only captured demo resources, never all resources in a project. Review its final report for residual resources and inspect the GCP console. Deleting pods alone does not stop GPU VM charges.
+
+Remove any stale Platform registration created for this run separately; see the private guide. Keep key files outside Git and revoke temporary access when the demo is finished.
+
+## Recovery
+
+| Symptom | Next check |
+|---|---|
+| GKE command fails for quota/capacity | Check the error, quota and regional availability. Do not silently create an extra GPU elsewhere; update the run's zone/cleanup record deliberately. |
+| Node Ready but no GPU slots | Wait for the NVIDIA driver/device plugin; inspect system pod status and node allocatable resources. |
+| CUDA pods Pending | Inspect `tk -n gpu-demo describe pod ` and host events. Check the pool labels, taint toleration and two-slot capacity. |
+| Pod Running but CUDA not successful | Inspect logs and readiness. Do not claim GPU execution from pod phase alone. |
+| Mac cannot reach GKE | Verify VPN/current public IP against the explicit master authorized CIDR; change only this run's allowlist if authorized. |
+| No tenant or no usage | Check `WATCH_NAMESPACES`, vCluster labels, optional tenant annotation and ownership of synced pods. Then check application logs. |
+| Dashboard requests a token | Use the generated local API token, never the Stripe key. Keep the token out of logs and shared files. |
+| Stripe customer/meter not found yet | Check destination errors and wait for initial collection/delivery. `catalog` must not invent a customer identity. |
+| Stripe preview has no usage | Wait for aggregation and check the Subscription start, attached Price, Meter, Customer and matching time window. |
+| Local vBilling stopped | Restart the same binary/config with the same data directory; do not start two collectors against the same scope or erase the ledger. |
+| Cleanup reports dependencies | Inspect only this run's PVC/LB/disk resources; wait for normal controller cleanup and rerun the exact captured cleanup plan. |
+
+## Running vBilling in Kubernetes instead
+
+The laptop is just a convenient demo location. The repository's Helm chart starts the same Go application in one StatefulSet pod with persistent storage. Do not run this alongside the local collector against the same workload: that can double-count usage.
+
+```bash
+kubectl create namespace vbilling
+kubectl -n vbilling create secret generic vbilling-stripe \
+ --from-file="api-key=$STRIPE_KEY_FILE"
+# Create an API secret from the local API token file; do not paste a token in YAML.
+# Use the api-token path generated by run-local.sh.
+kubectl -n vbilling create secret generic vbilling-api \
+ --from-file="api-token=$RUN_DIR/api-token"
+helm upgrade --install vbilling "$REPO/deploy/helm/vbilling" -n vbilling \
+ --set 'adapters={stripe}' --set stripe.existingSecret=vbilling-stripe \
+ --set stripe.autoSubscribe=false --set stripe.cancelOnRemove=false \
+ --set 'stripe.splitMetersBy={sku}' --set api.existingSecret=vbilling-api \
+ --set billing.collectionInterval=30s --set metering.cpuMemoryBasis=requests \
+ --set metering.meterControlPlane=false --set prometheus.url='' \
+ --set "watchNamespaces={$SHARED_NS}" --set tenantAPI.enabled=true
+```
+
+This alternative is not an instruction to migrate the active demo's state or start another collector. Plan ledger/state migration and private reader access before switching. Chart settings become pod environment variables; credentials come from Secrets; `/var/lib/vbilling` uses the chart's PVC (default 5Gi). A ConfigMap containing a selling price is not needed for Stripe usage delivery. These are deployment settings, not a full production hardening guide.
+
+## Validation and limits
+
+Validation at source commit `fe58703e2dea63284d95d28a7be71d2a551b40ea` covered shared L4 and private A100 GPU allocation, CUDA execution, ledger integrity, Stripe test usage delivery, and paused invoice previews. The reusable helpers pass 28 offline tests; the Go application builds, and the vCluster and vBilling Helm configurations render successfully. The packaged provisioning workflow has not been rerun end to end in a fresh cloud environment. GPU capacity, API permissions, and version compatibility must be checked for each deployment.
+
+The local runner takes the environment variables shown above and no arguments. Use `python3 scripts/stripe-test.py --help` and `python3 scripts/cleanup-gcp.py --help` from this example directory to inspect the other interfaces. See the [configuration reference](../../docs/configuration.html), [Kubernetes collector](../../internal/metrics/collector.go), [tenant identity discovery](../../internal/discovery/discovery.go), and [Stripe adapter](../../internal/destinations/stripe/adapter.go). This example does not demonstrate DCGM utilization, energy-based pricing, MIG, real invoicing/payment collection, or production availability guarantees.
+
+Run the offline helper checks from the repository root:
+
+```bash
+python3 -m unittest discover -s examples/gke-stripe/tests
+bash -n examples/gke-stripe/scripts/run-local.sh
+```
+
+Before sharing a Git diff, inspect the exact changed files and run the repository's secret scan/checks. Never add the run directory, key files, kubeconfigs, generated join scripts, auth caches, logs or screenshots to Git. A `.gitignore` rule is an extra guard, not proof that a file is safe.
diff --git a/examples/gke-stripe/manifests/gpu-operator-values.yaml b/examples/gke-stripe/manifests/gpu-operator-values.yaml
new file mode 100644
index 0000000..a4838ed
--- /dev/null
+++ b/examples/gke-stripe/manifests/gpu-operator-values.yaml
@@ -0,0 +1,8 @@
+# Repo example's whole-A100 stage. MIG strategy is prepared, but MIG is not
+# enabled until the node is explicitly labeled after basic GPU proof.
+driver: {enabled: true}
+toolkit: {enabled: true}
+mig: {strategy: single}
+dcgmExporter:
+ enabled: true
+ serviceMonitor: {enabled: false}
diff --git a/examples/gke-stripe/manifests/private-vcluster.yaml b/examples/gke-stripe/manifests/private-vcluster.yaml
new file mode 100644
index 0000000..90d2b52
--- /dev/null
+++ b/examples/gke-stripe/manifests/private-vcluster.yaml
@@ -0,0 +1,59 @@
+# Adapted from examples/gke-gpu/03-tenant-private.yaml, vCluster chart 0.36.1.
+# A separate tenant: do not apply to the working shared-l4 release.
+privateNodes:
+ enabled: true
+telemetry:
+ enabled: false
+controlPlane:
+ distro:
+ k8s:
+ version: v1.34.1
+ service:
+ annotations:
+ networking.gke.io/load-balancer-type: Internal
+ spec:
+ type: LoadBalancer
+ loadBalancerSourceRanges:
+ - 10.240.0.0/24
+ statefulSet:
+ scheduling:
+ nodeSelector:
+ cloud.google.com/gke-nodepool: default-pool
+ resources:
+ requests:
+ cpu: 200m
+ memory: 512Mi
+ persistence:
+ volumeClaim:
+ enabled: true
+ size: 5Gi
+ storageClass: standard-rwo
+ retentionPolicy: Delete
+networking:
+ podCIDR: 172.20.0.0/16
+ serviceCIDR: 172.21.0.0/16
+exportKubeConfig:
+ additionalSecrets:
+ - name: vbilling-kubeconfig
+ serviceAccount:
+ name: vbilling-reader
+ namespace: kube-system
+ clusterRole: vbilling-reader
+experimental:
+ deploy:
+ vcluster:
+ manifests: |-
+ apiVersion: rbac.authorization.k8s.io/v1
+ kind: ClusterRole
+ metadata:
+ name: vbilling-reader
+ rules:
+ - apiGroups: [""]
+ resources: [nodes, pods, persistentvolumeclaims, services]
+ verbs: [get, list, watch]
+ - apiGroups: [resource.k8s.io]
+ resources: [resourceclaims, resourceslices]
+ verbs: [get, list]
+ - apiGroups: [metrics.k8s.io]
+ resources: [pods]
+ verbs: [get, list]
diff --git a/examples/gke-stripe/manifests/private-workload.yaml b/examples/gke-stripe/manifests/private-workload.yaml
new file mode 100644
index 0000000..6ebdb17
--- /dev/null
+++ b/examples/gke-stripe/manifests/private-workload.yaml
@@ -0,0 +1,51 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: gpu-demo
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: private-a100-live
+ namespace: gpu-demo
+ labels:
+ app: private-a100-live
+spec:
+ replicas: 1
+ strategy:
+ type: Recreate
+ selector:
+ matchLabels:
+ app: private-a100-live
+ template:
+ metadata:
+ labels:
+ app: private-a100-live
+ spec:
+ automountServiceAccountToken: false
+ terminationGracePeriodSeconds: 10
+ containers:
+ - name: cuda
+ image: nvcr.io/nvidia/k8s/cuda-sample:nbody-cuda11.7.1
+ imagePullPolicy: IfNotPresent
+ command: [sh, -c]
+ args:
+ - |
+ set -eu
+ nvidia-smi -L
+ /cuda-samples/sample -benchmark -numbodies=1024
+ echo PRIVATE_GPU_CUDA_SUCCESS
+ touch /tmp/cuda-success
+ while true; do sleep 300; done
+ readinessProbe:
+ exec:
+ command: [test, -f, /tmp/cuda-success]
+ initialDelaySeconds: 2
+ periodSeconds: 5
+ resources:
+ requests:
+ cpu: 100m
+ memory: 256Mi
+ limits:
+ nvidia.com/gpu: 1
+ memory: 2Gi
diff --git a/examples/gke-stripe/manifests/shared-vcluster.yaml b/examples/gke-stripe/manifests/shared-vcluster.yaml
new file mode 100644
index 0000000..510802d
--- /dev/null
+++ b/examples/gke-stripe/manifests/shared-vcluster.yaml
@@ -0,0 +1,37 @@
+# vCluster chart 0.36.1. Only synthetic demo workloads belong in this tenant.
+# Shared host scheduling: GPU workload selectors remain separate from the
+# CPU-only control-plane selector below.
+privateNodes:
+ enabled: false
+telemetry:
+ enabled: false
+sync:
+ toHost:
+ pods:
+ enabled: true
+ fromHost:
+ nodes:
+ enabled: false
+ syncBackChanges: false
+controlPlane:
+ distro:
+ k8s:
+ # Public registry HEAD verified this tag exists; matches host/client minor.
+ version: v1.34.1
+ advanced:
+ virtualScheduler:
+ enabled: false
+ statefulSet:
+ scheduling:
+ nodeSelector:
+ cloud.google.com/gke-nodepool: default-pool
+ resources:
+ requests:
+ cpu: 200m
+ memory: 256Mi
+ persistence:
+ volumeClaim:
+ enabled: true
+ size: 5Gi
+ storageClass: standard-rwo
+ retentionPolicy: Delete
diff --git a/examples/gke-stripe/manifests/shared-workload.yaml b/examples/gke-stripe/manifests/shared-workload.yaml
new file mode 100644
index 0000000..5f9ae2b
--- /dev/null
+++ b/examples/gke-stripe/manifests/shared-workload.yaml
@@ -0,0 +1,50 @@
+# CUDA workload for shared GPU allocation on GKE.
+# Two slots on one real L4 with exactly two time-sharing clients per GPU.
+# This is a CUDA benchmark, not model inference.
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: gpu-demo
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: gpu-nbody
+ namespace: gpu-demo
+spec:
+ replicas: 2
+ selector:
+ matchLabels:
+ app: gpu-nbody
+ template:
+ metadata:
+ labels:
+ app: gpu-nbody
+ spec:
+ automountServiceAccountToken: false
+ nodeSelector:
+ cloud.google.com/gke-nodepool: l4-shared
+ cloud.google.com/gke-accelerator: nvidia-l4
+ cloud.google.com/gke-gpu-sharing-strategy: time-sharing
+ cloud.google.com/gke-max-shared-clients-per-gpu: "2"
+ tolerations:
+ - key: nvidia.com/gpu
+ operator: Exists
+ effect: NoSchedule
+ containers:
+ - name: benchmark
+ image: nvcr.io/nvidia/k8s/cuda-sample:nbody-cuda11.7.1
+ command: [sh, -ec]
+ args:
+ - |
+ nvidia-smi -L
+ while true; do
+ /cuda-samples/sample -benchmark -numbodies=32768
+ sleep 5
+ done
+ resources:
+ requests:
+ cpu: 500m
+ memory: 1Gi
+ limits:
+ nvidia.com/gpu: 1
diff --git a/examples/gke-stripe/scripts/api-read.py b/examples/gke-stripe/scripts/api-read.py
new file mode 100755
index 0000000..bba3088
--- /dev/null
+++ b/examples/gke-stripe/scripts/api-read.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python3
+"""Read the local demo API without putting its token in shell history or argv."""
+import argparse
+import json
+import sys
+import urllib.error
+import urllib.request
+from demo_common import GuardError, private_read, require
+class NoRedirect(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ raise GuardError('Redirect refused')
+def main():
+ p=argparse.ArgumentParser(description=__doc__)
+ p.add_argument('--token-file', required=True)
+ p.add_argument('--path', default='/api/v1/status')
+ a=p.parse_args()
+ require(a.path.startswith('/api/v1/') and not any(s in a.path for s in (':','..','\r','\n','#')), 'Only a local vBilling API path is supported')
+ token=private_read(a.token_file)
+ req=urllib.request.Request('http://127.0.0.1:18090'+a.path, headers={'Authorization':'Bearer '+token})
+ with urllib.request.build_opener(NoRedirect).open(req, timeout=30) as response:
+ print(json.dumps(json.load(response), indent=2))
+if __name__=='__main__':
+ try: main()
+ except (GuardError, OSError, ValueError, urllib.error.URLError):
+ print('Local API read failed; raw response and token suppressed.', file=sys.stderr); sys.exit(2)
diff --git a/examples/gke-stripe/scripts/cleanup-gcp.py b/examples/gke-stripe/scripts/cleanup-gcp.py
new file mode 100755
index 0000000..ff2f069
--- /dev/null
+++ b/examples/gke-stripe/scripts/cleanup-gcp.py
@@ -0,0 +1,159 @@
+#!/usr/bin/env python3
+"""Exact-resource cleanup for a dedicated GPU example; no wildcard deletions.
+
+Capture first records immutable resource IDs. Plan is offline. Execute rechecks
+ownership and IDs, requires the exact run confirmation, and refuses cluster
+deletion while Kubernetes PVs or LoadBalancer Services remain. It never deletes
+unrecorded firewalls, disks, IPs, or other resources to force network deletion.
+"""
+import argparse
+import json
+import re
+import subprocess
+import sys
+from demo_common import GuardError, outside_git, private_read, require, run_id, save_json
+
+class GCloud:
+ def __init__(self, project): self.project=project
+ def run(self, args, absent_ok=False):
+ proc=subprocess.run(['gcloud',*args,'--project='+self.project,'--quiet','--format=json'],capture_output=True,text=True)
+ if proc.returncode:
+ # IAM describe can report an ambiguous permission error for a deleted
+ # service account. Never interpret that error itself as absence: a
+ # separate successful, exact-email list must independently confirm it.
+ denied = re.search(r'\bPERMISSION_DENIED\b', proc.stderr)
+ if absent_ok and denied and len(args) == 4 and args[:3] == ['iam','service-accounts','describe'] and 'or it may not exist' in proc.stderr:
+ email = args[3]
+ require(re.fullmatch(r'[a-z][a-z0-9-]{4,29}@' + re.escape(self.project) + r'\.iam\.gserviceaccount\.com', email),
+ 'Unexpected service account identity in absence check')
+ rows = self.run(['iam','service-accounts','list','--filter=email=' + email])
+ require(isinstance(rows, list) and all(isinstance(row, dict) and isinstance(row.get('email'), str) for row in rows),
+ 'Service account list did not return a valid inventory')
+ if not any(row['email'] == email for row in rows): return None
+ # Other permission/connection errors remain failures even if their
+ # diagnostic text happens to include words such as "not found".
+ if absent_ok and not denied and re.search(r'\bNOT_FOUND\b|\bNotFound\b|was not found|could not be found|\bcode=404,\s*message=Not found:',proc.stderr): return None
+ raise GuardError('GCP command failed: '+ ' '.join(args[:4]) + '. Cleanup stopped; earlier successful deletions may already have completed. Inspect this exact resource.')
+ return json.loads(proc.stdout) if proc.stdout.strip() else {}
+
+def specs(state):
+ run=state['run']; zone=state['zone']; region=state['region']; project=state['project']
+ rows={
+ 'cluster': ['container','clusters','describe',run,'--zone='+zone],
+ 'network': ['compute','networks','describe',run+'-net'],
+ 'subnet': ['compute','networks','subnets','describe',run+'-subnet','--region='+region],
+ 'service_account': ['iam','service-accounts','describe',run+'-node@'+project+'.iam.gserviceaccount.com'],
+ }
+ if state.get('private_zone'): rows['private_vm']=['compute','instances','describe',run+'-a100','--zone='+state['private_zone']]
+ return rows
+
+def identity(kind, value, state):
+ run=state['run']; marker='vbilling-demo:'+run
+ if kind=='cluster':
+ require(value.get('name')==run and value.get('location')==state['zone'], 'Cluster identity mismatch')
+ labels=value.get('resourceLabels') or {}
+ require(labels.get('run')==run and labels.get('purpose')=='vbilling-demo', 'Cluster ownership labels differ')
+ require(value.get('network','').split('/')[-1]==run+'-net' and value.get('subnetwork','').split('/')[-1]==run+'-subnet', 'Cluster network differs')
+ elif kind in ('network','subnet'):
+ suffix='-net' if kind=='network' else '-subnet'
+ require(value.get('name')==run+suffix and value.get('description')==marker, 'Network/subnet ownership differs')
+ if kind=='subnet': require(value.get('network','').endswith('/networks/'+run+'-net'),'Subnet network differs')
+ elif kind=='service_account':
+ require(value.get('email')==run+'-node@'+state['project']+'.iam.gserviceaccount.com' and
+ value.get('displayName')=='vBilling demo '+run, 'Service account identity differs')
+ else:
+ labels=value.get('labels') or {}
+ require(value.get('name')==run+'-a100' and labels.get('run')==run and labels.get('purpose')=='vbilling-demo', 'Private VM ownership differs')
+ require(value.get('zone','').endswith('/zones/'+state['private_zone']), 'Private VM zone differs')
+ disks=value.get('disks') or []
+ require(len(disks)==1 and disks[0].get('boot') and disks[0].get('autoDelete'), 'Private VM must have only its auto-delete boot disk')
+ interfaces=value.get('networkInterfaces') or []
+ require(len(interfaces)==1 and interfaces[0].get('network','').endswith('/networks/'+run+'-net'), 'Private VM network differs')
+ immutable=value.get('uniqueId') if kind=='service_account' else value.get('id')
+ require(bool(immutable), 'Missing immutable provider ID for '+kind)
+ return str(immutable)
+
+def capture(args):
+ run_id(args.run)
+ require(re.fullmatch(r'[a-z][a-z0-9-]{4,61}[a-z0-9]',args.project), 'Invalid GCP project ID')
+ for location in (args.zone,args.region,args.private_zone):
+ if location: require(re.fullmatch(r'[a-z0-9-]+',location),'Invalid location')
+ state={k:getattr(args,k) for k in ('project','run','zone','region','private_zone')}
+ client=GCloud(state['project']); state['ids']={}
+ for kind,argv in specs(state).items():
+ value=client.run(argv)
+ state['ids'][kind]=identity(kind,value,state)
+ save_json(args.state,state,exclusive=True)
+ print(json.dumps({'captured':state,'note':'Read-only capture. No deletion scheduled.'},indent=2))
+
+def load(path):
+ state=json.loads(private_read(path)); run_id(state['run'])
+ require(set(state['ids'])==set(specs(state)), 'Cleanup manifest resources are incomplete or unexpected')
+ return state
+
+def kread(kubeconfig,args):
+ proc=subprocess.run(['kubectl','--kubeconfig',kubeconfig,'--request-timeout=30s',*args],capture_output=True,text=True)
+ require(proc.returncode==0,'Kubernetes preflight failed; cluster was not deleted')
+ return json.loads(proc.stdout)
+
+def ensure_drained(cluster, kubeconfig):
+ private_read(kubeconfig)
+ cfg=kread(kubeconfig,['config','view','--minify','-o','json'])
+ endpoint=cfg['clusters'][0]['cluster']['server']
+ require(endpoint.rstrip('/')=='https://'+cluster['endpoint'], 'Kubeconfig does not point to the verified cluster')
+ require(kread(kubeconfig,['get','pv','-o','json']).get('items')==[], 'Persistent volumes remain. Delete only demo namespaces and wait for their disks to disappear before cleanup')
+ services=kread(kubeconfig,['get','services','--all-namespaces','-o','json'])['items']
+ require(not any(s['spec'].get('type')=='LoadBalancer' for s in services), 'LoadBalancer Services remain. Delete only demo namespaces and wait for load balancers to disappear before cleanup')
+
+def execute(state, confirm, kubeconfig):
+ require(confirm==state['run'],'--confirm-run must exactly match the captured run')
+ client=GCloud(state['project']); current={}
+ # Validate every extant recorded object before the first deletion.
+ for kind,argv in specs(state).items():
+ value=client.run(argv,absent_ok=True); current[kind]=value
+ if value is not None:
+ require(identity(kind,value,state)==state['ids'][kind], 'Resource was recreated or replaced: '+kind)
+ if current['cluster'] is not None:
+ require(bool(kubeconfig),'--host-kubeconfig is required for Kubernetes preflight')
+ ensure_drained(current['cluster'],kubeconfig)
+ run=state['run']
+ if current.get('private_vm') is not None:
+ client.run(['compute','instances','delete',run+'-a100','--zone='+state['private_zone'],'--delete-disks=boot'])
+ if current['cluster'] is not None:
+ client.run(['container','clusters','delete',run,'--zone='+state['zone']])
+ if current['subnet'] is not None:
+ client.run(['compute','networks','subnets','delete',run+'-subnet','--region='+state['region']])
+ if current['network'] is not None:
+ client.run(['compute','networks','delete',run+'-net'])
+ if current['service_account'] is not None:
+ email=run+'-node@'+state['project']+'.iam.gserviceaccount.com'
+ # The dedicated SA and exact role are the only IAM changes in this recipe.
+ policy=client.run(['projects','get-iam-policy',state['project']])
+ member='serviceAccount:'+email; role='roles/container.defaultNodeServiceAccount'
+ if any(b.get('role')==role and member in b.get('members',[]) and not b.get('condition') for b in policy.get('bindings',[])):
+ client.run(['projects','remove-iam-policy-binding',state['project'],'--member='+member,'--role='+role,'--condition=None'])
+ client.run(['iam','service-accounts','delete',email])
+ missing=[]
+ for kind,argv in specs(state).items():
+ require(client.run(argv,absent_ok=True) is None,'Deletion not confirmed: '+kind)
+ missing.append(kind)
+ return {'confirmed_absent':missing,'note':'Only captured resources checked. Review the project resource inventory for orphaned disks/IPs; no sweeping delete was performed.'}
+
+def main():
+ p=argparse.ArgumentParser(description=__doc__)
+ p.add_argument('--state',required=True)
+ sub=p.add_subparsers(dest='action',required=True)
+ c=sub.add_parser('capture')
+ for key in ('project','run','zone','region'): c.add_argument('--'+key,required=True)
+ c.add_argument('--private-zone')
+ sub.add_parser('plan')
+ e=sub.add_parser('execute'); e.add_argument('--confirm-run',required=True); e.add_argument('--host-kubeconfig')
+ a=p.parse_args(); outside_git(a.state)
+ if a.action=='capture': return capture(a)
+ state=load(a.state)
+ if a.action=='plan': print(json.dumps({'plan':state,'deletes':list(specs(state)),'executed':False},indent=2))
+ else: print(json.dumps(execute(state,a.confirm_run,a.host_kubeconfig),indent=2))
+if __name__=='__main__':
+ try: main()
+ except GuardError as e: print('Stopped: '+str(e),file=sys.stderr); sys.exit(2)
+ except (OSError,ValueError,KeyError,TypeError): print('Invalid state or command output; no broad cleanup attempted.',file=sys.stderr); sys.exit(2)
diff --git a/examples/gke-stripe/scripts/demo_common.py b/examples/gke-stripe/scripts/demo_common.py
new file mode 100755
index 0000000..0b2cf83
--- /dev/null
+++ b/examples/gke-stripe/scripts/demo_common.py
@@ -0,0 +1,60 @@
+"""Small, stdlib-only safety helpers for GPU example administration (not vBilling)."""
+import json
+import os
+from pathlib import Path
+import re
+import stat
+import subprocess
+import tempfile
+
+class GuardError(Exception):
+ pass
+
+def require(ok, message):
+ if not ok:
+ raise GuardError(message)
+
+def outside_git(path):
+ path = Path(path).expanduser().resolve()
+ for parent in (path, *path.parents):
+ require(not (parent / '.git').exists(), 'State and credential files must be outside every Git checkout')
+ return path
+
+def private_read(path):
+ path = outside_git(path)
+ require(path.is_file(), 'Required private file is missing')
+ mode = path.stat()
+ require(mode.st_uid == os.getuid() and stat.S_IMODE(mode.st_mode) & 0o077 == 0,
+ 'Private file must be owned by you and have mode 0600 (or 0400)')
+ return path.read_text().strip()
+
+def save_json(path, value, exclusive=False):
+ path = outside_git(path)
+ require(path.parent.is_dir(), 'Create the private state directory first')
+ require(not exclusive or not path.exists(), 'State already exists; preserve it and use the same run')
+ fd, temp = tempfile.mkstemp(prefix='.' + path.name + '.', dir=path.parent)
+ try:
+ with os.fdopen(fd, 'w') as f:
+ json.dump(value, f, indent=2)
+ f.write('\n')
+ f.flush()
+ os.fsync(f.fileno())
+ os.replace(temp, path)
+ finally:
+ if os.path.exists(temp):
+ os.unlink(temp)
+
+def run_id(value):
+ require(re.fullmatch(r'vbdemo-[0-9]{8}-[0-9]{6}', value) is not None,
+ 'Run ID must be vbdemo-YYYYMMDD-HHMMSS')
+ return value
+
+def provider_id(value, prefix):
+ require(isinstance(value, str) and re.fullmatch(re.escape(prefix) + r'[A-Za-z0-9_]+', value),
+ 'Provider returned an invalid object ID')
+ return value
+
+def command(argv):
+ result = subprocess.run(argv, capture_output=True, text=True)
+ require(result.returncode == 0, 'Command failed; output suppressed to protect credentials: ' + argv[0])
+ return result.stdout
diff --git a/examples/gke-stripe/scripts/private-reader.py b/examples/gke-stripe/scripts/private-reader.py
new file mode 100755
index 0000000..022a793
--- /dev/null
+++ b/examples/gke-stripe/scripts/private-reader.py
@@ -0,0 +1,50 @@
+#!/usr/bin/env python3
+"""Export only the named read-only tenant Secret into private local files."""
+import argparse
+import base64
+import json
+import os
+from pathlib import Path
+import sys
+import tempfile
+from demo_common import GuardError, command, outside_git, private_read, require, run_id, save_json
+
+def main():
+ p=argparse.ArgumentParser(description=__doc__)
+ for key in ('host-kubeconfig','namespace','name','tenant','region','run-dir'): p.add_argument('--'+key, required=True)
+ a=p.parse_args()
+ root=outside_git(a.run_dir)
+ require(a.tenant.endswith('-private'), 'Use the run ID-private tenant')
+ run_id(a.tenant[:-8]); require(a.namespace == a.tenant, 'Private namespace must match private tenant')
+ require(a.name == 'private-a100', 'This demo exports the private-a100 reader only')
+ private_read(a.host_kubeconfig)
+ out=root/'private-reader.kubeconfig'; tenant_file=root/'private-tenants.json'
+ require(root.is_dir() and not out.exists() and not tenant_file.exists(), 'Private output must be new; preserve existing reader files')
+ secret=json.loads(command(['kubectl','--kubeconfig',a.host_kubeconfig,'-n',a.namespace,'get','secret','vbilling-kubeconfig','-o','json']))
+ raw=next((secret.get('data',{}).get(k) for k in ('config','kubeconfig','value') if secret.get('data',{}).get(k)), None)
+ require(bool(raw), 'Reader Secret has no kubeconfig')
+ fd,temp=tempfile.mkstemp(prefix='.reader-',dir=root)
+ try:
+ with os.fdopen(fd,'wb') as f: f.write(base64.b64decode(raw,validate=True))
+ cfg=json.loads(command(['kubectl','--kubeconfig',temp,'config','view','--raw','-o','json']))
+ finally: os.unlink(temp)
+ contexts={c['name']:c['context'] for c in cfg['contexts']}
+ target=contexts[cfg['current-context']]['cluster']
+ matched=False
+ for row in cfg['clusters']:
+ if row['name']==target:
+ row['cluster']['server']='https://127.0.0.1:18446'
+ row['cluster']['tls-server-name']=a.name+'.'+a.namespace
+ row['cluster'].pop('insecure-skip-tls-verify',None)
+ require(bool(row['cluster'].get('certificate-authority-data')), 'Reader needs a trusted CA')
+ matched=True
+ require(matched, 'Reader cluster is missing')
+ save_json(out,cfg,exclusive=True)
+ save_json(tenant_file,[{'name':a.name,'kubeconfig':str(out),'tenant':a.tenant,
+ 'displayName':'Private A100 example','metadata':{'plan':a.tenant,'region':a.region}}],exclusive=True)
+ print(json.dumps({'reader_file':str(out),'tenant_file':str(tenant_file),'server':'https://127.0.0.1:18446','credentials_printed':False}))
+
+if __name__=='__main__':
+ try: main()
+ except GuardError as e: print('Stopped: '+str(e),file=sys.stderr); sys.exit(2)
+ except (OSError, ValueError, KeyError): print('Reader export failed; raw credentials suppressed.',file=sys.stderr); sys.exit(2)
diff --git a/examples/gke-stripe/scripts/private-startup.py b/examples/gke-stripe/scripts/private-startup.py
new file mode 100755
index 0000000..aaf6907
--- /dev/null
+++ b/examples/gke-stripe/scripts/private-startup.py
@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+"""Validate a captured join command and save a private VM startup script.
+
+This does not create a token or VM. Capture `vcluster token create --expires 1h`
+output to a 0600 file outside Git, then pass only its path here. Never use set -x.
+"""
+import argparse
+import ipaddress
+import os
+import re
+import sys
+import urllib.parse
+from demo_common import GuardError, outside_git, private_read, require
+
+def render(raw, allowed_host):
+ lines=[line.strip() for line in raw.splitlines() if line.strip().startswith('curl ')]
+ require(len(lines)==1,'Expected exactly one join command')
+ match=re.fullmatch(r'curl -fsSLk "(https://[^"\s]+/node/join\?token=[A-Za-z0-9.%_-]+)" \| sh -',lines[0])
+ require(match is not None,'Unexpected join command; raw output suppressed')
+ url=urllib.parse.urlsplit(match.group(1))
+ allowed_hosts=[allowed_host] if isinstance(allowed_host,str) else allowed_host
+ require(url.hostname in allowed_hosts and not url.username and not url.password and not url.fragment,
+ 'Join endpoint differs from the explicitly allowed host')
+ return '''#!/bin/bash
+set +x
+set -euo pipefail
+umask 077
+exec >> /var/log/vcluster-join.log 2>&1
+[ -f /var/lib/vcluster-joined ] && exit 0
+'''+lines[0]+'''
+touch /var/lib/vcluster-joined
+echo PRIVATE_NODE_JOIN_COMPLETE
+'''
+
+def main():
+ p=argparse.ArgumentParser(description=__doc__)
+ p.add_argument('--join-file',required=True)
+ p.add_argument('--allowed-host',required=True,action='append',help='Exact host/IP observed in the private endpoint or Platform URL')
+ p.add_argument('--output',required=True)
+ a=p.parse_args()
+ out=outside_git(a.output); require(not out.exists(),'Output exists; do not overwrite token material')
+ result=render(private_read(a.join_file),a.allowed_host)
+ fd=os.open(out,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
+ with os.fdopen(fd,'w') as f: f.write(result)
+ print('Private startup file prepared; remove it and VM startup metadata after the node joins.')
+if __name__=='__main__':
+ try: main()
+ except GuardError as e: print('Stopped: '+str(e),file=sys.stderr); sys.exit(2)
+ except (OSError,ValueError): print('Startup preparation failed; raw credentials suppressed.',file=sys.stderr); sys.exit(2)
diff --git a/examples/gke-stripe/scripts/run-local.sh b/examples/gke-stripe/scripts/run-local.sh
new file mode 100755
index 0000000..2842e18
--- /dev/null
+++ b/examples/gke-stripe/scripts/run-local.sh
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+# Local demo launch only. The compiled Go binary is the actual vBilling app.
+# Keep the foreground terminal open; Ctrl-C stops the collector cleanly.
+set +x
+set -euo pipefail
+umask 077
+if [[ $# -ne 0 ]]; then
+ echo 'Configure RUN_DIR, HOST_KUBECONFIG, WATCH_NAMESPACE, CLUSTER_NAME, REGION, and optionally STRIPE_KEY_FILE, PRIVATE_TENANTS_FILE; then run this script without arguments.' >&2
+ exit 2
+fi
+: "${RUN_DIR:?Private state directory outside the repository is required}"
+: "${HOST_KUBECONFIG:?Path to isolated host kubeconfig required}"
+: "${WATCH_NAMESPACE:?Exact shared tenant namespace required}"
+: "${CLUSTER_NAME:?Demo GKE cluster name required}"
+: "${REGION:?GCP region required}"
+SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
+BINARY=${VBILLING_BINARY:-$RUN_DIR/vbilling}
+[[ -x "$BINARY" ]] || { echo "Build first: go build -o \"\$RUN_DIR/vbilling\" ./cmd/vbilling" >&2; exit 2; }
+# Validate private files without printing their values. Generate the API token
+# outside Git; it is entered into the local dashboard, not sent to Stripe.
+python3 - "$SCRIPT_DIR" "$RUN_DIR" "$HOST_KUBECONFIG" "${STRIPE_KEY_FILE:-}" "${PRIVATE_TENANTS_FILE:-}" <<'PY'
+import os, pathlib, re, secrets, sys
+sys.path.insert(0, sys.argv[1])
+from demo_common import GuardError, outside_git, private_read, require
+try:
+ root=outside_git(sys.argv[2]); require(root.is_dir(), 'Create RUN_DIR first')
+ require(root.stat().st_mode & 0o077 == 0, 'RUN_DIR must have mode 0700')
+ private_read(sys.argv[3])
+ if sys.argv[4]:
+ key=private_read(sys.argv[4]); require(re.fullmatch(r'sk_test_[A-Za-z0-9]+', key) and len(key)>16, 'Only a Stripe sk_test_ secret is accepted')
+ if sys.argv[5]: private_read(sys.argv[5])
+ token=root/'api-token'
+ if not token.exists():
+ fd=os.open(token, os.O_WRONLY|os.O_CREAT|os.O_EXCL, 0o600)
+ with os.fdopen(fd,'w') as f: f.write(secrets.token_urlsafe(32)+'\n')
+ require(bool(private_read(token)), 'API token is empty')
+except (GuardError, OSError):
+ print('Private path, permissions, or test-key validation failed; no values printed.', file=sys.stderr); sys.exit(2)
+PY
+LOCK="$RUN_DIR/collector.lock"
+mkdir "$LOCK" 2>/dev/null || { echo 'Collector lock exists. Stop the recorded collector; remove the lock only after verifying it is stopped.' >&2; exit 2; }
+printf '%s\n' "$$" > "$LOCK/launcher.pid"
+child=''
+cleanup() { if [[ -n "$child" ]] && kill -0 "$child" 2>/dev/null; then kill -TERM "$child" 2>/dev/null || true; wait "$child" || true; fi; rm -f "$LOCK/launcher.pid" "$LOCK/collector.pid"; rmdir "$LOCK"; }
+trap cleanup EXIT
+trap 'exit 130' INT
+trap 'exit 143' TERM
+export KUBECONFIG="$HOST_KUBECONFIG" DATA_DIR="$RUN_DIR/ledger-data"
+export WATCH_NAMESPACES="$WATCH_NAMESPACE" TENANT_SOURCE=cluster
+export TENANT_API=false TENANT_ADMIN_FALLBACK=false TENANT_CLUSTERS_FILE=''
+export WATCH_DELETIONS=false CPU_MEMORY_BASIS=requests METER_CONTROL_PLANE=false
+export ENFORCEMENT_MODE=observe PROMETHEUS_URL='' PROMETHEUS_METRICS_FILE='' CUSTOM_METRICS_FILE=''
+export METER_BY_NAMESPACE=false DEDICATED_NODE_LABEL='' STRIPE_WEBHOOK_SECRET=''
+unset ADAPTER TENANT_EXCLUDE_NAMESPACES GPU_RESOURCES GPU_SKU_LABEL CAPACITY_TYPE_LABEL
+export COLLECTION_INTERVAL=30s RECONCILE_INTERVAL=15s MAX_BACKFILL=15m
+export CLUSTER_NAME REGION DEFAULT_PLAN_CODE="${DEMO_PLAN:-$WATCH_NAMESPACE}" BILLING_CURRENCY=USD
+export LISTEN_ADDR=127.0.0.1:18090
+API_TOKEN=$(cat "$RUN_DIR/api-token")
+export API_TOKEN INGEST_TOKEN=''
+export ADAPTERS=noop STRIPE_API_KEY=''
+export STRIPE_API_BASE=https://api.stripe.com STRIPE_API_VERSION=2026-09-30.endive
+export STRIPE_AUTO_SUBSCRIBE=false STRIPE_CANCEL_ON_REMOVE=false STRIPE_SPLIT_METERS_BY=sku STRIPE_MAX_RPS=5
+if [[ -n "${PRIVATE_TENANTS_FILE:-}" ]]; then
+ export TENANT_API=true PRIVATE_NODE_BILLING=node TENANT_CLUSTERS_FILE="$PRIVATE_TENANTS_FILE"
+fi
+if [[ -n "${STRIPE_KEY_FILE:-}" ]]; then
+ STRIPE_API_KEY=$(cat "$STRIPE_KEY_FILE")
+ export STRIPE_API_KEY ADAPTERS=stripe
+fi
+printf 'Starting the Go vBilling application at http://127.0.0.1:18090/; destination: %s\n' "$ADAPTERS"
+printf 'Dashboard token file: %s/api-token (keep private). Ledger: %s\n' "$RUN_DIR" "$DATA_DIR"
+"$BINARY" &
+child=$!
+printf '%s\n' "$child" > "$LOCK/collector.pid"
+wait "$child"
diff --git a/examples/gke-stripe/scripts/stripe-test.py b/examples/gke-stripe/scripts/stripe-test.py
new file mode 100755
index 0000000..31b1073
--- /dev/null
+++ b/examples/gke-stripe/scripts/stripe-test.py
@@ -0,0 +1,265 @@
+#!/usr/bin/env python3
+"""Test-only Stripe administration; the Go vBilling adapter sends all usage.
+
+No payment method, charge, pay, send, finalize, or resume command is implemented.
+Creating and pausing a subscription takes TWO requests. Stripe may finalize its
+initial zero-value invoice before the pause. Retry the SAME state after any
+uncertain result; the subscription ID is saved before the pause request.
+"""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import re
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from demo_common import GuardError, outside_git, private_read, provider_id, require, run_id, save_json
+
+BASE = 'https://api.stripe.com'
+VERSION = '2026-09-30.endive'
+META = 'vbilling_demo_run'
+
+class NoRedirect(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ raise GuardError('Stripe redirect refused')
+
+class Client:
+ def __init__(self, key_file):
+ self.key = private_read(key_file)
+ require(re.fullmatch(r'sk_test_[A-Za-z0-9]+', self.key) and len(self.key) > 16,
+ 'Only Stripe test secret keys are accepted')
+ self.opener = urllib.request.build_opener(NoRedirect)
+
+ def request(self, method, path, form=None, idem=None):
+ require(path.startswith('/v1/') and not any(x in path for x in ('://', '..', '#', '?')),
+ 'Unexpected Stripe API path')
+ data = urllib.parse.urlencode(form or {}).encode()
+ url = BASE + path
+ headers = {'Authorization': 'Bearer ' + self.key, 'Stripe-Version': VERSION}
+ if idem: headers['Idempotency-Key'] = idem
+ if method == 'GET':
+ if data: url += '?' + data.decode()
+ data = None
+ else: headers['Content-Type'] = 'application/x-www-form-urlencoded'
+ try:
+ with self.opener.open(urllib.request.Request(url, data=data, headers=headers, method=method), timeout=30) as response:
+ result = json.load(response)
+ except urllib.error.HTTPError as error:
+ raise GuardError('Stripe HTTP ' + str(error.code) + '; response suppressed. Retry with the SAME state file.') from None
+ except (urllib.error.URLError, TimeoutError, OSError, ValueError):
+ raise GuardError('Stripe outcome uncertain; response suppressed. Retry with the SAME state file.') from None
+ require(isinstance(result, dict), 'Unexpected Stripe response')
+ require(result.get('livemode') is not True, 'A live-mode object was returned; refusing')
+ return result
+
+ def all(self, path, query=None):
+ query = dict(query or {}, limit=100)
+ rows = []
+ for _ in range(100):
+ result = self.request('GET', path, query)
+ batch = result.get('data')
+ require(isinstance(batch, list), 'Invalid Stripe list')
+ require(all(r.get('livemode') is not True for r in batch), 'Live-mode object in list')
+ rows.extend(batch)
+ if not result.get('has_more'): return rows
+ require(bool(batch), 'Invalid empty Stripe page')
+ query['starting_after'] = batch[-1]['id']
+ raise GuardError('Stripe pagination limit reached; narrow the demo account')
+
+def idem(state, action):
+ raw = '|'.join([state['run'], state['tenant'], state['event'], action])
+ return 'vbilling-demo-' + hashlib.sha256(raw.encode()).hexdigest()
+
+def obj(value, kind):
+ require(value.get('object') == kind and value.get('livemode') is False,
+ 'Expected an explicitly test-mode ' + kind)
+ return value
+
+def ref(value):
+ return value.get('id') if isinstance(value, dict) else value
+
+def owned(value, state, kind):
+ value = obj(value, kind)
+ require((value.get('metadata') or {}).get(META) == state['run'], 'Object is not owned by this run')
+ require((value.get('metadata') or {}).get('vbilling_tenant_id') == state['tenant'], 'Object tenant differs')
+ return value
+
+def one(rows, message):
+ require(len(rows) == 1, message)
+ return rows[0]
+
+def check_customer(client, state):
+ customer = obj(client.request('GET', '/v1/customers/' + provider_id(state['customer'], 'cus_')), 'customer')
+ require((customer.get('metadata') or {}).get('vbilling_tenant_id') == state['tenant'], 'Customer tenant does not match')
+ require(not customer.get('email'), 'Demo customer must have no email')
+ require(not customer.get('default_source') and not (customer.get('invoice_settings') or {}).get('default_payment_method'),
+ 'Demo customer must have no default payment source')
+ require(customer.get('balance', 0) == 0 and not customer.get('discount') and not customer.get('discounts'),
+ 'Demo customer must have no credit balance or discounts')
+ for suffix in ('payment_methods', 'sources'):
+ result = client.request('GET', '/v1/customers/' + state['customer'] + '/' + suffix, {'limit': 1})
+ require(result.get('data') == [] and not result.get('has_more'), 'Demo customer must have no saved payment methods')
+ return customer
+
+def check_meter(client, state):
+ meter = obj(client.request('GET', '/v1/billing/meters/' + provider_id(state['meter'], 'mtr_')), 'billing.meter')
+ require(meter.get('event_name') == state['event'] and meter.get('status') == 'active', 'Meter event or status differs')
+ require((meter.get('default_aggregation') or {}).get('formula') == 'sum', 'Meter must SUM usage')
+ require(meter.get('customer_mapping') == {'type': 'by_id', 'event_payload_key': 'stripe_customer_id'} and
+ (meter.get('value_settings') or {}).get('event_payload_key') == 'value', 'Meter payload keys differ')
+ return meter
+
+def check_price(client, state):
+ price = owned(client.request('GET', '/v1/prices/' + provider_id(state['price'], 'price_')), state, 'price')
+ recurring = price.get('recurring') or {}
+ require(price.get('currency') == 'usd' and str(price.get('unit_amount_decimal')) == '250', 'Expected USD 250 cents per GPU-hour')
+ require(price.get('active') and price.get('billing_scheme') == 'per_unit' and not price.get('transform_quantity'), 'Price must be active per-unit without transform')
+ require(recurring.get('meter') == state['meter'] and recurring.get('interval') == 'month' and
+ recurring.get('interval_count', 1) == 1 and recurring.get('usage_type') == 'metered', 'Price meter or billing interval differs')
+ require(ref(price.get('product')) == state['product'], 'Price product differs')
+ return price
+
+def check_sub(sub, state, paused=True):
+ sub = owned(sub, state, 'subscription')
+ provider_id(sub.get('id'), 'sub_')
+ require(ref(sub.get('customer')) == state['customer'], 'Subscription customer differs')
+ require(sub.get('collection_method') == 'charge_automatically', 'Subscription collection method differs')
+ require(not sub.get('default_source') and not sub.get('default_payment_method'), 'Subscription has a payment source')
+ require(not sub.get('discounts') and not sub.get('discount') and not sub.get('trial_end') and
+ not sub.get('default_tax_rates') and not (sub.get('automatic_tax') or {}).get('enabled'), 'Subscription has unexpected adjustments')
+ items = sub.get('items') or {}
+ rows = items.get('data') or []
+ require(len(rows) == 1 and not items.get('has_more') and ref(rows[0].get('price')) == state['price'], 'Subscription must contain only this run price')
+ if paused:
+ pause = sub.get('pause_collection') or {}
+ require(pause.get('behavior') == 'keep_as_draft' and pause.get('resumes_at') is None,
+ 'Collection is not confirmed paused. Retry create-and-pause with the same state immediately.')
+ return sub
+
+def metadata(state):
+ return {'metadata[' + META + ']': state['run'], 'metadata[vbilling_tenant_id]': state['tenant'],
+ 'metadata[vbilling_plan]': state['run']}
+
+def catalog(client, state, save):
+ if not state.get('customer'):
+ row = one([c for c in client.all('/v1/customers') if (c.get('metadata') or {}).get('vbilling_tenant_id') == state['tenant']],
+ 'Need exactly one vBilling-created test customer for this tenant; start collector and wait')
+ state['customer'] = provider_id(row['id'], 'cus_'); save()
+ check_customer(client, state)
+ if not state.get('meter'):
+ row = one([m for m in client.all('/v1/billing/meters') if m.get('event_name') == state['event']],
+ 'Need exactly one vBilling-created meter for this event; wait for its first measurement')
+ state['meter'] = provider_id(row['id'], 'mtr_'); save()
+ check_meter(client, state)
+ if not state.get('product'):
+ rows = [p for p in client.all('/v1/products') if (p.get('metadata') or {}).get(META) == state['run'] and
+ (p.get('metadata') or {}).get('vbilling_tenant_id') == state['tenant']]
+ require(len(rows) <= 1, 'Duplicate run products; inspect before continuing')
+ product = rows[0] if rows else client.request('POST', '/v1/products', dict(metadata(state), name='GPU access example ' + state['tenant']), idem(state, 'product'))
+ owned(product, state, 'product')
+ state['product'] = provider_id(product['id'], 'prod_'); save()
+ owned(client.request('GET', '/v1/products/' + provider_id(state['product'], 'prod_')), state, 'product')
+ if not state.get('price'):
+ rows = [p for p in client.all('/v1/prices', {'product': state['product']}) if (p.get('metadata') or {}).get(META) == state['run']]
+ require(len(rows) <= 1, 'Duplicate run prices; inspect before continuing')
+ price = rows[0] if rows else client.request('POST', '/v1/prices', dict(metadata(state), **{
+ 'product': state['product'], 'currency': 'usd', 'unit_amount_decimal': '250',
+ 'recurring[interval]': 'month', 'recurring[usage_type]': 'metered', 'recurring[meter]': state['meter']}), idem(state, 'price'))
+ owned(price, state, 'price')
+ state['price'] = provider_id(price['id'], 'price_'); save()
+ check_price(client, state)
+ return dict(state, note='Product and USD 2.50/GPU-hour price ready. Meter/customer came from vBilling; no subscription created yet.')
+
+def create_pause(client, state, save):
+ check_customer(client, state); check_meter(client, state); check_price(client, state)
+ rows = client.all('/v1/subscriptions', {'customer': state['customer'], 'status': 'all'})
+ ours = [s for s in rows if (s.get('metadata') or {}).get(META) == state['run']]
+ require(len(ours) <= 1 and len(rows) == len(ours), 'Customer has other subscriptions; use an isolated demo tenant')
+ if state.get('subscription'):
+ sub = client.request('GET', '/v1/subscriptions/' + provider_id(state['subscription'], 'sub_'))
+ elif ours:
+ sub = ours[0]
+ else:
+ pending = client.request('GET', '/v1/invoiceitems', {'customer': state['customer'], 'pending': 'true', 'limit': 1})
+ require(pending.get('data') == [] and not pending.get('has_more'), 'Customer has pending invoice items')
+ sub = client.request('POST', '/v1/subscriptions', dict(metadata(state), **{
+ 'customer': state['customer'], 'items[0][price]': state['price'],
+ 'collection_method': 'charge_automatically', 'payment_behavior': 'default_incomplete',
+ 'proration_behavior': 'none', 'automatic_tax[enabled]': 'false',
+ 'payment_settings[save_default_payment_method]': 'off'}), idem(state, 'subscription'))
+ sub = check_sub(sub, state, paused=False)
+ require(sub.get('status') not in ('canceled', 'incomplete_expired'), 'Run subscription is canceled/expired; never recreate it under the same run')
+ state['subscription'] = sub['id']; state['pause_verified'] = False; save()
+ # First network call after the new subscription's identity is persisted.
+ # Do not log/debug a raw provider response or add calls before the pause.
+ client.request('POST', '/v1/subscriptions/' + sub['id'],
+ {'pause_collection[behavior]': 'keep_as_draft', 'proration_behavior': 'none'}, idem(state, 'pause'))
+ sub = check_sub(client.request('GET', '/v1/subscriptions/' + sub['id']), state)
+ require(sub.get('status') == 'active', 'Collection paused but subscription is not active; inspect test account')
+ state['pause_verified'] = True; save()
+ return dict(state, note='Test collection verified paused indefinitely. Creation + pause were two requests; initial zero invoice may have finalized.')
+
+def inspect(client, state):
+ check_customer(client, state); check_meter(client, state); check_price(client, state)
+ sub = check_sub(client.request('GET', '/v1/subscriptions/' + provider_id(state.get('subscription'), 'sub_')), state)
+ return dict(state, status=sub.get('status'), pause_collection=sub['pause_collection'])
+
+def preview(client, state):
+ inspect(client, state)
+ invoice = obj(client.request('POST', '/v1/invoices/create_preview', {
+ 'customer': state['customer'], 'subscription': state['subscription']}), 'invoice')
+ require(ref(invoice.get('customer')) == state['customer'], 'Preview customer differs')
+ require(invoice.get('attempted') is not True, 'Unexpected payment-attempt flag on preview')
+ lines = invoice.get('lines') or {}
+ require(not lines.get('has_more'), 'Preview has additional lines; do not claim a verified total')
+ cleaned = []
+ for row in lines.get('data', []):
+ price = ref(((row.get('pricing') or {}).get('price_details') or {}).get('price')) or ref(row.get('price'))
+ require(price == state['price'], 'Preview contains another price')
+ cleaned.append({key: row.get(key) for key in ('amount', 'currency', 'quantity', 'quantity_decimal', 'period')})
+ return {'preview_only': True, 'livemode': False, 'customer': state['customer'], 'subscription': state['subscription'],
+ 'amount_due_cents': invoice.get('amount_due'), 'total_cents': invoice.get('total'), 'currency': invoice.get('currency'), 'lines': cleaned}
+
+def cancel(client, state, save):
+ sid = provider_id(state.get('subscription'), 'sub_')
+ sub = check_sub(client.request('GET', '/v1/subscriptions/' + sid), state, paused=False)
+ if sub.get('status') != 'canceled':
+ sub = check_sub(client.request('DELETE', '/v1/subscriptions/' + sid, {'invoice_now': 'false', 'prorate': 'false'}), state, paused=False)
+ require(sub.get('status') == 'canceled', 'Cancellation not confirmed; retry same state')
+ state['canceled'] = True; save()
+ return dict(state, note='Exact owned test subscription canceled, without an invoice or proration. Customer, meter, product and price retained as evidence.')
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument('--key-file', required=True)
+ p.add_argument('--state', required=True)
+ p.add_argument('--run', required=True)
+ p.add_argument('--tenant', required=True)
+ p.add_argument('--event', required=True)
+ p.add_argument('action', choices=['catalog', 'create-and-pause', 'inspect', 'preview', 'cancel'])
+ a = p.parse_args()
+ run_id(a.run)
+ require(a.tenant in (a.run, a.run + '-private'), 'Tenant must be the run ID or run ID-private')
+ require(a.event in ('vcluster_gpu_hours__nvidia_l4_timeslice_2', 'vcluster_gpu_hours__nvidia_a100_sxm4_40gb'), 'Use one of the documented demo GPU events')
+ path = outside_git(a.state)
+ identity = {'run': a.run, 'tenant': a.tenant, 'event': a.event}
+ state = json.loads(private_read(path)) if path.exists() else dict(identity)
+ require(all(state.get(k) == v for k, v in identity.items()), 'Existing state belongs to a different run, tenant, or meter')
+ save = lambda: save_json(path, state)
+ if not path.exists(): save()
+ client = Client(a.key_file)
+ if a.action == 'catalog': result = catalog(client, state, save)
+ elif a.action == 'create-and-pause': result = create_pause(client, state, save)
+ elif a.action == 'cancel': result = cancel(client, state, save)
+ elif a.action == 'preview': result = preview(client, state)
+ else: result = inspect(client, state)
+ print(json.dumps(result, indent=2))
+
+if __name__ == '__main__':
+ try: main()
+ except GuardError as error:
+ print('Stopped: ' + str(error), file=sys.stderr); sys.exit(2)
+ except (OSError, ValueError, KeyError, TypeError):
+ print('Stopped: invalid or incomplete private state; raw data suppressed. Preserve state and inspect locally.', file=sys.stderr); sys.exit(2)
diff --git a/examples/gke-stripe/tests/test_demo.py b/examples/gke-stripe/tests/test_demo.py
new file mode 100644
index 0000000..8462d12
--- /dev/null
+++ b/examples/gke-stripe/tests/test_demo.py
@@ -0,0 +1,278 @@
+"""Offline guard/behavior tests. No cloud or Stripe connection is performed."""
+import copy
+import importlib.util
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+SCRIPTS=Path(__file__).resolve().parents[1]/'scripts'
+sys.path.insert(0,str(SCRIPTS))
+from demo_common import GuardError, outside_git, private_read, run_id
+
+def load(name):
+ spec=importlib.util.spec_from_file_location(name.replace('-','_'),SCRIPTS/(name+'.py'))
+ module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module); return module
+stripe=load('stripe-test'); cleanup=load('cleanup-gcp'); startup=load('private-startup')
+RUN='vbdemo-20260101-120000'
+STATE={'run':RUN,'tenant':RUN,'event':'vcluster_gpu_hours__nvidia_l4_timeslice_2',
+ 'customer':'cus_TEST','meter':'mtr_test_TEST','product':'prod_TEST','price':'price_TEST'}
+
+def owned(kind, **fields):
+ return dict(object=kind,livemode=False,metadata={'vbilling_demo_run':RUN,'vbilling_tenant_id':RUN},**fields)
+
+def subscription(**fields):
+ return owned('subscription', id='sub_TEST',customer='cus_TEST',collection_method='charge_automatically',
+ items={'data':[{'id':'si_TEST','price':'price_TEST'}],'has_more':False},**fields)
+
+class FakeStripe:
+ def __init__(self, fail_pause=False):
+ self.calls=[]; self.sub=None; self.fail_pause=fail_pause
+ def all(self,path,query=None):
+ if path=='/v1/subscriptions': return [] if self.sub is None else [copy.deepcopy(self.sub)]
+ raise AssertionError(path)
+ def request(self,method,path,form=None,idem=None):
+ self.calls.append((method,path,form,idem))
+ if path=='/v1/customers/cus_TEST': return owned('customer',id='cus_TEST',balance=0)
+ if path.endswith(('/payment_methods','/sources')): return {'data':[],'has_more':False}
+ if path=='/v1/billing/meters/mtr_test_TEST': return owned('billing.meter',id='mtr_test_TEST',status='active',event_name=STATE['event'],default_aggregation={'formula':'sum'},customer_mapping={'type':'by_id','event_payload_key':'stripe_customer_id'},value_settings={'event_payload_key':'value'})
+ if path=='/v1/prices/price_TEST': return owned('price',id='price_TEST',active=True,billing_scheme='per_unit',currency='usd',unit_amount_decimal='250',product='prod_TEST',recurring={'interval':'month','usage_type':'metered','meter':'mtr_test_TEST'})
+ if path=='/v1/invoiceitems': return {'data':[],'has_more':False}
+ if path=='/v1/subscriptions' and method=='POST':
+ self.sub=subscription(status='active'); return copy.deepcopy(self.sub)
+ if path=='/v1/subscriptions/sub_TEST':
+ if method=='POST':
+ if self.fail_pause: raise GuardError('simulated network uncertainty')
+ self.sub['pause_collection']={'behavior':'keep_as_draft','resumes_at':None}
+ elif method=='DELETE': self.sub['status']='canceled'; self.sub['pause_collection']=None
+ return copy.deepcopy(self.sub)
+ if path=='/v1/invoices/create_preview':
+ return owned('invoice',customer='cus_TEST',attempted=False,currency='usd',total=2,amount_due=2,
+ lines={'data':[{'price':'price_TEST','amount':2,'currency':'usd','quantity_decimal':'0.008333333'}]})
+ raise AssertionError((method,path))
+
+class StripeTests(unittest.TestCase):
+ def test_create_persist_then_pause_and_verify(self):
+ client=FakeStripe(); state=dict(STATE); snapshots=[]
+ stripe.create_pause(client,state,lambda:snapshots.append(copy.deepcopy(state)))
+ self.assertEqual(snapshots[0]['subscription'],'sub_TEST')
+ self.assertFalse(snapshots[0]['pause_verified']); self.assertTrue(snapshots[-1]['pause_verified'])
+ writes=[x for x in client.calls if x[0]=='POST']
+ self.assertEqual([x[1] for x in writes],['/v1/subscriptions','/v1/subscriptions/sub_TEST'])
+ self.assertEqual(writes[1][2]['pause_collection[behavior]'],'keep_as_draft')
+ self.assertFalse(any(c[1].endswith(('/pay','/finalize','/send')) for c in client.calls))
+ def test_pause_failure_preserves_id_and_retry_does_not_recreate(self):
+ client=FakeStripe(fail_pause=True); state=dict(STATE); saved=[]
+ with self.assertRaises(GuardError): stripe.create_pause(client,state,lambda:saved.append(copy.deepcopy(state)))
+ self.assertEqual(saved[-1]['subscription'],'sub_TEST'); self.assertFalse(saved[-1]['pause_verified'])
+ client.fail_pause=False; stripe.create_pause(client,state,lambda:None)
+ self.assertEqual(sum(c[0]=='POST' and c[1]=='/v1/subscriptions' for c in client.calls),1)
+ def test_live_object_rejected(self):
+ value=subscription(status='active'); value['livemode']=True
+ with self.assertRaises(GuardError): stripe.check_sub(value,STATE,False)
+ def test_wrong_tenant_rejected(self):
+ value=subscription(status='active'); value['metadata']['vbilling_tenant_id']='different'
+ with self.assertRaises(GuardError): stripe.check_sub(value,STATE,False)
+ def test_unpaused_preview_rejected(self):
+ client=FakeStripe(); client.sub=subscription(status='active'); state=dict(STATE,subscription='sub_TEST')
+ with self.assertRaises(GuardError): stripe.preview(client,state)
+ self.assertFalse(any(c[1]=='/v1/invoices/create_preview' for c in client.calls))
+ def test_preview_is_nonfinalizing(self):
+ client=FakeStripe(); state=dict(STATE); stripe.create_pause(client,state,lambda:None)
+ result=stripe.preview(client,state); self.assertEqual(result['total_cents'],2); self.assertTrue(result['preview_only'])
+ def test_cancel_does_not_invoice_and_is_retryable(self):
+ client=FakeStripe(); state=dict(STATE); stripe.create_pause(client,state,lambda:None)
+ stripe.cancel(client,state,lambda:None); stripe.cancel(client,state,lambda:None)
+ writes=[c for c in client.calls if c[0]=='DELETE']
+ self.assertEqual(len(writes),1); self.assertEqual(writes[0][2],{'invoice_now':'false','prorate':'false'})
+ def test_live_key_and_open_permissions_rejected_before_network(self):
+ with tempfile.TemporaryDirectory() as temp:
+ path=Path(temp)/'key'; path.write_text('sk_'+'live_SYNTHETICONLY'); path.chmod(0o600)
+ with self.assertRaises(GuardError): stripe.Client(path)
+ path.write_text('sk_'+'test_SYNTHETICONLY123456'); path.chmod(0o644)
+ with self.assertRaises(GuardError): stripe.Client(path)
+
+
+class CatalogFake(FakeStripe):
+ def __init__(self, uncertain_price=False):
+ super().__init__(); self.products=[]; self.prices=[]; self.uncertain_price=uncertain_price
+ def all(self,path,query=None):
+ if path=='/v1/customers': return [owned('customer',id='cus_TEST')]
+ if path=='/v1/billing/meters': return [owned('billing.meter',id='mtr_test_TEST',event_name=STATE['event'])]
+ if path=='/v1/products': return copy.deepcopy(self.products)
+ if path=='/v1/prices': return copy.deepcopy(self.prices)
+ return super().all(path,query)
+ def request(self,method,path,form=None,idem=None):
+ if method=='POST' and path=='/v1/products':
+ self.calls.append((method,path,form,idem)); result=owned('product',id='prod_TEST')
+ self.products.append(result); return result
+ if method=='GET' and path=='/v1/products/prod_TEST': return owned('product',id='prod_TEST')
+ if method=='POST' and path=='/v1/prices':
+ self.calls.append((method,path,form,idem)); result=owned('price',id='price_TEST')
+ self.prices.append(result)
+ if self.uncertain_price: self.uncertain_price=False; raise GuardError('uncertain create response')
+ return result
+ return super().request(method,path,form,idem)
+
+class CatalogTests(unittest.TestCase):
+ def test_catalog_reuses_owned_objects_on_retry(self):
+ client=CatalogFake(); state={k:STATE[k] for k in ('run','tenant','event')}
+ stripe.catalog(client,state,lambda:None); stripe.catalog(client,state,lambda:None)
+ self.assertEqual(state['product'],'prod_TEST'); self.assertEqual(state['price'],'price_TEST')
+ self.assertEqual([c[1] for c in client.calls if c[0]=='POST'],['/v1/products','/v1/prices'])
+ form=next(c[2] for c in client.calls if c[1]=='/v1/prices')
+ self.assertEqual(form['recurring[meter]'],'mtr_test_TEST'); self.assertEqual(form['unit_amount_decimal'],'250')
+ def test_catalog_unknown_price_outcome_recovers_by_metadata(self):
+ client=CatalogFake(uncertain_price=True); state={k:STATE[k] for k in ('run','tenant','event')}
+ with self.assertRaises(GuardError): stripe.catalog(client,state,lambda:None)
+ self.assertEqual(state['product'],'prod_TEST'); self.assertNotIn('price',state)
+ stripe.catalog(client,state,lambda:None)
+ self.assertEqual(state['price'],'price_TEST')
+ self.assertEqual(sum(c[0]=='POST' and c[1]=='/v1/prices' for c in client.calls),1)
+
+class LocalGuards(unittest.TestCase):
+ def test_state_inside_git_rejected(self):
+ with tempfile.TemporaryDirectory() as temp:
+ (Path(temp)/'.git').mkdir()
+ with self.assertRaises(GuardError): outside_git(Path(temp)/'key')
+ def test_run_id(self):
+ self.assertEqual(run_id(RUN),RUN)
+ with self.assertRaises(GuardError): run_id('../production')
+ def test_join_host_guard(self):
+ raw='curl -fsSLk "https://10.240.0.5/node/join?token=synthetic" | sh -'
+ self.assertIn('PRIVATE_NODE_JOIN_COMPLETE',startup.render(raw,'10.240.0.5'))
+ platform_raw=raw.replace('10.240.0.5','platform.example.invalid')
+ self.assertIn('PRIVATE_NODE_JOIN_COMPLETE',startup.render(platform_raw,['10.240.0.5','platform.example.invalid']))
+ self.assertIn('PRIVATE_NODE_JOIN_COMPLETE',startup.render(raw,['10.240.0.5','platform.example.invalid']))
+ with self.assertRaises(GuardError): startup.render(raw,'example.invalid')
+ with self.assertRaises(GuardError): startup.render(raw+'; malicious','10.240.0.5')
+ def test_native_launcher_fake_binary_configuration(self):
+ with tempfile.TemporaryDirectory() as temp:
+ root=Path(temp); root.chmod(0o700)
+ kube=root/'host.kubeconfig'; kube.write_text('synthetic'); kube.chmod(0o600)
+ key=root/'stripe-key'; key.write_text('sk_'+'test_SYNTHETICONLY123456'); key.chmod(0o600)
+ binary=root/'vbilling'
+ binary.write_text('#!/usr/bin/env python3\nimport json,os\nfrom pathlib import Path\nkeys=["ADAPTERS","LISTEN_ADDR","WATCH_NAMESPACES","COLLECTION_INTERVAL","TENANT_API","STRIPE_AUTO_SUBSCRIBE","STRIPE_CANCEL_ON_REMOVE","STRIPE_SPLIT_METERS_BY"]\nPath(os.environ["RUN_DIR"],"seen.json").write_text(json.dumps({k:os.environ[k] for k in keys}))\n')
+ binary.chmod(0o700)
+ env=dict(os.environ,RUN_DIR=str(root),HOST_KUBECONFIG=str(kube),STRIPE_KEY_FILE=str(key),WATCH_NAMESPACE=RUN,CLUSTER_NAME=RUN,REGION='test-region')
+ result=subprocess.run(['bash',str(SCRIPTS/'run-local.sh')],env=env,capture_output=True,text=True)
+ self.assertEqual(result.returncode,0,result.stderr)
+ config=json.loads((root/'seen.json').read_text())
+ self.assertEqual(config['ADAPTERS'],'stripe'); self.assertEqual(config['TENANT_API'],'false')
+ self.assertEqual(config['STRIPE_AUTO_SUBSCRIBE'],'false'); self.assertEqual(config['STRIPE_CANCEL_ON_REMOVE'],'false')
+ self.assertNotIn(key.read_text(),result.stdout+result.stderr)
+ self.assertFalse((root/'collector.lock').exists())
+ self.assertEqual((root/'api-token').stat().st_mode & 0o777,0o600)
+ def test_launcher_rejects_second_collector(self):
+ with tempfile.TemporaryDirectory() as temp:
+ root=Path(temp); root.chmod(0o700)
+ kube=root/'kube'; kube.write_text('synthetic'); kube.chmod(0o600)
+ binary=root/'vbilling'; binary.write_text('#!/bin/sh\nexit 0\n'); binary.chmod(0o700)
+ (root/'collector.lock').mkdir()
+ env=dict(os.environ,RUN_DIR=str(root),HOST_KUBECONFIG=str(kube),WATCH_NAMESPACE=RUN,CLUSTER_NAME=RUN,REGION='test-region')
+ env.pop('STRIPE_KEY_FILE',None)
+ result=subprocess.run(['bash',str(SCRIPTS/'run-local.sh')],env=env,capture_output=True,text=True)
+ self.assertEqual(result.returncode,2); self.assertIn('Collector lock exists',result.stderr)
+
+CSTATE={'run':RUN,'project':'example-project','zone':'us-central1-a','region':'us-central1','private_zone':None}
+def cloud_values(state):
+ return {
+ 'cluster':{'id':'cluster-id','name':RUN,'location':state['zone'],'resourceLabels':{'run':RUN,'purpose':'vbilling-demo'},'network':RUN+'-net','subnetwork':RUN+'-subnet','endpoint':'192.0.2.1'},
+ 'network':{'id':'network-id','name':RUN+'-net','description':'vbilling-demo:'+RUN},
+ 'subnet':{'id':'subnet-id','name':RUN+'-subnet','description':'vbilling-demo:'+RUN,'network':'https://example/projects/example-project/global/networks/'+RUN+'-net'},
+ 'service_account':{'uniqueId':'sa-id','email':RUN+'-node@example-project.iam.gserviceaccount.com','displayName':'vBilling demo '+RUN},
+ }
+class FakeGCloud:
+ def __init__(self,state,values): self.state=state; self.values=values; self.calls=[]
+ def run(self,args,absent_ok=False):
+ self.calls.append(args)
+ for kind,argv in cleanup.specs(self.state).items():
+ if args==argv: return copy.deepcopy(self.values.get(kind))
+ if args[:2]==['projects','get-iam-policy']: return {'bindings':[]}
+ require_delete='delete' in args
+ if require_delete:
+ for kind,argv in cleanup.specs(self.state).items():
+ if args[:args.index('delete')]==argv[:argv.index('describe')]: self.values.pop(kind,None); return {}
+ raise AssertionError(args)
+class CleanupTests(unittest.TestCase):
+ def setUp(self):
+ self.state=dict(CSTATE); self.values=cloud_values(self.state)
+ self.state['ids']={k:cleanup.identity(k,v,self.state) for k,v in self.values.items()}
+ def test_plan_is_no_network(self):
+ self.assertEqual(set(cleanup.specs(self.state)),set(self.state['ids']))
+ def test_wrong_confirmation_blocks(self):
+ with self.assertRaises(GuardError): cleanup.execute(self.state,'different','ignored')
+ def test_replaced_resource_blocks_all_deletes(self):
+ self.values['network']['id']='replacement'; fake=FakeGCloud(self.state,self.values)
+ with patch.object(cleanup,'GCloud',return_value=fake):
+ with self.assertRaises(GuardError): cleanup.execute(self.state,RUN,'ignored')
+ self.assertFalse(any('delete' in c for c in fake.calls))
+ def test_owner_label_blocks_all_deletes(self):
+ self.values['cluster']['resourceLabels']['run']='different'; fake=FakeGCloud(self.state,self.values)
+ with patch.object(cleanup,'GCloud',return_value=fake):
+ with self.assertRaises(GuardError): cleanup.execute(self.state,RUN,'ignored')
+ self.assertFalse(any('delete' in c for c in fake.calls))
+ def test_pv_preflight_failure_blocks(self):
+ fake=FakeGCloud(self.state,self.values)
+ with patch.object(cleanup,'GCloud',return_value=fake),patch.object(cleanup,'ensure_drained',side_effect=GuardError('PV exists')):
+ with self.assertRaises(GuardError): cleanup.execute(self.state,RUN,'ignored')
+ self.assertFalse(any('delete' in c for c in fake.calls))
+ def test_exact_delete_order_and_repeated_absent(self):
+ fake=FakeGCloud(self.state,self.values)
+ with patch.object(cleanup,'GCloud',return_value=fake),patch.object(cleanup,'ensure_drained'):
+ result=cleanup.execute(self.state,RUN,'ignored')
+ cleanup.execute(self.state,RUN,'ignored')
+ self.assertEqual(len(result['confirmed_absent']),4)
+ deletes=[c for c in fake.calls if 'delete' in c]
+ self.assertEqual(len(deletes),4)
+ self.assertEqual(deletes[0][:3],['container','clusters','delete'])
+ self.assertTrue(all('--filter' not in ' '.join(c) for c in deletes))
+ def test_private_vm_only_exact_boot_disk_and_network(self):
+ state=dict(self.state,private_zone='us-central1-f')
+ good={'id':'vm-id','name':RUN+'-a100','zone':'projects/example-project/zones/us-central1-f',
+ 'labels':{'run':RUN,'purpose':'vbilling-demo'},
+ 'disks':[{'boot':True,'autoDelete':True}],
+ 'networkInterfaces':[{'network':'projects/example-project/global/networks/'+RUN+'-net'}]}
+ self.assertEqual(cleanup.identity('private_vm',good,state),'vm-id')
+ for changed in ({'disks':[{'boot':True,'autoDelete':False}]}, {'disks':[{'boot':False,'autoDelete':True}]},
+ {'disks':[{'boot':True,'autoDelete':True},{'boot':False,'autoDelete':False}]},
+ {'networkInterfaces':[]}, {'networkInterfaces':[{'network':'production'}]}):
+ with self.subTest(changed=changed), self.assertRaises(GuardError):
+ cleanup.identity('private_vm',dict(good,**changed),state)
+ def test_gke_precise_404_is_absence(self):
+ proc=subprocess.CompletedProcess([],1,'','ResponseError: code=404, message=Not found: projects/example-project/locations/us-central1-a/clusters/demo. No cluster named demo.')
+ with patch.object(cleanup.subprocess,'run',return_value=proc):
+ self.assertIsNone(cleanup.GCloud('example-project').run(['container','clusters','describe','demo'],absent_ok=True))
+ def test_iam_ambiguous_describe_uses_successful_empty_list(self):
+ email=RUN+'-node@example-project.iam.gserviceaccount.com'
+ denied=subprocess.CompletedProcess([],1,'','PERMISSION_DENIED: Permission denied on resource (or it may not exist).')
+ empty=subprocess.CompletedProcess([],0,'[]','')
+ with patch.object(cleanup.subprocess,'run',side_effect=[denied,empty]) as mock:
+ self.assertIsNone(cleanup.GCloud('example-project').run(['iam','service-accounts','describe',email],absent_ok=True))
+ self.assertEqual(mock.call_args_list[1].args[0][1:5],['iam','service-accounts','list','--filter=email='+email])
+ def test_iam_failed_list_does_not_confirm_absence(self):
+ email=RUN+'-node@example-project.iam.gserviceaccount.com'
+ denied=subprocess.CompletedProcess([],1,'','PERMISSION_DENIED: Permission denied on resource (or it may not exist).')
+ with patch.object(cleanup.subprocess,'run',side_effect=[denied,denied]):
+ with self.assertRaises(GuardError): cleanup.GCloud('example-project').run(['iam','service-accounts','describe',email],absent_ok=True)
+ def test_iam_existing_account_does_not_confirm_absence(self):
+ email=RUN+'-node@example-project.iam.gserviceaccount.com'
+ denied=subprocess.CompletedProcess([],1,'','PERMISSION_DENIED: Permission denied on resource (or it may not exist).')
+ present=subprocess.CompletedProcess([],0,json.dumps([{'email':email}]),'')
+ with patch.object(cleanup.subprocess,'run',side_effect=[denied,present]):
+ with self.assertRaises(GuardError): cleanup.GCloud('example-project').run(['iam','service-accounts','describe',email],absent_ok=True)
+ def test_other_resource_ambiguous_permission_never_lists_iam(self):
+ denied=subprocess.CompletedProcess([],1,'','PERMISSION_DENIED: resource was not found (or it may not exist).')
+ with patch.object(cleanup.subprocess,'run',return_value=denied) as mock:
+ with self.assertRaises(GuardError): cleanup.GCloud('example-project').run(['compute','networks','describe','x'],absent_ok=True)
+ self.assertEqual(mock.call_count,1)
+ def test_permission_error_is_not_absence(self):
+ proc=subprocess.CompletedProcess([],1,'','PERMISSION_DENIED')
+ with patch.object(cleanup.subprocess,'run',return_value=proc):
+ with self.assertRaises(GuardError): cleanup.GCloud('example-project').run(['compute','networks','describe','x'],absent_ok=True)
+
+if __name__=='__main__': unittest.main()
diff --git a/internal/api/server.go b/internal/api/server.go
index 4d114c0..9a85ca5 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -186,6 +186,18 @@ func field(e *usage.Event, key string) string {
}
// filter applies tenant/metric/project/region/sku/cluster query filters.
+// filterKeys are the query parameters filter understands.
+var filterKeys = []string{"tenant", "metric", "project", "region", "sku", "cluster"}
+
+func hasFilter(r *http.Request) bool {
+ for _, k := range filterKeys {
+ if r.URL.Query().Get(k) != "" {
+ return true
+ }
+ }
+ return false
+}
+
func filter(r *http.Request) func(*usage.Event) bool {
q := r.URL.Query()
checks := map[string]string{}
@@ -564,21 +576,30 @@ func (s *Server) reconcile(w http.ResponseWriter, r *http.Request) {
}
continue
}
- results = append(results, s.reconcileOne(r.Context(), d, rec, from, to, keep))
+ results = append(results, s.reconcileOne(r.Context(), d, rec, from, to, keep, hasFilter(r)))
}
writeJSON(w, http.StatusOK, map[string]any{"from": from, "to": to, "results": results})
}
-func (s *Server) reconcileOne(ctx context.Context, d destinations.Destination, rec destinations.Reconciler, from, to time.Time, keep func(*usage.Event) bool) reconcileResult {
+func (s *Server) reconcileOne(ctx context.Context, d destinations.Destination, rec destinations.Reconciler, from, to time.Time, keep func(*usage.Event) bool, filtered bool) reconcileResult {
res := reconcileResult{Destination: d.Name()}
type key struct{ tenant, metric string }
ledger, dead := map[key]float64{}, map[key]float64{}
in := func(e *usage.Event) bool {
- return !e.WindowStart.Before(from) && e.WindowStart.Before(to) && destinations.Accepts(d, e.Metric) && keep(e)
+ return !e.WindowStart.Before(from) && e.WindowStart.Before(to) && destinations.Accepts(d, e.Metric)
}
+ // Billing platforms report totals per tenant and metric only, so filters
+ // pick which (tenant, metric) pairs to compare, and each comparison covers
+ // all of that tenant's usage of the metric. Summing only the filtered
+ // events (one SKU, one tenant cluster) would mismatch by construction.
+ selected := map[key]bool{}
err := s.spool.Scan(from, to, func(e *usage.Event) error {
if in(e) {
- ledger[key{e.Tenant, e.Metric}] += e.Quantity
+ k := key{e.Tenant, e.Metric}
+ ledger[k] += e.Quantity
+ if keep(e) {
+ selected[k] = true
+ }
}
return nil
})
@@ -588,11 +609,25 @@ func (s *Server) reconcileOne(ctx context.Context, d destinations.Destination, r
}
if dls, err := s.spool.DeadLetters(d.Name()); err == nil {
for i := range dls {
- if in(&dls[i].Event) {
- dead[key{dls[i].Event.Tenant, dls[i].Event.Metric}] += dls[i].Event.Quantity
+ if e := &dls[i].Event; in(e) {
+ k := key{e.Tenant, e.Metric}
+ dead[k] += e.Quantity
+ if keep(e) {
+ selected[k] = true
+ }
}
}
}
+ for k := range ledger {
+ if !selected[k] {
+ delete(ledger, k)
+ }
+ }
+ for k := range dead {
+ if !selected[k] {
+ delete(dead, k)
+ }
+ }
tenantSet, metricSet := map[string]bool{}, map[string]bool{}
for k := range ledger {
tenantSet[k.tenant], metricSet[k.metric] = true, true
@@ -623,7 +658,9 @@ func (s *Server) reconcileOne(ctx context.Context, d destinations.Destination, r
keys[k] = true
}
for k := range recorded {
- keys[k] = true
+ if !filtered || selected[k] {
+ keys[k] = true
+ }
}
res.OK = true
for k := range keys {
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index 8a376c7..186e354 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -144,8 +144,12 @@ func (e *env) get(t *testing.T, path string) (int, string) {
func (e *env) deliver(t *testing.T) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
- defer cancel()
- go e.disp.Run(ctx)
+ done := make(chan struct{})
+ go func() { e.disp.Run(ctx); close(done) }()
+ defer func() {
+ cancel()
+ <-done // Stop all dispatcher writes before returning or cleaning up the spool.
+ }()
for e.sp.Cursor("fakebill") < e.sp.Committed() {
if ctx.Err() != nil {
t.Fatal("delivery timed out")
@@ -336,3 +340,34 @@ func TestAPITokenProtectsReadEndpoints(t *testing.T) {
t.Fatal("healthz must stay open for probes")
}
}
+
+func TestReconcileFiltersSelectTenantMetricPairs(t *testing.T) {
+ e := setup(t)
+ e.sp.Cursor("fakebill")
+ day := now.Truncate(24 * time.Hour).Add(-24 * time.Hour)
+ e.commit(t, 10, day.Add(time.Hour)) // H100 and L40S GPU hours for one tenant cluster
+ e.deliver(t)
+
+ type resp struct {
+ Results []reconcileResult `json:"results"`
+ }
+ for _, q := range []string{"", "sku=NVIDIA-H100", "cluster=vcluster-a-train", "region=ap-southeast-2", "tenant=acme&sku=NVIDIA-L40S"} {
+ _, body := e.get(t, "/api/v1/reconcile?"+q)
+ var r resp
+ json.Unmarshal([]byte(body), &r)
+ if len(r.Results) != 1 || !r.Results[0].OK || len(r.Results[0].Rows) != 1 {
+ t.Fatalf("%q: %s", q, body)
+ }
+ // Platforms report per tenant and metric: a filter must not compare
+ // one SKU's share of the ledger with the platform's whole total.
+ if row := r.Results[0].Rows[0]; row.Ledger == 0 || row.Ledger != row.Recorded {
+ t.Fatalf("%q compared part of the usage: %+v", q, row)
+ }
+ }
+ _, body := e.get(t, "/api/v1/reconcile?sku=AMD-MI300X")
+ var r resp
+ json.Unmarshal([]byte(body), &r)
+ if len(r.Results) != 1 || !r.Results[0].OK || len(r.Results[0].Rows) != 0 {
+ t.Fatalf("a filter that matches nothing should compare nothing: %s", body)
+ }
+}
diff --git a/internal/controller/controller.go b/internal/controller/controller.go
index 10f76ae..17acd28 100644
--- a/internal/controller/controller.go
+++ b/internal/controller/controller.go
@@ -53,7 +53,8 @@ type Controller struct {
mu sync.Mutex
clusters []discovery.TenantCluster
- lastSeen map[string]time.Time // tenant -> last time it had a tenant cluster
+ lastSeen map[string]time.Time // tenant -> last time it had a tenant cluster
+ offboarded map[string]map[string]bool // tenant -> destinations done offboarding it
discovered bool
lastWindow time.Time
@@ -76,7 +77,7 @@ func New(cfg *config.Config, disc Discoverer, coll Collector, sp *spool.Spool, t
}
}
return &Controller{cfg: cfg, disc: disc, coll: coll, spool: sp, tenants: tenants, dests: dests, tel: tel,
- now: time.Now, lastSeen: map[string]time.Time{}, gaps: gaps}
+ now: time.Now, lastSeen: map[string]time.Time{}, offboarded: map[string]map[string]bool{}, gaps: gaps}
}
// Run reconciles tenants every ReconcileInterval and closes a metering
@@ -166,6 +167,7 @@ func (c *Controller) Reconcile(ctx context.Context) {
c.discovered = true
for _, t := range tenants {
c.lastSeen[t.ID] = now
+ delete(c.offboarded, t.ID) // back before offboarding finished: start over next time
}
var gone []usage.Tenant
current := map[string]bool{}
@@ -181,9 +183,27 @@ func (c *Controller) Reconcile(ctx context.Context) {
c.mu.Unlock()
for _, t := range gone {
+ // Each destination offboards a tenant once. A permanent rejection is
+ // final too: retrying it every pass would never succeed.
+ c.mu.Lock()
+ done := c.offboarded[t.ID]
+ if done == nil {
+ done = map[string]bool{}
+ c.offboarded[t.ID] = done
+ }
+ c.mu.Unlock()
ok := true
for _, d := range c.dests {
- if err := d.RemoveTenant(ctx, t); err != nil {
+ if done[d.Name()] {
+ continue
+ }
+ switch err := d.RemoveTenant(ctx, t); {
+ case err == nil:
+ done[d.Name()] = true
+ case destinations.IsPermanent(err):
+ log.Printf("[controller] offboard %s from %s: %v (rejected, not retried)", t.ID, d.Name(), err)
+ done[d.Name()] = true
+ default:
log.Printf("[controller] offboard %s from %s: %v (will retry)", t.ID, d.Name(), err)
ok = false
}
@@ -193,6 +213,7 @@ func (c *Controller) Reconcile(ctx context.Context) {
c.tenants.Delete(t.ID)
c.mu.Lock()
delete(c.lastSeen, t.ID)
+ delete(c.offboarded, t.ID)
c.mu.Unlock()
}
}
diff --git a/internal/controller/controller_test.go b/internal/controller/controller_test.go
index 775ea57..eb1270d 100644
--- a/internal/controller/controller_test.go
+++ b/internal/controller/controller_test.go
@@ -329,3 +329,41 @@ func TestTenantAPIGapsSurviveRestart(t *testing.T) {
t.Fatalf("gap after restart = %v, %v", g, ok)
}
}
+
+// rejectDest permanently rejects every offboarding, like a receiver that
+// will never accept the event.
+type rejectDest struct {
+ fakeDest
+ calls int
+}
+
+func (d *rejectDest) Name() string { return "picky" }
+func (d *rejectDest) RemoveTenant(context.Context, usage.Tenant) error {
+ d.calls++
+ return destinations.Permanent(errors.New("HTTP 400: unknown event type"))
+}
+
+func TestOffboardingIsFinalPerDestination(t *testing.T) {
+ h := newHarness(t)
+ picky := &rejectDest{}
+ h.c.dests = append(h.c.dests, picky)
+ ctx := context.Background()
+ h.disc.clusters = []discovery.TenantCluster{cluster("team-a", "train", nil)}
+ h.c.Reconcile(ctx)
+
+ h.disc.clusters = nil
+ h.now = h.now.Add(2 * time.Hour)
+ h.dest.failRm = 1
+ h.c.Reconcile(ctx) // fake is down (retried); picky rejects for good (final)
+ h.c.Reconcile(ctx) // fake succeeds; picky is not asked again
+ h.c.Reconcile(ctx)
+ if picky.calls != 1 {
+ t.Fatalf("a permanent rejection was retried: %d calls", picky.calls)
+ }
+ if len(h.dest.removed) != 1 {
+ t.Fatalf("removed = %v", h.dest.removed)
+ }
+ if _, ok := h.reg.Get("vcluster-team-a-train"); ok {
+ t.Fatal("tenant still registered after offboarding")
+ }
+}
diff --git a/internal/destinations/lago/adapter.go b/internal/destinations/lago/adapter.go
index 9f36224..76646f8 100644
--- a/internal/destinations/lago/adapter.go
+++ b/internal/destinations/lago/adapter.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log"
+ "net/http"
"strings"
"github.com/vclusterlabs-experiments/vbilling/internal/config"
@@ -87,7 +88,12 @@ func (a *Adapter) EnsureTenant(ctx context.Context, t usage.Tenant) error {
// RemoveTenant terminates the subscription, which makes Lago issue the final
// invoice. vBilling only calls it after the offboarding grace period.
func (a *Adapter) RemoveTenant(ctx context.Context, t usage.Tenant) error {
- return a.client.TerminateSubscription(ctx, subscriptionIDFor(t.ID))
+ err := a.client.TerminateSubscription(ctx, subscriptionIDFor(t.ID))
+ var httpErr *destinations.HTTPError
+ if errors.As(err, &httpErr) && httpErr.Status == http.StatusNotFound {
+ return nil // already terminated, or never created: nothing to end
+ }
+ return err
}
// ToEvent converts a canonical event to Lago's wire format. Dimensions and
diff --git a/internal/destinations/lago/adapter_test.go b/internal/destinations/lago/adapter_test.go
index dce8d04..af36622 100644
--- a/internal/destinations/lago/adapter_test.go
+++ b/internal/destinations/lago/adapter_test.go
@@ -71,3 +71,22 @@ func TestDuplicateTransactionIDCountsAsDelivered(t *testing.T) {
t.Fatal("lago filter: downtime is informational; utilization is kept for v0.1 plans")
}
}
+
+func TestRemoveTenantTreatsMissingSubscriptionAsDone(t *testing.T) {
+ status := http.StatusNotFound
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete || r.URL.Path != "/api/v1/subscriptions/sub-acme" {
+ t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
+ }
+ w.WriteHeader(status)
+ }))
+ defer srv.Close()
+ a := &Adapter{client: NewClient(srv.URL, "key"), cfg: &config.Config{}}
+ if err := a.RemoveTenant(context.Background(), usage.Tenant{ID: "acme"}); err != nil {
+ t.Fatalf("an already terminated subscription is nothing to end: %v", err)
+ }
+ status = http.StatusBadGateway
+ if err := a.RemoveTenant(context.Background(), usage.Tenant{ID: "acme"}); err == nil || destinations.IsPermanent(err) {
+ t.Fatalf("HTTP 502: got %v, want a retryable error", err)
+ }
+}
diff --git a/internal/destinations/lago/client.go b/internal/destinations/lago/client.go
index 5830ebe..bab7aee 100644
--- a/internal/destinations/lago/client.go
+++ b/internal/destinations/lago/client.go
@@ -293,7 +293,7 @@ func (c *Client) delete(ctx context.Context, path string) error {
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(resp.Body)
- return fmt.Errorf("lago API error %d: %s", resp.StatusCode, string(respBody))
+ return destinations.StatusError(http.MethodDelete, path, resp.StatusCode, respBody)
}
log.Printf("[lago] DELETE %s -> %d", path, resp.StatusCode)
diff --git a/internal/destinations/metronome/adapter.go b/internal/destinations/metronome/adapter.go
index a7c165d..9db767e 100644
--- a/internal/destinations/metronome/adapter.go
+++ b/internal/destinations/metronome/adapter.go
@@ -2,6 +2,8 @@ package metronome
import (
"context"
+ "crypto/sha256"
+ "encoding/hex"
"errors"
"fmt"
"log"
@@ -9,6 +11,7 @@ import (
"regexp"
"sort"
"strconv"
+ "strings"
"sync"
"time"
@@ -171,6 +174,9 @@ func (a *Adapter) EnsureTenant(ctx context.Context, t usage.Tenant) error {
if err != nil {
return err
}
+ if err := a.ensureStripeLink(ctx, t, cusID); err != nil {
+ return err
+ }
rateCard := t.Plan
if rateCard == "" {
rateCard = a.cfg.MetronomeRateCard
@@ -200,19 +206,14 @@ func (a *Adapter) ensureCustomer(ctx context.Context, t usage.Tenant) (string, e
if req.Name == "" {
req.Name = t.ID
}
+ var stripeID string
if a.stripe != nil {
- stripeID, err := a.stripe.EnsureCustomer(ctx, t)
+ id, err := a.stripe.EnsureCustomer(ctx, t)
if err != nil {
return "", fmt.Errorf("link stripe customer for %s: %w", t.ID, err)
}
- req.BillingProviderConfigs = []BillingProviderConfig{{
- BillingProvider: "stripe",
- DeliveryMethod: "direct_to_billing_provider",
- Configuration: map[string]string{
- "stripe_customer_id": stripeID,
- "stripe_collection_method": a.cfg.MetronomeStripeCollect,
- },
- }}
+ stripeID = id
+ req.BillingProviderConfigs = []BillingProviderConfig{a.stripeConfig(stripeID)}
}
cus, err := a.c.CreateCustomer(ctx, req)
if IsConflict(err) {
@@ -227,6 +228,9 @@ func (a *Adapter) ensureCustomer(ctx context.Context, t usage.Tenant) (string, e
}
log.Printf("[metronome] created customer %s for tenant %s", cus.ID, t.ID)
a.remember(t.ID, cus.ID)
+ if stripeID != "" {
+ _ = a.state.Set("link/"+cus.ID, stripeID)
+ }
return cus.ID, nil
}
@@ -235,34 +239,227 @@ func (a *Adapter) remember(tenant, cus string) {
_ = a.state.Set("tenant/"+cus, tenant)
}
-func (a *Adapter) ensureContract(ctx context.Context, t usage.Tenant, cusID, rateCard string) error {
- if _, ok := a.state.Get("contract/" + t.ID + "/" + rateCard); ok {
+func (a *Adapter) stripeConfig(stripeID string) BillingProviderConfig {
+ return BillingProviderConfig{
+ BillingProvider: "stripe",
+ DeliveryMethod: "direct_to_billing_provider",
+ Configuration: map[string]string{
+ "stripe_customer_id": stripeID,
+ "stripe_collection_method": a.cfg.MetronomeStripeCollect,
+ },
+ }
+}
+
+// ensureStripeLink gives a customer vBilling did not create (found by its
+// alias, or pinned by ID) the Stripe configuration it creates customers with,
+// unless the customer already has one.
+func (a *Adapter) ensureStripeLink(ctx context.Context, t usage.Tenant, cusID string) error {
+ if a.stripe == nil {
+ return nil
+ }
+ if _, ok := a.state.Get("link/" + cusID); ok {
return nil
}
- start := a.now().UTC().Truncate(24 * time.Hour) // UTC midnight
- req := CreateContractRequest{
- CustomerID: cusID,
- StartingAt: rfc3339(start),
- UniquenessKey: "vbilling-" + t.ID + "-" + rateCard,
- UsageStatementSchedule: &UsageStatementSchedule{Frequency: "MONTHLY", Day: "FIRST_OF_MONTH"},
+ cfgs, err := a.c.BillingProviderConfigs(ctx, cusID)
+ if err != nil {
+ return fmt.Errorf("billing provider configurations of %s: %w", cusID, err)
}
- if uuidRe.MatchString(rateCard) {
- req.RateCardID = rateCard
- } else {
- req.RateCardAlias = rateCard
+ for _, c := range cfgs {
+ if c.BillingProvider == "stripe" && c.ArchivedAt == "" {
+ return a.state.Set("link/"+cusID, fmt.Sprint(c.Configuration["stripe_customer_id"]))
+ }
}
- if a.stripe != nil {
- req.BillingProviderConfig = &BillingProviderConfig{BillingProvider: "stripe", DeliveryMethod: "direct_to_billing_provider"}
+ stripeID, err := a.stripe.EnsureCustomer(ctx, t)
+ if err != nil {
+ return fmt.Errorf("link stripe customer for %s: %w", t.ID, err)
}
- id, err := a.c.CreateContract(ctx, req)
- if IsConflict(err) {
- id, err = "existing", nil // uniqueness_key already used: the contract exists
+ if err := a.c.SetBillingProviderConfig(ctx, cusID, a.stripeConfig(stripeID)); err != nil {
+ return fmt.Errorf("link customer %s to stripe: %w", cusID, err)
+ }
+ log.Printf("[metronome] linked customer %s of tenant %s to Stripe customer %s", cusID, t.ID, stripeID)
+ return a.state.Set("link/"+cusID, stripeID)
+}
+
+// Contract uniqueness keys. v0.2.0 used "vbilling--", which
+// cannot be reused once that contract ended, so newer contracts carry their
+// start date (and a counter for a second contract on the same day):
+// "vbilling:@[#n]:".
+const maxUniquenessKey = 128
+
+func rateCardToken(rateCard string) string {
+ if len(rateCard) <= 64 {
+ return rateCard
+ }
+ return "h" + shortHash(rateCard)
+}
+
+func shortHash(s string) string {
+ sum := sha256.Sum256([]byte(s))
+ return hex.EncodeToString(sum[:8])
+}
+
+// contractRateCard returns the rate card token a vBilling contract key of
+// this tenant was created for, or "" for any other contract.
+func contractRateCard(key, tenant string) string {
+ if body, ok := strings.CutPrefix(key, "vbilling:"); ok {
+ i := strings.LastIndex(body, ":")
+ if i < 0 || body[i+1:] != shortHash(tenant) {
+ return ""
+ }
+ if j := strings.LastIndex(body[:i], "@"); j > 0 {
+ return body[:j]
+ }
+ return ""
+ }
+ if rc, ok := strings.CutPrefix(key, "vbilling-"+tenant+"-"); ok {
+ return rateCardToken(rc)
+ }
+ return ""
+}
+
+func parseTime(s string) time.Time {
+ t, _ := time.Parse(time.RFC3339, s)
+ return t
+}
+
+func laterOf(a, b time.Time) time.Time {
+ if a.After(b) {
+ return a
+ }
+ return b
+}
+
+// openAt reports whether a contract is in force at t (or starts later).
+func openAt(c Contract, t time.Time) bool {
+ return c.ArchivedAt == "" && (c.EndingBefore == "" || parseTime(c.EndingBefore).After(t))
+}
+
+// ensureContract keeps one open vBilling contract per tenant, on the tenant's
+// rate card. When the rate card changes, a new contract starts at UTC
+// midnight and every other open vBilling contract of the tenant ends there,
+// so no usage is rated twice. Contracts vBilling did not create are left alone.
+func (a *Adapter) ensureContract(ctx context.Context, t usage.Tenant, cusID, rateCard string) error {
+ if cur, _ := a.state.Get("contract-current/" + t.ID); cur == rateCard {
+ return nil
}
+ all, err := a.c.ListContracts(ctx, cusID)
if err != nil {
- return fmt.Errorf("create contract for %s on %s: %w", t.ID, rateCard, err)
+ return fmt.Errorf("list contracts of %s: %w", t.ID, err)
+ }
+ today := a.now().UTC().Truncate(24 * time.Hour)
+ token := rateCardToken(rateCard)
+ keys := map[string]bool{}
+ var ours []Contract
+ var keep *Contract
+ for _, c := range all {
+ keys[c.UniquenessKey] = true
+ rc := contractRateCard(c.UniquenessKey, t.ID)
+ if rc == "" {
+ continue
+ }
+ ours = append(ours, c)
+ if rc == token && openAt(c, today) && (keep == nil || parseTime(c.StartingAt).Before(parseTime(keep.StartingAt))) {
+ c := c
+ keep = &c
+ }
+ }
+ if keep == nil {
+ if keep, err = a.createContract(ctx, t, cusID, rateCard, today, keys); err != nil {
+ return err
+ }
}
- log.Printf("[metronome] contract %s for tenant %s on rate card %s", id, t.ID, rateCard)
- return a.state.Set("contract/"+t.ID+"/"+rateCard, id)
+ if err := a.endOtherContracts(ctx, t, cusID, ours, *keep, today); err != nil {
+ return err
+ }
+ return a.state.Set("contract-current/"+t.ID, rateCard)
+}
+
+func (a *Adapter) createContract(ctx context.Context, t usage.Tenant, cusID, rateCard string, start time.Time, used map[string]bool) (*Contract, error) {
+ base := "vbilling:" + rateCardToken(rateCard) + "@" + start.Format("2006-01-02")
+ for n := 1; n <= 9; n++ {
+ key := base
+ if n > 1 {
+ key += "#" + strconv.Itoa(n)
+ }
+ key += ":" + shortHash(t.ID)
+ if used[key] {
+ continue // ended or archived earlier today: keys cannot be reused
+ }
+ if len(key) > maxUniquenessKey {
+ return nil, destinations.Permanent(fmt.Errorf("contract key for %s on %s exceeds %d characters", t.ID, rateCard, maxUniquenessKey))
+ }
+ req := CreateContractRequest{
+ CustomerID: cusID,
+ StartingAt: rfc3339(start),
+ UniquenessKey: key,
+ UsageStatementSchedule: &UsageStatementSchedule{Frequency: "MONTHLY", Day: "FIRST_OF_MONTH"},
+ }
+ if uuidRe.MatchString(rateCard) {
+ req.RateCardID = rateCard
+ } else {
+ req.RateCardAlias = rateCard
+ }
+ if a.stripe != nil {
+ req.BillingProviderConfig = &BillingProviderConfig{BillingProvider: "stripe", DeliveryMethod: "direct_to_billing_provider"}
+ }
+ id, err := a.c.CreateContract(ctx, req)
+ if IsConflict(err) {
+ // Created meanwhile, by an earlier attempt or another replica.
+ all, lerr := a.c.ListContracts(ctx, cusID)
+ if lerr != nil {
+ return nil, fmt.Errorf("list contracts of %s: %w", t.ID, lerr)
+ }
+ for _, c := range all {
+ if c.UniquenessKey == key && openAt(c, start) {
+ return &c, nil
+ }
+ }
+ continue
+ }
+ if err != nil {
+ return nil, fmt.Errorf("create contract for %s on %s: %w", t.ID, rateCard, err)
+ }
+ log.Printf("[metronome] contract %s for tenant %s on rate card %s from %s", id, t.ID, rateCard, start.Format("2006-01-02"))
+ return &Contract{ID: id, UniquenessKey: key, StartingAt: rfc3339(start)}, nil
+ }
+ return nil, destinations.Permanent(fmt.Errorf("no free contract key for %s on %s today", t.ID, rateCard))
+}
+
+// endOtherContracts ends every other open vBilling contract of the tenant
+// where keep takes over: at keep's start, or today if keep started earlier
+// (two contracts that overlapped before this version). One that would not
+// have started by then is archived instead.
+func (a *Adapter) endOtherContracts(ctx context.Context, t usage.Tenant, cusID string, ours []Contract, keep Contract, today time.Time) error {
+ at := parseTime(keep.StartingAt)
+ if at.Before(today) {
+ at = today
+ }
+ for _, c := range ours {
+ if c.ID == keep.ID || !openAt(c, at) {
+ continue
+ }
+ start := parseTime(c.StartingAt)
+ var err error
+ if start.Before(at) {
+ err = a.c.EndContract(ctx, cusID, c.ID, at)
+ } else {
+ err = a.c.ArchiveContract(ctx, cusID, c.ID)
+ }
+ switch {
+ case err == nil:
+ log.Printf("[metronome] contract %s of tenant %s ended at %s, where contract %s takes over", c.ID, t.ID, rfc3339(at), keep.ID)
+ if from := laterOf(start, parseTime(keep.StartingAt)); from.Before(at) {
+ log.Printf("[metronome] WARNING: contracts %s and %s of tenant %s both ran from %s to %s: check their invoices for that period",
+ c.ID, keep.ID, t.ID, rfc3339(from), rfc3339(at))
+ }
+ case destinations.IsPermanent(err):
+ // Retrying cannot help: say so once and move on.
+ log.Printf("[metronome] WARNING: could not end contract %s of tenant %s (%v): end it in Metronome", c.ID, t.ID, err)
+ default:
+ return fmt.Errorf("end contract %s of %s: %w", c.ID, t.ID, err)
+ }
+ }
+ return nil
}
// RemoveTenant leaves the customer and contract in place: final usage still
diff --git a/internal/destinations/metronome/adapter_test.go b/internal/destinations/metronome/adapter_test.go
index c00c868..2034141 100644
--- a/internal/destinations/metronome/adapter_test.go
+++ b/internal/destinations/metronome/adapter_test.go
@@ -9,6 +9,7 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "sort"
"strconv"
"strings"
"sync"
@@ -36,7 +37,10 @@ type fakeMetronome struct {
events map[string]IngestEvent // transaction_id -> first accepted
ingests int
contracts map[string]CreateContractRequest // uniqueness key -> request
+ ended map[string]string // uniqueness key -> ending_before
+ archived map[string]bool // uniqueness key -> archived
billing map[string][]BillingProviderConfig
+ billSets int // setCustomerBillingProviderConfigurations calls
failNext map[string]int
hideOnce bool // next alias lookup misses (eventual consistency), forcing a 409 on create
conflicts int
@@ -44,7 +48,8 @@ type fakeMetronome struct {
func newFakeMetronome(t *testing.T) *fakeMetronome {
f := &fakeMetronome{t: t, customers: map[string]*Customer{}, events: map[string]IngestEvent{},
- contracts: map[string]CreateContractRequest{}, billing: map[string][]BillingProviderConfig{}, failNext: map[string]int{}}
+ contracts: map[string]CreateContractRequest{}, ended: map[string]string{}, archived: map[string]bool{},
+ billing: map[string][]BillingProviderConfig{}, failNext: map[string]int{}}
f.srv = httptest.NewServer(http.HandlerFunc(f.handle))
t.Cleanup(f.srv.Close)
return f
@@ -150,6 +155,96 @@ func (f *fakeMetronome) handle(w http.ResponseWriter, r *http.Request) {
f.contracts[req.UniquenessKey] = req
f.reply(w, 200, map[string]any{"data": map[string]string{"id": uuidFor(req.UniquenessKey)}})
+ case "POST /v2/contracts/list":
+ assertMatchesSchema(f.t, "list_contracts_v2", body)
+ var req listContractsRequest
+ json.Unmarshal(body, &req)
+ var keys []string
+ for k, c := range f.contracts {
+ if c.CustomerID == req.CustomerID && (req.IncludeArchived || !f.archived[k]) {
+ keys = append(keys, k)
+ }
+ }
+ sort.Strings(keys)
+ from, _ := strconv.Atoi(req.Cursor)
+ to := from + req.Limit
+ var cursor any
+ if to < len(keys) {
+ cursor = strconv.Itoa(to)
+ } else {
+ to = len(keys)
+ }
+ var data []map[string]any
+ for _, k := range keys[from:to] {
+ c := map[string]any{"id": uuidFor(k), "uniqueness_key": k, "starting_at": f.contracts[k].StartingAt, "customer_id": req.CustomerID}
+ if e := f.ended[k]; e != "" {
+ c["ending_before"] = e
+ }
+ if f.archived[k] {
+ c["archived_at"] = now.Format(time.RFC3339)
+ }
+ data = append(data, c)
+ }
+ f.reply(w, 200, map[string]any{"data": data, "cursor": cursor})
+
+ case "POST /v1/contracts/updateEndDate", "POST /v1/contracts/archive":
+ var req struct {
+ CustomerID string `json:"customer_id"`
+ ContractID string `json:"contract_id"`
+ EndingBefore string `json:"ending_before"`
+ }
+ json.Unmarshal(body, &req)
+ key := ""
+ for k, c := range f.contracts {
+ if uuidFor(k) == req.ContractID && c.CustomerID == req.CustomerID {
+ key = k
+ }
+ }
+ if key == "" {
+ f.reply(w, 404, map[string]string{"message": "contract not found"})
+ return
+ }
+ if strings.HasSuffix(r.URL.Path, "/archive") {
+ assertMatchesSchema(f.t, "archive_contract", body)
+ f.archived[key] = true
+ } else {
+ assertMatchesSchema(f.t, "update_contract_end_date", body)
+ if req.EndingBefore <= f.contracts[key].StartingAt {
+ f.reply(w, 400, map[string]string{"message": "ending_before must be after starting_at"})
+ return
+ }
+ f.ended[key] = req.EndingBefore
+ }
+ f.reply(w, 200, map[string]any{"data": map[string]string{"id": req.ContractID}})
+
+ case "POST /v1/getCustomerBillingProviderConfigurations":
+ assertMatchesSchema(f.t, "get_customer_billing_provider_configurations", body)
+ var req struct {
+ CustomerID string `json:"customer_id"`
+ }
+ json.Unmarshal(body, &req)
+ var data []map[string]any
+ for i, c := range f.billing[req.CustomerID] {
+ data = append(data, map[string]any{"id": uuidFor(fmt.Sprint(req.CustomerID, i)), "billing_provider": c.BillingProvider,
+ "customer_id": req.CustomerID, "configuration": c.Configuration, "delivery_method": c.DeliveryMethod})
+ }
+ f.reply(w, 200, map[string]any{"data": data})
+
+ case "POST /v1/setCustomerBillingProviderConfigurations":
+ assertMatchesSchema(f.t, "set_customer_billing_provider_configurations", body)
+ var req struct {
+ Data []struct {
+ CustomerID string `json:"customer_id"`
+ BillingProviderConfig
+ } `json:"data"`
+ }
+ json.Unmarshal(body, &req)
+ f.billSets++
+ for _, d := range req.Data {
+ f.billing[d.CustomerID] = append(f.billing[d.CustomerID], d.BillingProviderConfig)
+ }
+ f.reply(w, 200, map[string]any{"data": []any{}})
+
case "POST /v1/usage":
assertMatchesSchema(f.t, "usage", body)
var req UsageRequest
@@ -289,7 +384,7 @@ func TestEnsureTenantCustomerAndContract(t *testing.T) {
if err := b.EnsureTenant(ctx, usage.Tenant{ID: "beta", Plan: rc}); err != nil {
t.Fatal(err)
}
- if got := f.contracts["vbilling-beta-"+rc]; got.RateCardID != rc || got.RateCardAlias != "" {
+ if got := f.contracts["vbilling:"+rc+"@2026-10-05:"+shortHash("beta")]; got.RateCardID != rc || got.RateCardAlias != "" {
t.Fatalf("uuid rate card: %+v", got)
}
}
@@ -466,3 +561,148 @@ func TestVerifySignatureOfficialVector(t *testing.T) {
t.Fatalf("fresh signature rejected: %v", err)
}
}
+
+// contract finds a contract's uniqueness key by rate card and start day.
+func (f *fakeMetronome) contract(t *testing.T, rateCard, day string) string {
+ t.Helper()
+ for k, c := range f.contracts {
+ if c.RateCardAlias == rateCard && c.StartingAt == day+"T00:00:00Z" {
+ return k
+ }
+ }
+ t.Fatalf("no contract on %s from %s in %v", rateCard, day, f.contracts)
+ return ""
+}
+
+func TestRateCardChangeHandsOverContract(t *testing.T) {
+ f := newFakeMetronome(t)
+ a := newAdapter(t, f, nil, func(c *config.Config) { c.MetronomeRateCard = "payg" })
+ ctx := context.Background()
+ at := func(day int) { a.now = func() time.Time { return now.AddDate(0, 0, day) } }
+ ensure := func(plan string) {
+ t.Helper()
+ if err := a.EnsureTenant(ctx, usage.Tenant{ID: "acme", Plan: plan}); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ ensure("") // payg from 2026-10-05
+ payg := f.contract(t, "payg", "2026-10-05")
+
+ at(1) // moved to a committed rate card the next day
+ ensure("committed")
+ committed := f.contract(t, "committed", "2026-10-06")
+ if f.ended[payg] != "2026-10-06T00:00:00Z" || f.ended[committed] != "" {
+ t.Fatalf("handover: payg ends %q, committed ends %q", f.ended[payg], f.ended[committed])
+ }
+ ensure("committed") // steady state: no API calls, nothing changes
+ if len(f.contracts) != 2 {
+ t.Fatalf("contracts: %d", len(f.contracts))
+ }
+
+ at(3) // back to payg: its old key is used up, so a new contract starts
+ ensure("payg")
+ payg2 := f.contract(t, "payg", "2026-10-08")
+ if f.ended[committed] != "2026-10-08T00:00:00Z" || f.ended[payg] != "2026-10-06T00:00:00Z" || f.ended[payg2] != "" {
+ t.Fatalf("second handover: %v", f.ended)
+ }
+
+ ensure("committed") // and straight back the same day: payg2 never ran, so it is archived
+ if !f.archived[payg2] {
+ t.Fatal("same-day contract not archived")
+ }
+ committed2 := f.contract(t, "committed", "2026-10-08")
+ if f.archived[committed2] || f.ended[committed2] != "" {
+ t.Fatal("new committed contract not open")
+ }
+ ensure("payg") // a third switch that day needs a fresh key: "#2"
+ var fresh int
+ for k, c := range f.contracts {
+ if c.RateCardAlias == "payg" && !f.archived[k] && f.ended[k] == "" {
+ fresh++
+ if !strings.Contains(k, "@2026-10-08#2:") {
+ t.Fatalf("expected a #2 key, got %s", k)
+ }
+ }
+ }
+ if fresh != 1 || !f.archived[committed2] {
+ t.Fatalf("open payg contracts %d, committed2 archived %v", fresh, f.archived[committed2])
+ }
+
+ // A second replica (or lost state) adopts the open contract instead of creating one.
+ b := newAdapter(t, f, nil)
+ b.now = a.now
+ n := len(f.contracts)
+ if err := b.EnsureTenant(ctx, usage.Tenant{ID: "acme", Plan: "payg"}); err != nil {
+ t.Fatal(err)
+ }
+ if len(f.contracts) != n {
+ t.Fatal("second replica created a contract")
+ }
+}
+
+func TestUpgradeAdoptsLegacyContractAndEndsOverlap(t *testing.T) {
+ f := newFakeMetronome(t)
+ a := newAdapter(t, f, nil)
+ ctx := context.Background()
+ cus := uuidFor("acme")
+ f.customers[cus] = &Customer{ID: cus, Name: "acme", IngestAliases: []string{"acme"}}
+ // v0.2.0 left two open contracts after a plan change, next to one vBilling
+ // does not own and enough unrelated ones to need several pages.
+ f.contracts["vbilling-acme-payg"] = CreateContractRequest{CustomerID: cus, StartingAt: "2026-09-20T00:00:00Z", RateCardAlias: "payg", UniquenessKey: "vbilling-acme-payg"}
+ f.contracts["vbilling-acme-committed"] = CreateContractRequest{CustomerID: cus, StartingAt: "2026-09-28T00:00:00Z", RateCardAlias: "committed", UniquenessKey: "vbilling-acme-committed"}
+ f.contracts["sales-deal-7"] = CreateContractRequest{CustomerID: cus, StartingAt: "2026-09-01T00:00:00Z", RateCardAlias: "enterprise", UniquenessKey: "sales-deal-7"}
+ for i := 0; i < 25; i++ {
+ k := fmt.Sprintf("old-%02d", i)
+ f.contracts[k] = CreateContractRequest{CustomerID: cus, StartingAt: "2026-01-01T00:00:00Z", UniquenessKey: k}
+ f.ended[k] = "2026-02-01T00:00:00Z"
+ }
+ n := len(f.contracts)
+
+ if err := a.EnsureTenant(ctx, usage.Tenant{ID: "acme", Plan: "committed"}); err != nil {
+ t.Fatal(err)
+ }
+ if len(f.contracts) != n {
+ t.Fatal("a legacy contract on the current rate card should be adopted, not replaced")
+ }
+ if f.ended["vbilling-acme-payg"] != "2026-10-05T00:00:00Z" {
+ t.Fatalf("overlapping legacy contract ends %q", f.ended["vbilling-acme-payg"])
+ }
+ if f.ended["vbilling-acme-committed"] != "" || f.ended["sales-deal-7"] != "" || f.archived["sales-deal-7"] {
+ t.Fatal("touched the adopted contract or one vBilling does not own")
+ }
+}
+
+func TestStripeLinkAddedToExistingCustomer(t *testing.T) {
+ f := newFakeMetronome(t)
+ ss, created := stripeStub(t)
+ link := stripe.New(stripe.NewClient(ss.URL, "sk_test_x", "2026-09-30.endive", 0), &config.Config{}, kv.Memory())
+ ctx := context.Background()
+ // Created before the Stripe link was turned on.
+ pre := &Customer{ID: uuidFor("acme"), Name: "acme", IngestAliases: []string{"acme"}}
+ f.customers[pre.ID] = pre
+ a := newAdapter(t, f, link)
+ for i := 0; i < 2; i++ {
+ if err := a.EnsureTenant(ctx, usage.Tenant{ID: "acme"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ cfgs := f.billing[pre.ID]
+ if f.billSets != 1 || len(cfgs) != 1 || cfgs[0].BillingProvider != "stripe" || cfgs[0].Configuration["stripe_customer_id"] != "cus_linked" {
+ t.Fatalf("link: sets=%d configs=%+v", f.billSets, cfgs)
+ }
+ if *created != 1 {
+ t.Fatalf("stripe customers created: %d", *created)
+ }
+ // A customer that already has a Stripe configuration is left as it is.
+ other := &Customer{ID: uuidFor("globex"), Name: "globex", IngestAliases: []string{"globex"}}
+ f.customers[other.ID] = other
+ f.billing[other.ID] = []BillingProviderConfig{{BillingProvider: "stripe", DeliveryMethod: "direct_to_billing_provider",
+ Configuration: map[string]string{"stripe_customer_id": "cus_theirs"}}}
+ if err := a.EnsureTenant(ctx, usage.Tenant{ID: "globex"}); err != nil {
+ t.Fatal(err)
+ }
+ if f.billSets != 1 || len(f.billing[other.ID]) != 1 {
+ t.Fatal("re-linked a customer that already had Stripe")
+ }
+}
diff --git a/internal/destinations/metronome/client.go b/internal/destinations/metronome/client.go
index 2d00be5..b4eab1e 100644
--- a/internal/destinations/metronome/client.go
+++ b/internal/destinations/metronome/client.go
@@ -264,6 +264,100 @@ func (c *Client) CreateContract(ctx context.Context, req CreateContractRequest)
return resp.Data.ID, nil
}
+// Contract is the part of a contract vBilling reads back.
+type Contract struct {
+ ID string `json:"id"`
+ UniquenessKey string `json:"uniqueness_key"`
+ StartingAt string `json:"starting_at"`
+ EndingBefore string `json:"ending_before"`
+ ArchivedAt string `json:"archived_at"`
+}
+
+type listContractsRequest struct {
+ CustomerID string `json:"customer_id"`
+ IncludeArchived bool `json:"include_archived"`
+ Limit int `json:"limit"`
+ Cursor string `json:"cursor,omitempty"`
+}
+
+// ListContracts pages through a customer's contracts, archived ones included.
+func (c *Client) ListContracts(ctx context.Context, customerID string) ([]Contract, error) {
+ var out []Contract
+ req := listContractsRequest{CustomerID: customerID, IncludeArchived: true, Limit: 20}
+ for page := 0; page < 100; page++ {
+ var resp struct {
+ Data []Contract `json:"data"`
+ Cursor *string `json:"cursor"`
+ }
+ if err := c.do(ctx, http.MethodPost, "/v2/contracts/list", nil, req, &resp); err != nil {
+ return nil, err
+ }
+ out = append(out, resp.Data...)
+ if resp.Cursor == nil || *resp.Cursor == "" {
+ break
+ }
+ req.Cursor = *resp.Cursor
+ }
+ return out, nil
+}
+
+// EndContract sets a contract's (exclusive) end.
+func (c *Client) EndContract(ctx context.Context, customerID, contractID string, endingBefore time.Time) error {
+ req := struct {
+ CustomerID string `json:"customer_id"`
+ ContractID string `json:"contract_id"`
+ EndingBefore string `json:"ending_before"`
+ }{customerID, contractID, rfc3339(endingBefore)}
+ return c.do(ctx, http.MethodPost, "/v1/contracts/updateEndDate", nil, req, nil)
+}
+
+// ArchiveContract removes a contract that should never have run. Finalized
+// invoices are kept.
+func (c *Client) ArchiveContract(ctx context.Context, customerID, contractID string) error {
+ req := struct {
+ CustomerID string `json:"customer_id"`
+ ContractID string `json:"contract_id"`
+ VoidInvoices bool `json:"void_invoices"`
+ }{customerID, contractID, false}
+ return c.do(ctx, http.MethodPost, "/v1/contracts/archive", nil, req, nil)
+}
+
+// --- billing provider configurations ---
+
+// CustomerBillingConfig is a billing provider configuration of a customer.
+type CustomerBillingConfig struct {
+ ID string `json:"id"`
+ BillingProvider string `json:"billing_provider"`
+ Configuration map[string]any `json:"configuration"`
+ ArchivedAt string `json:"archived_at"`
+}
+
+// BillingProviderConfigs lists a customer's active billing provider configurations.
+func (c *Client) BillingProviderConfigs(ctx context.Context, customerID string) ([]CustomerBillingConfig, error) {
+ req := struct {
+ CustomerID string `json:"customer_id"`
+ }{customerID}
+ var resp struct {
+ Data []CustomerBillingConfig `json:"data"`
+ }
+ if err := c.do(ctx, http.MethodPost, "/v1/getCustomerBillingProviderConfigurations", nil, req, &resp); err != nil {
+ return nil, err
+ }
+ return resp.Data, nil
+}
+
+// SetBillingProviderConfig adds a billing provider configuration to a customer.
+func (c *Client) SetBillingProviderConfig(ctx context.Context, customerID string, cfg BillingProviderConfig) error {
+ type entry struct {
+ CustomerID string `json:"customer_id"`
+ BillingProviderConfig
+ }
+ req := struct {
+ Data []entry `json:"data"`
+ }{[]entry{{customerID, cfg}}}
+ return c.do(ctx, http.MethodPost, "/v1/setCustomerBillingProviderConfigurations", nil, req, nil)
+}
+
// --- usage (reconciliation) ---
type UsageRequest struct {
diff --git a/internal/destinations/metronome/testdata/openapi-request-schemas.json b/internal/destinations/metronome/testdata/openapi-request-schemas.json
index 44010f1..59d6b9a 100644
--- a/internal/destinations/metronome/testdata/openapi-request-schemas.json
+++ b/internal/destinations/metronome/testdata/openapi-request-schemas.json
@@ -1 +1 @@
-{"create_billable_metric": {"properties": {"aggregation_key": {"type": "string"}, "aggregation_type": {"enum": ["count", "Count", "COUNT", "latest", "Latest", "LATEST", "max", "Max", "MAX", "sum", "Sum", "SUM", "unique", "Unique", "UNIQUE"], "type": "string"}, "custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "event_type_filter": {"properties": {"in_values": {"items": {"type": "string"}, "type": "array"}, "not_in_values": {"items": {"type": "string"}, "type": "array"}}, "type": "object"}, "group_keys": {"items": {"items": {"type": "string"}, "type": "array"}, "type": "array"}, "name": {"type": "string"}, "property_filters": {"items": {"properties": {"exists": {"type": "boolean"}, "in_values": {"items": {"type": "string"}, "type": "array"}, "name": {"type": "string"}, "not_in_values": {"items": {"type": "string"}, "type": "array"}}, "required": ["name"], "type": "object"}, "type": "array"}, "sql": {"type": "string"}}, "required": ["name"], "type": "object"}, "create_contract": {"properties": {"billing_provider_configuration": {"properties": {"billing_provider": {"enum": ["aws_marketplace", "azure_marketplace", "gcp_marketplace", "stripe", "netsuite"], "type": "string"}, "billing_provider_configuration_id": {"format": "uuid", "type": "string"}, "delivery_method": {"enum": ["direct_to_billing_provider", "aws_sqs", "tackle", "aws_sns"], "type": "string"}}, "type": "object"}, "commits": {"items": {"properties": {"access_schedule": {"properties": {"access_type": {"enum": ["SPEND", "spend", "QUANTITY", "quantity"], "type": "string"}, "credit_type_id": {"format": "uuid", "type": "string"}, "schedule_items": {"items": {"properties": {"amount": {"$truncated": true}, "ending_before": {"$truncated": true}, "starting_at": {"$truncated": true}}, "required": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "object"}, "type": "array"}}, "required": ["schedule_items"], "type": "object"}, "amount": {"type": "number"}, "applicable_product_ids": {"items": {"format": "uuid", "type": "string"}, "type": "array"}, "applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "hierarchy_configuration": {"properties": {"child_access": {"oneOf": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}]}}, "required": ["child_access"], "type": "object"}, "invoice_schedule": {"properties": {"credit_type_id": {"format": "uuid", "type": "string"}, "do_not_invoice": {"default": false, "type": "boolean"}, "recurring_schedule": {"properties": {"amount": {"type": {"$truncated": true}}, "amount_distribution": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "ending_before": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "frequency": {"$truncated": true}, "quantity": {"type": {"$truncated": true}}, "starting_at": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "unit_price": {"type": {"$truncated": true}}}, "required": ["starting_at", "ending_before", "frequency", "amount_distribution"], "type": "object"}, "schedule_items": {"items": {"properties": {"amount": {"$truncated": true}, "quantity": {"$truncated": true}, "timestamp": {"$truncated": true}, "unit_price": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "type": "array"}}, "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "priority": {"type": "number"}, "product_id": {"format": "uuid", "type": "string"}, "rate_type": {"enum": ["COMMIT_RATE", "commit_rate", "LIST_RATE", "list_rate"], "type": "string"}, "rollover_fraction": {"type": "number"}, "specifiers": {"items": {"properties": {"exclude": {"items": {"$truncated": true}, "type": "array", "x-mint": {"groups": {"$truncated": true}}, "x-stainless-skip": true}, "presentation_group_values": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "pricing_group_values": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "product_id": {"format": "uuid", "type": "string"}, "product_tags": {"items": {"type": {"$truncated": true}}, "type": "array"}}, "type": "object"}, "type": "array"}, "spend_tracker_attributes": {"allOf": [{"properties": {"counts_as_discounted": {"type": {"$truncated": true}}}, "required": ["counts_as_discounted"], "type": "object"}], "x-mint": {"groups": ["ff:tb-spend-trackers"]}}, "temporary_id": {"type": "string"}, "type": {"enum": ["PREPAID", "prepaid", "POSTPAID", "postpaid"], "type": "string"}}, "required": ["type", "product_id"], "type": "object"}, "type": "array"}, "credits": {"items": {"properties": {"access_schedule": {"properties": {"access_type": {"enum": ["SPEND", "spend", "QUANTITY", "quantity"], "type": "string"}, "credit_type_id": {"format": "uuid", "type": "string"}, "schedule_items": {"items": {"properties": {"amount": {"$truncated": true}, "ending_before": {"$truncated": true}, "starting_at": {"$truncated": true}}, "required": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "object"}, "type": "array"}}, "required": ["schedule_items"], "type": "object"}, "applicable_product_ids": {"items": {"format": "uuid", "type": "string"}, "type": "array"}, "applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "hierarchy_configuration": {"properties": {"child_access": {"oneOf": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}]}}, "required": ["child_access"], "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "priority": {"type": "number"}, "product_id": {"format": "uuid", "type": "string"}, "rate_type": {"enum": ["COMMIT_RATE", "commit_rate", "LIST_RATE", "list_rate"], "type": "string"}, "rollover_fraction": {"type": "number"}, "specifiers": {"items": {"properties": {"exclude": {"items": {"$truncated": true}, "type": "array", "x-mint": {"groups": {"$truncated": true}}, "x-stainless-skip": true}, "presentation_group_values": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "pricing_group_values": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "product_id": {"format": "uuid", "type": "string"}, "product_tags": {"items": {"type": {"$truncated": true}}, "type": "array"}}, "type": "object"}, "type": "array"}}, "required": ["product_id", "access_schedule"], "type": "object"}, "type": "array"}, "custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "customer_id": {"format": "uuid", "type": "string"}, "discounts": {"items": {"properties": {"custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "product_id": {"format": "uuid", "type": "string"}, "schedule": {"properties": {"credit_type_id": {"format": "uuid", "type": "string"}, "do_not_invoice": {"default": false, "type": "boolean"}, "recurring_schedule": {"properties": {"amount": {"type": {"$truncated": true}}, "amount_distribution": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "ending_before": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "frequency": {"$truncated": true}, "quantity": {"type": {"$truncated": true}}, "starting_at": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "unit_price": {"type": {"$truncated": true}}}, "required": ["starting_at", "ending_before", "frequency", "amount_distribution"], "type": "object"}, "schedule_items": {"items": {"properties": {"amount": {"$truncated": true}, "quantity": {"$truncated": true}, "timestamp": {"$truncated": true}, "unit_price": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "type": "array"}}, "type": "object"}}, "required": ["product_id", "schedule"], "type": "object"}, "type": "array", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "ending_before": {"format": "date-time", "type": "string"}, "hierarchy_configuration": {"properties": {"parent": {"properties": {"contract_id": {"format": "uuid", "type": "string"}, "customer_id": {"format": "uuid", "type": "string"}}, "required": ["contract_id", "customer_id"], "type": "object"}, "parent_behavior": {"properties": {"invoice_consolidation_type": {"enum": ["CONCATENATE", "NONE", "concatenate", "none"], "type": "string"}}, "type": "object"}, "payer": {"enum": ["SELF", "PARENT", "self", "parent"], "type": "string"}, "usage_statement_behavior": {"enum": ["CONSOLIDATE", "SEPARATE", "consolidate", "separate"], "type": "string"}}, "type": "object"}, "multiplier_override_prioritization": {"enum": ["LOWEST_MULTIPLIER", "lowest_multiplier", "EXPLICIT", "explicit"], "type": "string"}, "name": {"type": "string"}, "net_payment_terms_days": {"type": "number"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "overrides": {"items": {"properties": {"applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "ending_before": {"format": "date-time", "type": "string"}, "entitled": {"type": "boolean"}, "is_commit_specific": {"type": "boolean"}, "multiplier": {"type": "number"}, "override_specifiers": {"items": {"properties": {"any_commit_or_credit_ids": {"items": {"type": {"$truncated": true}}, "type": "array"}, "billing_frequency": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}, "commit_ids": {"items": {"type": {"$truncated": true}}, "type": "array"}, "exclude": {"items": {"$truncated": true}, "type": "array", "x-mint": {"groups": {"$truncated": true}}, "x-stainless-skip": true}, "presentation_group_values": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "pricing_group_values": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "product_id": {"format": "uuid", "type": "string"}, "product_tags": {"items": {"type": {"$truncated": true}}, "type": "array"}, "recurring_commit_ids": {"items": {"type": {"$truncated": true}}, "type": "array"}}, "type": "object"}, "type": "array"}, "overwrite_rate": {"properties": {"credit_type_id": {"format": "uuid", "type": "string"}, "custom_rate": {"additionalProperties": true, "type": "object", "x-mint": {"groups": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}]}}, "is_prorated": {"type": "boolean", "x-mint": {"groups": [{"$truncated": true}]}}, "minimum_config": {"properties": {"minimum": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "price": {"type": "number"}, "quantity": {"type": "number", "x-mint": {"groups": [{"$truncated": true}]}}, "rate_type": {"enum": ["FLAT", "flat", "PERCENTAGE", "percentage", "SUBSCRIPTION", "subscription", "TIERED", "tiered", "TIERED_PERCENTAGE", "tiered_percentage", "CUSTOM", "custom"], "type": "string", "x-mint-enum": {"CUSTOM": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "SUBSCRIPTION": [{"$truncated": true}], "TIERED_PERCENTAGE": [{"$truncated": true}], "custom": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "subscription": [{"$truncated": true}], "tiered_percentage": [{"$truncated": true}]}}, "tiers": {"items": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}}, "required": ["rate_type"], "type": "object"}, "priority": {"type": "number"}, "product_id": {"format": "uuid", "type": "string"}, "starting_at": {"format": "date-time", "type": "string"}, "target": {"enum": ["COMMIT_RATE", "commit_rate", "LIST_RATE", "list_rate"], "type": "string"}, "tiers": {"items": {"properties": {"multiplier": {"type": "number"}, "size": {"type": "number"}}, "required": ["multiplier"], "type": "object"}, "type": "array"}, "type": {"enum": ["OVERWRITE", "overwrite", "MULTIPLIER", "multiplier", "TIERED", "tiered"], "type": "string"}}, "required": ["starting_at"], "type": "object"}, "type": "array"}, "package_alias": {"type": "string"}, "package_customizations": {"properties": {"add_commits": {"items": {"properties": {"access_schedule": {"properties": {"access_type": {"$truncated": true}, "credit_type_id": {"$truncated": true}, "schedule_items": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "amount": {"type": "number"}, "applicable_product_ids": {"items": {"format": "uuid", "type": "string"}, "type": "array"}, "applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "custom_fields": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "hierarchy_configuration": {"properties": {"child_access": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "invoice_schedule": {"properties": {"credit_type_id": {"$truncated": true}, "do_not_invoice": {"$truncated": true}, "recurring_schedule": {"$truncated": true}, "schedule_items": {"$truncated": true}}, "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": [{"$truncated": true}]}}, "priority": {"type": "number"}, "product_id": {"format": "uuid", "type": "string"}, "rate_type": {"enum": ["COMMIT_RATE", "commit_rate", "LIST_RATE", "list_rate"], "type": "string"}, "rollover_fraction": {"type": "number"}, "specifiers": {"items": {"properties": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}, "spend_tracker_attributes": {"allOf": [{"$truncated": true}], "x-mint": {"groups": [{"$truncated": true}]}}, "temporary_id": {"type": "string"}, "type": {"enum": ["PREPAID", "prepaid", "POSTPAID", "postpaid"], "type": "string"}}, "required": ["type", "product_id"], "type": "object"}, "type": "array"}, "add_credits": {"items": {"properties": {"access_schedule": {"properties": {"access_type": {"$truncated": true}, "credit_type_id": {"$truncated": true}, "schedule_items": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "applicable_product_ids": {"items": {"format": "uuid", "type": "string"}, "type": "array"}, "applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "custom_fields": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "hierarchy_configuration": {"properties": {"child_access": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": [{"$truncated": true}]}}, "priority": {"type": "number"}, "product_id": {"format": "uuid", "type": "string"}, "rate_type": {"enum": ["COMMIT_RATE", "commit_rate", "LIST_RATE", "list_rate"], "type": "string"}, "rollover_fraction": {"type": "number"}, "specifiers": {"items": {"properties": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}}, "required": ["product_id", "access_schedule"], "type": "object"}, "type": "array"}, "add_overrides": {"items": {"properties": {"applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "ending_before": {"format": "date-time", "type": "string"}, "entitled": {"type": "boolean"}, "is_commit_specific": {"type": "boolean"}, "multiplier": {"type": "number"}, "override_specifiers": {"items": {"properties": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}, "overwrite_rate": {"properties": {"credit_type_id": {"$truncated": true}, "custom_rate": {"$truncated": true}, "is_prorated": {"$truncated": true}, "minimum_config": {"$truncated": true}, "price": {"$truncated": true}, "quantity": {"$truncated": true}, "rate_type": {"$truncated": true}, "tiers": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "priority": {"type": "number"}, "product_id": {"format": "uuid", "type": "string"}, "starting_at": {"format": "date-time", "type": "string"}, "target": {"enum": ["COMMIT_RATE", "commit_rate", "LIST_RATE", "list_rate"], "type": "string"}, "tiers": {"items": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}, "type": {"enum": ["OVERWRITE", "overwrite", "MULTIPLIER", "multiplier", "TIERED", "tiered"], "type": "string"}}, "required": ["starting_at"], "type": "object"}, "type": "array"}, "add_recurring_commits": {"items": {"allOf": [{"properties": {"access_amount": {"$truncated": true}, "applicable_product_ids": {"$truncated": true}, "applicable_product_tags": {"$truncated": true}, "commit_duration": {"$truncated": true}, "ending_before": {"$truncated": true}, "hierarchy_configuration": {"$truncated": true}, "name": {"$truncated": true}, "netsuite_sales_order_id": {"$truncated": true}, "priority": {"$truncated": true}, "product_id": {"$truncated": true}, "proration": {"$truncated": true}, "rate_type": {"$truncated": true}, "recurrence_frequency": {"$truncated": true}, "rollover_fraction": {"$truncated": true}, "specifiers": {"$truncated": true}, "starting_at": {"$truncated": true}, "subscription_config": {"$truncated": true}, "temporary_id": {"$truncated": true}}, "required": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "object"}, {"properties": {"invoice_amount": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "proration_rounding": {"properties": {"$truncated": true}, "type": {"$truncated": true}}}, "type": "object"}]}, "type": "array"}, "add_recurring_credits": {"items": {"allOf": [{"properties": {"access_amount": {"$truncated": true}, "applicable_product_ids": {"$truncated": true}, "applicable_product_tags": {"$truncated": true}, "commit_duration": {"$truncated": true}, "ending_before": {"$truncated": true}, "hierarchy_configuration": {"$truncated": true}, "name": {"$truncated": true}, "netsuite_sales_order_id": {"$truncated": true}, "priority": {"$truncated": true}, "product_id": {"$truncated": true}, "proration": {"$truncated": true}, "rate_type": {"$truncated": true}, "recurrence_frequency": {"$truncated": true}, "rollover_fraction": {"$truncated": true}, "specifiers": {"$truncated": true}, "starting_at": {"$truncated": true}, "subscription_config": {"$truncated": true}, "temporary_id": {"$truncated": true}}, "required": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "object"}, {"properties": {"proration_rounding": {"properties": {"$truncated": true}, "type": {"$truncated": true}}}, "type": "object"}]}, "type": "array"}, "add_scheduled_charges": {"items": {"properties": {"custom_fields": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": [{"$truncated": true}]}}, "product_id": {"format": "uuid", "type": "string"}, "schedule": {"properties": {"credit_type_id": {"$truncated": true}, "do_not_invoice": {"$truncated": true}, "recurring_schedule": {"$truncated": true}, "schedule_items": {"$truncated": true}}, "type": "object"}}, "required": ["product_id", "schedule"], "type": "object"}, "type": "array"}, "add_spend_trackers": {"items": {"properties": {"alias": {"type": "string"}, "applicable_spend_specifiers": {"items": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}, "credit_type_id": {"format": "uuid", "type": "string"}, "reset_frequency": {"enum": ["BILLING_PERIOD"], "type": "string"}}, "required": ["alias", "credit_type_id", "reset_frequency", "applicable_spend_specifiers"], "type": "object"}, "type": "array", "x-mint": {"groups": ["ff:tb-spend-trackers"]}}, "add_subscriptions": {"items": {"properties": {"billing_cycle_config": {"properties": {"$truncated": true}, "type": {"$truncated": true}}, "collection_schedule": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}, "custom_fields": {"additionalProperties": {"$truncated": true}, "type": {"$truncated": true}}, "ending_before": {"format": "date-time", "type": "string"}, "initial_quantity": {"type": "number"}, "name": {"type": "string"}, "payment_gate_config": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}, "x-mint": {"$truncated": true}, "x-stainless-skip": {"$truncated": true}}, "proration": {"properties": {"$truncated": true}, "type": {"$truncated": true}}, "quantity_management_mode": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}, "seat_config": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "starting_at": {"format": "date-time", "type": "string"}, "subscription_rate": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "temporary_id": {"type": "string"}}, "required": ["subscription_rate", "collection_schedule", "proration"], "type": "object"}, "type": "array"}, "modify_subscriptions": {"items": {"properties": {"initial_quantity": {"type": "number"}, "initial_seat_ids": {"items": {"type": "string"}, "type": "array"}, "initial_unassigned_seats": {"type": "number"}, "subscription_alias": {"type": "string"}, "template_subscription_id": {"format": "uuid", "type": "string"}}, "type": "object", "x-mint": {"groups": ["ff:package-customizations"]}, "x-stainless-skip": true}, "type": "array"}, "modify_usage_statement_schedule": {"properties": {"billing_anchor_date": {"format": "date-time", "type": "string"}}, "type": "object"}, "set_prepaid_balance_threshold_configuration": {"properties": {"commit": {"allOf": [{"$truncated": true}, {"properties": {"$truncated": true}, "type": {"$truncated": true}}], "type": "object"}, "custom_credit_type_id": {"format": "uuid", "type": "string"}, "discount_configuration": {"properties": {"cap": {"allOf": {"$truncated": true}, "x-mint": {"$truncated": true}}, "payment_fraction": {"type": {"$truncated": true}}}, "required": ["payment_fraction"], "type": "object"}, "is_enabled": {"type": "boolean"}, "payment_gate_config": {"properties": {"payment_gate_type": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "precalculated_tax_config": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "stripe_config": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "tax_type": {"enum": {"$truncated": true}, "type": {"$truncated": true}}}, "required": ["payment_gate_type"], "type": "object"}, "recharge_to_amount": {"type": "number"}, "threshold_amount": {"type": "number"}, "threshold_balance_specifiers": {"items": {"properties": {"exclude": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "type": "array"}}, "required": ["is_enabled", "threshold_amount", "recharge_to_amount", "commit", "payment_gate_config"], "type": "object"}, "set_spend_threshold_configuration": {"properties": {"commit": {"allOf": [{"$truncated": true}], "type": "object"}, "discount_configuration": {"properties": {"cap": {"allOf": {"$truncated": true}, "x-mint": {"$truncated": true}}, "payment_fraction": {"type": {"$truncated": true}}}, "required": ["payment_fraction"], "type": "object"}, "is_enabled": {"type": "boolean"}, "payment_gate_config": {"properties": {"payment_gate_type": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "precalculated_tax_config": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "stripe_config": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "tax_type": {"enum": {"$truncated": true}, "type": {"$truncated": true}}}, "required": ["payment_gate_type"], "type": "object"}, "threshold_amount": {"type": "number"}}, "required": ["is_enabled", "threshold_amount", "commit", "payment_gate_config"], "type": "object"}}, "type": "object", "x-mint": {"groups": ["ff:package-customizations"]}, "x-stainless-skip": true}, "package_id": {"format": "uuid", "type": "string"}, "prepaid_balance_threshold_configuration": {"properties": {"commit": {"allOf": [{"properties": {"name": {"type": {"$truncated": true}}, "priority": {"type": {"$truncated": true}}, "product_id": {"type": {"$truncated": true}}}, "required": ["product_id"], "type": "object"}, {"properties": {"applicable_product_ids": {"items": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}, "applicable_product_tags": {"items": {"type": {"$truncated": true}}, "type": "array"}, "duration": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "rate_type": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}, "rollover_fraction": {"type": "number"}, "specifiers": {"items": {"$truncated": true}, "type": "array", "x-mint": {"groups": {"$truncated": true}}}}, "type": "object"}], "type": "object"}, "custom_credit_type_id": {"format": "uuid", "type": "string"}, "discount_configuration": {"properties": {"cap": {"allOf": [{"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}], "x-mint": {"groups": ["ff:threshold-billing-discounts"]}}, "payment_fraction": {"type": "number"}}, "required": ["payment_fraction"], "type": "object"}, "is_enabled": {"type": "boolean"}, "payment_gate_config": {"properties": {"payment_gate_type": {"enum": ["NONE", "STRIPE", "EXTERNAL"], "type": "string"}, "precalculated_tax_config": {"properties": {"tax_amount": {"type": "number"}, "tax_name": {"type": "string"}}, "required": ["tax_amount"], "type": "object"}, "stripe_config": {"properties": {"invoice_metadata": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "payment_type": {"enum": [{"$truncated": true}, {"$truncated": true}], "type": "string"}}, "required": ["payment_type"], "type": "object"}, "tax_type": {"enum": ["NONE", "STRIPE", "ANROK", "PRECALCULATED"], "type": "string"}}, "required": ["payment_gate_type"], "type": "object"}, "recharge_to_amount": {"type": "number"}, "threshold_amount": {"type": "number"}, "threshold_balance_specifiers": {"items": {"properties": {"exclude": {"items": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}}, "required": ["exclude"], "type": "object"}, "type": "array"}}, "required": ["is_enabled", "threshold_amount", "recharge_to_amount", "commit", "payment_gate_config"], "type": "object"}, "professional_services": {"items": {"properties": {"custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "max_amount": {"type": "number"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "product_id": {"format": "uuid", "type": "string"}, "quantity": {"type": "number"}, "unit_price": {"type": "number"}}, "required": ["product_id", "unit_price", "quantity", "max_amount"], "type": "object"}, "type": "array", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "rate_card_alias": {"type": "string"}, "rate_card_id": {"format": "uuid", "type": "string"}, "recurring_commits": {"items": {"allOf": [{"properties": {"access_amount": {"properties": {"access_type": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "credit_type_id": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "quantity": {"type": {"$truncated": true}}, "unit_price": {"type": {"$truncated": true}}}, "required": ["unit_price"], "type": "object"}, "applicable_product_ids": {"items": {"format": "uuid", "type": "string"}, "type": "array"}, "applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "commit_duration": {"properties": {"unit": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "value": {"type": {"$truncated": true}}}, "required": ["value"], "type": "object"}, "ending_before": {"format": "date-time", "type": "string"}, "hierarchy_configuration": {"properties": {"child_access": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": [{"$truncated": true}]}}, "priority": {"type": "number"}, "product_id": {"format": "uuid", "type": "string"}, "proration": {"enum": ["NONE", "none", "FIRST", "first", "LAST", "last", "FIRST_AND_LAST", "first_and_last"], "type": "string"}, "rate_type": {"enum": ["COMMIT_RATE", "commit_rate", "LIST_RATE", "list_rate"], "type": "string"}, "recurrence_frequency": {"enum": ["MONTHLY", "monthly", "QUARTERLY", "quarterly", "ANNUAL", "annual", "WEEKLY", "weekly", "DAILY", "daily"], "type": "string"}, "rollover_fraction": {"type": "number"}, "specifiers": {"items": {"properties": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}, "starting_at": {"format": "date-time", "type": "string"}, "subscription_config": {"properties": {"access_policy": {"$truncated": true}, "allocation": {"$truncated": true}, "apply_seat_increase_config": {"$truncated": true}, "subscription_id": {"$truncated": true}}, "required": [{"$truncated": true}, {"$truncated": true}], "type": "object"}, "temporary_id": {"type": "string"}}, "required": ["product_id", "access_amount", "priority", "commit_duration", "starting_at"], "type": "object"}, {"properties": {"invoice_amount": {"properties": {"credit_type_id": {"format": "uuid", "type": "string"}, "quantity": {"type": "number"}, "unit_price": {"type": "number"}}, "required": ["unit_price", "quantity", "credit_type_id"], "type": "object"}, "proration_rounding": {"properties": {"access": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "invoice": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}}, "type": "object"}}, "type": "object"}]}, "type": "array"}, "recurring_credits": {"items": {"allOf": [{"properties": {"access_amount": {"properties": {"access_type": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "credit_type_id": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "quantity": {"type": {"$truncated": true}}, "unit_price": {"type": {"$truncated": true}}}, "required": ["unit_price"], "type": "object"}, "applicable_product_ids": {"items": {"format": "uuid", "type": "string"}, "type": "array"}, "applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "commit_duration": {"properties": {"unit": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "value": {"type": {"$truncated": true}}}, "required": ["value"], "type": "object"}, "ending_before": {"format": "date-time", "type": "string"}, "hierarchy_configuration": {"properties": {"child_access": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": [{"$truncated": true}]}}, "priority": {"type": "number"}, "product_id": {"format": "uuid", "type": "string"}, "proration": {"enum": ["NONE", "none", "FIRST", "first", "LAST", "last", "FIRST_AND_LAST", "first_and_last"], "type": "string"}, "rate_type": {"enum": ["COMMIT_RATE", "commit_rate", "LIST_RATE", "list_rate"], "type": "string"}, "recurrence_frequency": {"enum": ["MONTHLY", "monthly", "QUARTERLY", "quarterly", "ANNUAL", "annual", "WEEKLY", "weekly", "DAILY", "daily"], "type": "string"}, "rollover_fraction": {"type": "number"}, "specifiers": {"items": {"properties": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}, "starting_at": {"format": "date-time", "type": "string"}, "subscription_config": {"properties": {"access_policy": {"$truncated": true}, "allocation": {"$truncated": true}, "apply_seat_increase_config": {"$truncated": true}, "subscription_id": {"$truncated": true}}, "required": [{"$truncated": true}, {"$truncated": true}], "type": "object"}, "temporary_id": {"type": "string"}}, "required": ["product_id", "access_amount", "priority", "commit_duration", "starting_at"], "type": "object"}, {"properties": {"proration_rounding": {"properties": {"access": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}}, "type": "object"}}, "type": "object"}]}, "type": "array"}, "reseller_royalties": {"items": {"properties": {"applicable_product_ids": {"items": {"format": "uuid", "type": "string"}, "type": "array"}, "applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "aws_options": {"properties": {"aws_account_number": {"type": "string"}, "aws_offer_id": {"type": "string"}, "aws_payer_reference_id": {"type": "string"}}, "type": "object"}, "ending_before": {"format": "date-time", "type": "string"}, "fraction": {"type": "number"}, "gcp_options": {"properties": {"gcp_account_id": {"type": "string"}, "gcp_offer_id": {"type": "string"}}, "type": "object"}, "netsuite_reseller_id": {"type": "string"}, "reseller_contract_value": {"type": "number"}, "reseller_type": {"enum": ["AWS", "AWS_PRO_SERVICE", "GCP", "GCP_PRO_SERVICE"], "type": "string"}, "starting_at": {"format": "date-time", "type": "string"}}, "required": ["reseller_type", "starting_at", "fraction", "netsuite_reseller_id"], "type": "object"}, "type": "array", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "revenue_system_configuration": {"properties": {"delivery_method": {"enum": ["direct_to_billing_provider"], "type": "string", "x-mint": {"groups": ["ff:revenue-rec-configurations-enabled"]}}, "provider": {"enum": ["netsuite"], "type": "string", "x-mint": {"groups": ["ff:revenue-rec-configurations-enabled"]}}, "revenue_system_configuration_id": {"format": "uuid", "type": "string"}}, "type": "object", "x-mint": {"groups": ["ff:revenue-rec-configurations-enabled"]}}, "salesforce_opportunity_id": {"type": "string", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "scheduled_charges": {"items": {"properties": {"custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "product_id": {"format": "uuid", "type": "string"}, "schedule": {"properties": {"credit_type_id": {"format": "uuid", "type": "string"}, "do_not_invoice": {"default": false, "type": "boolean"}, "recurring_schedule": {"properties": {"amount": {"type": {"$truncated": true}}, "amount_distribution": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "ending_before": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "frequency": {"$truncated": true}, "quantity": {"type": {"$truncated": true}}, "starting_at": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "unit_price": {"type": {"$truncated": true}}}, "required": ["starting_at", "ending_before", "frequency", "amount_distribution"], "type": "object"}, "schedule_items": {"items": {"properties": {"amount": {"$truncated": true}, "quantity": {"$truncated": true}, "timestamp": {"$truncated": true}, "unit_price": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "type": "array"}}, "type": "object"}}, "required": ["product_id", "schedule"], "type": "object"}, "type": "array"}, "scheduled_charges_on_usage_invoices": {"enum": ["ALL"], "type": "string"}, "spend_threshold_configuration": {"properties": {"commit": {"allOf": [{"properties": {"name": {"type": {"$truncated": true}}, "priority": {"type": {"$truncated": true}}, "product_id": {"type": {"$truncated": true}}}, "required": ["product_id"], "type": "object"}], "type": "object"}, "discount_configuration": {"properties": {"cap": {"allOf": [{"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}], "x-mint": {"groups": ["ff:threshold-billing-discounts"]}}, "payment_fraction": {"type": "number"}}, "required": ["payment_fraction"], "type": "object"}, "is_enabled": {"type": "boolean"}, "payment_gate_config": {"properties": {"payment_gate_type": {"enum": ["NONE", "STRIPE", "EXTERNAL"], "type": "string"}, "precalculated_tax_config": {"properties": {"tax_amount": {"type": "number"}, "tax_name": {"type": "string"}}, "required": ["tax_amount"], "type": "object"}, "stripe_config": {"properties": {"invoice_metadata": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "payment_type": {"enum": [{"$truncated": true}, {"$truncated": true}], "type": "string"}}, "required": ["payment_type"], "type": "object"}, "tax_type": {"enum": ["NONE", "STRIPE", "ANROK", "PRECALCULATED"], "type": "string"}}, "required": ["payment_gate_type"], "type": "object"}, "threshold_amount": {"type": "number"}}, "required": ["is_enabled", "threshold_amount", "commit", "payment_gate_config"], "type": "object"}, "spend_trackers": {"items": {"properties": {"alias": {"type": "string"}, "applicable_spend_specifiers": {"items": {"properties": {"discounted": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}, "sources": {"items": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}, "spend_type": {"enum": [{"$truncated": true}], "type": "string"}}, "required": ["spend_type", "sources"], "type": "object"}, "type": "array"}, "credit_type_id": {"format": "uuid", "type": "string"}, "reset_frequency": {"enum": ["BILLING_PERIOD"], "type": "string"}}, "required": ["alias", "credit_type_id", "reset_frequency", "applicable_spend_specifiers"], "type": "object"}, "type": "array", "x-mint": {"groups": ["ff:tb-spend-trackers"]}}, "starting_at": {"format": "date-time", "type": "string"}, "subscriptions": {"items": {"properties": {"billing_cycle_config": {"properties": {"anchor_date": {"format": "date-time", "type": "string"}, "invoice_placement": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}}, "type": "object"}, "collection_schedule": {"enum": ["ADVANCE", "ARREARS", "advance", "arrears"], "type": "string"}, "custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "ending_before": {"format": "date-time", "type": "string"}, "initial_quantity": {"type": "number"}, "name": {"type": "string"}, "payment_gate_config": {"properties": {"payment_gate_type": {"enum": [{"$truncated": true}, {"$truncated": true}], "type": "string"}, "tax_type": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}}, "required": ["payment_gate_type"], "type": "object", "x-mint": {"groups": ["ff:payment-gated-subscriptions-enabled"]}, "x-stainless-skip": true}, "proration": {"properties": {"invoice_behavior": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}, "is_prorated": {"type": "boolean"}, "rounding": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}}, "type": "object"}, "quantity_management_mode": {"enum": ["SEAT_BASED", "seat_based", "QUANTITY_ONLY", "quantity_only"], "type": "string"}, "seat_config": {"properties": {"initial_seat_ids": {"items": {"type": {"$truncated": true}}, "type": "array"}, "initial_unassigned_seats": {"type": "number"}, "seat_group_key": {"type": "string"}}, "required": ["seat_group_key", "initial_seat_ids"], "type": "object"}, "starting_at": {"format": "date-time", "type": "string"}, "subscription_rate": {"properties": {"billing_frequency": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}, "product_id": {"format": "uuid", "type": "string"}}, "required": ["billing_frequency", "product_id"], "type": "object"}, "temporary_id": {"type": "string"}}, "required": ["subscription_rate", "collection_schedule", "proration"], "type": "object"}, "type": "array"}, "total_contract_value": {"type": "number", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "transition": {"properties": {"from_contract_id": {"format": "uuid", "type": "string"}, "future_invoice_behavior": {"properties": {"trueup": {"enum": ["remove", "as_is", "REMOVE", "AS_IS"], "nullable": true, "type": "string"}}, "type": "object"}, "type": {"enum": ["RENEWAL", "renewal"], "type": "string"}}, "required": ["type", "from_contract_id"], "type": "object"}, "uniqueness_key": {"maxLength": 128, "minLength": 1, "type": "string"}, "usage_filter": {"properties": {"group_key": {"type": "string"}, "group_values": {"items": {"type": "string"}, "type": "array"}, "starting_at": {"format": "date-time", "type": "string"}}, "required": ["group_key", "group_values"], "type": "object"}, "usage_statement_schedule": {"properties": {"billing_anchor_date": {"format": "date-time", "type": "string"}, "day": {"enum": ["FIRST_OF_MONTH", "first_of_month", "CONTRACT_START", "contract_start", "CUSTOM_DATE", "custom_date"], "type": "string"}, "frequency": {"enum": ["MONTHLY", "monthly", "QUARTERLY", "quarterly", "ANNUAL", "annual", "WEEKLY", "weekly"], "type": "string"}, "invoice_generation_starting_at": {"format": "date-time", "type": "string"}}, "required": ["frequency"], "type": "object"}}, "required": ["customer_id", "starting_at"], "type": "object"}, "create_customer": {"properties": {"custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "customer_billing_provider_configurations": {"items": {"properties": {"billing_provider": {"enum": ["aws_marketplace", "azure_marketplace", "gcp_marketplace", "stripe", "netsuite"], "type": "string"}, "configuration": {"additionalProperties": true, "type": "object"}, "delivery_method": {"enum": ["direct_to_billing_provider", "aws_sqs", "tackle", "aws_sns"], "type": "string"}, "delivery_method_id": {"format": "uuid", "type": "string"}, "tax_provider": {"enum": ["anrok", "avalara", "stripe"], "type": "string"}}, "required": ["billing_provider"], "type": "object"}, "type": "array"}, "customer_revenue_system_configurations": {"items": {"properties": {"configuration": {"additionalProperties": true, "type": "object"}, "delivery_method": {"enum": ["direct_to_billing_provider"], "type": "string", "x-mint": {"groups": ["ff:revenue-rec-configurations-enabled"]}}, "delivery_method_id": {"format": "uuid", "type": "string"}, "provider": {"enum": ["netsuite"], "type": "string", "x-mint": {"groups": ["ff:revenue-rec-configurations-enabled"]}}}, "required": ["provider"], "type": "object", "x-mint": {"groups": ["ff:revenue-rec-configurations-enabled"]}}, "type": "array", "x-mint": {"groups": ["ff:revenue-rec-configurations-enabled"]}}, "external_id": {"maxLength": 128, "minLength": 1, "type": "string"}, "ingest_aliases": {"items": {"maxLength": 128, "minLength": 1, "type": "string"}, "maxItems": 2000, "type": "array"}, "name": {"type": "string"}}, "required": ["name"], "type": "object"}, "ingest": {"items": {"properties": {"customer_id": {"maxLength": 1024, "minLength": 1, "type": "string"}, "event_type": {"maxLength": 1024, "minLength": 1, "type": "string"}, "properties": {"additionalProperties": true, "type": "object"}, "timestamp": {"type": "string"}, "transaction_id": {"maxLength": 128, "minLength": 1, "type": "string"}}, "required": ["customer_id", "event_type", "timestamp", "transaction_id"], "type": "object"}, "maxItems": 100, "minItems": 1, "type": "array"}, "usage": {"properties": {"billable_metrics": {"items": {"properties": {"group_by": {"properties": {"key": {"type": "string"}, "values": {"items": {"minLength": 1, "type": "string"}, "maxItems": 200, "minItems": 1, "type": "array"}}, "required": ["key"], "type": "object"}, "id": {"format": "uuid", "type": "string"}}, "required": ["id"], "type": "object"}, "type": "array"}, "customer_ids": {"items": {"format": "uuid", "type": "string"}, "type": "array"}, "ending_before": {"format": "date-time", "type": "string"}, "starting_on": {"format": "date-time", "type": "string"}, "window_size": {"enum": ["hour", "day", "none", "HOUR", "DAY", "NONE", "Hour", "Day", "None"], "type": "string"}}, "required": ["window_size", "starting_on", "ending_before"], "type": "object"}}
\ No newline at end of file
+{"archive_contract": {"properties": {"contract_id": {"description": "ID of the contract to archive", "format": "uuid", "type": "string"}, "customer_id": {"description": "ID of the customer whose contract is to be archived", "format": "uuid", "type": "string"}, "void_invoices": {"description": "If false, the existing finalized invoices will remain after the contract is archived.", "type": "boolean"}}, "required": ["customer_id", "contract_id", "void_invoices"], "type": "object"}, "create_billable_metric": {"properties": {"aggregation_key": {"type": "string"}, "aggregation_type": {"enum": ["count", "Count", "COUNT", "latest", "Latest", "LATEST", "max", "Max", "MAX", "sum", "Sum", "SUM", "unique", "Unique", "UNIQUE"], "type": "string"}, "custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "event_type_filter": {"properties": {"in_values": {"items": {"type": "string"}, "type": "array"}, "not_in_values": {"items": {"type": "string"}, "type": "array"}}, "type": "object"}, "group_keys": {"items": {"items": {"type": "string"}, "type": "array"}, "type": "array"}, "name": {"type": "string"}, "property_filters": {"items": {"properties": {"exists": {"type": "boolean"}, "in_values": {"items": {"type": "string"}, "type": "array"}, "name": {"type": "string"}, "not_in_values": {"items": {"type": "string"}, "type": "array"}}, "required": ["name"], "type": "object"}, "type": "array"}, "sql": {"type": "string"}}, "required": ["name"], "type": "object"}, "create_contract": {"properties": {"billing_provider_configuration": {"properties": {"billing_provider": {"enum": ["aws_marketplace", "azure_marketplace", "gcp_marketplace", "stripe", "netsuite"], "type": "string"}, "billing_provider_configuration_id": {"format": "uuid", "type": "string"}, "delivery_method": {"enum": ["direct_to_billing_provider", "aws_sqs", "tackle", "aws_sns"], "type": "string"}}, "type": "object"}, "commits": {"items": {"properties": {"access_schedule": {"properties": {"access_type": {"enum": ["SPEND", "spend", "QUANTITY", "quantity"], "type": "string"}, "credit_type_id": {"format": "uuid", "type": "string"}, "schedule_items": {"items": {"properties": {"amount": {"$truncated": true}, "ending_before": {"$truncated": true}, "starting_at": {"$truncated": true}}, "required": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "object"}, "type": "array"}}, "required": ["schedule_items"], "type": "object"}, "amount": {"type": "number"}, "applicable_product_ids": {"items": {"format": "uuid", "type": "string"}, "type": "array"}, "applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "hierarchy_configuration": {"properties": {"child_access": {"oneOf": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}]}}, "required": ["child_access"], "type": "object"}, "invoice_schedule": {"properties": {"credit_type_id": {"format": "uuid", "type": "string"}, "do_not_invoice": {"default": false, "type": "boolean"}, "recurring_schedule": {"properties": {"amount": {"type": {"$truncated": true}}, "amount_distribution": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "ending_before": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "frequency": {"$truncated": true}, "quantity": {"type": {"$truncated": true}}, "starting_at": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "unit_price": {"type": {"$truncated": true}}}, "required": ["starting_at", "ending_before", "frequency", "amount_distribution"], "type": "object"}, "schedule_items": {"items": {"properties": {"amount": {"$truncated": true}, "quantity": {"$truncated": true}, "timestamp": {"$truncated": true}, "unit_price": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "type": "array"}}, "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "priority": {"type": "number"}, "product_id": {"format": "uuid", "type": "string"}, "rate_type": {"enum": ["COMMIT_RATE", "commit_rate", "LIST_RATE", "list_rate"], "type": "string"}, "rollover_fraction": {"type": "number"}, "specifiers": {"items": {"properties": {"exclude": {"items": {"$truncated": true}, "type": "array", "x-mint": {"groups": {"$truncated": true}}, "x-stainless-skip": true}, "presentation_group_values": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "pricing_group_values": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "product_id": {"format": "uuid", "type": "string"}, "product_tags": {"items": {"type": {"$truncated": true}}, "type": "array"}}, "type": "object"}, "type": "array"}, "spend_tracker_attributes": {"allOf": [{"properties": {"counts_as_discounted": {"type": {"$truncated": true}}}, "required": ["counts_as_discounted"], "type": "object"}], "x-mint": {"groups": ["ff:tb-spend-trackers"]}}, "temporary_id": {"type": "string"}, "type": {"enum": ["PREPAID", "prepaid", "POSTPAID", "postpaid"], "type": "string"}}, "required": ["type", "product_id"], "type": "object"}, "type": "array"}, "credits": {"items": {"properties": {"access_schedule": {"properties": {"access_type": {"enum": ["SPEND", "spend", "QUANTITY", "quantity"], "type": "string"}, "credit_type_id": {"format": "uuid", "type": "string"}, "schedule_items": {"items": {"properties": {"amount": {"$truncated": true}, "ending_before": {"$truncated": true}, "starting_at": {"$truncated": true}}, "required": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "object"}, "type": "array"}}, "required": ["schedule_items"], "type": "object"}, "applicable_product_ids": {"items": {"format": "uuid", "type": "string"}, "type": "array"}, "applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "hierarchy_configuration": {"properties": {"child_access": {"oneOf": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}]}}, "required": ["child_access"], "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "priority": {"type": "number"}, "product_id": {"format": "uuid", "type": "string"}, "rate_type": {"enum": ["COMMIT_RATE", "commit_rate", "LIST_RATE", "list_rate"], "type": "string"}, "rollover_fraction": {"type": "number"}, "specifiers": {"items": {"properties": {"exclude": {"items": {"$truncated": true}, "type": "array", "x-mint": {"groups": {"$truncated": true}}, "x-stainless-skip": true}, "presentation_group_values": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "pricing_group_values": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "product_id": {"format": "uuid", "type": "string"}, "product_tags": {"items": {"type": {"$truncated": true}}, "type": "array"}}, "type": "object"}, "type": "array"}}, "required": ["product_id", "access_schedule"], "type": "object"}, "type": "array"}, "custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "customer_id": {"format": "uuid", "type": "string"}, "discounts": {"items": {"properties": {"custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "product_id": {"format": "uuid", "type": "string"}, "schedule": {"properties": {"credit_type_id": {"format": "uuid", "type": "string"}, "do_not_invoice": {"default": false, "type": "boolean"}, "recurring_schedule": {"properties": {"amount": {"type": {"$truncated": true}}, "amount_distribution": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "ending_before": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "frequency": {"$truncated": true}, "quantity": {"type": {"$truncated": true}}, "starting_at": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "unit_price": {"type": {"$truncated": true}}}, "required": ["starting_at", "ending_before", "frequency", "amount_distribution"], "type": "object"}, "schedule_items": {"items": {"properties": {"amount": {"$truncated": true}, "quantity": {"$truncated": true}, "timestamp": {"$truncated": true}, "unit_price": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "type": "array"}}, "type": "object"}}, "required": ["product_id", "schedule"], "type": "object"}, "type": "array", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "ending_before": {"format": "date-time", "type": "string"}, "hierarchy_configuration": {"properties": {"parent": {"properties": {"contract_id": {"format": "uuid", "type": "string"}, "customer_id": {"format": "uuid", "type": "string"}}, "required": ["contract_id", "customer_id"], "type": "object"}, "parent_behavior": {"properties": {"invoice_consolidation_type": {"enum": ["CONCATENATE", "NONE", "concatenate", "none"], "type": "string"}}, "type": "object"}, "payer": {"enum": ["SELF", "PARENT", "self", "parent"], "type": "string"}, "usage_statement_behavior": {"enum": ["CONSOLIDATE", "SEPARATE", "consolidate", "separate"], "type": "string"}}, "type": "object"}, "multiplier_override_prioritization": {"enum": ["LOWEST_MULTIPLIER", "lowest_multiplier", "EXPLICIT", "explicit"], "type": "string"}, "name": {"type": "string"}, "net_payment_terms_days": {"type": "number"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "overrides": {"items": {"properties": {"applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "ending_before": {"format": "date-time", "type": "string"}, "entitled": {"type": "boolean"}, "is_commit_specific": {"type": "boolean"}, "multiplier": {"type": "number"}, "override_specifiers": {"items": {"properties": {"any_commit_or_credit_ids": {"items": {"type": {"$truncated": true}}, "type": "array"}, "billing_frequency": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}, "commit_ids": {"items": {"type": {"$truncated": true}}, "type": "array"}, "exclude": {"items": {"$truncated": true}, "type": "array", "x-mint": {"groups": {"$truncated": true}}, "x-stainless-skip": true}, "presentation_group_values": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "pricing_group_values": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "product_id": {"format": "uuid", "type": "string"}, "product_tags": {"items": {"type": {"$truncated": true}}, "type": "array"}, "recurring_commit_ids": {"items": {"type": {"$truncated": true}}, "type": "array"}}, "type": "object"}, "type": "array"}, "overwrite_rate": {"properties": {"credit_type_id": {"format": "uuid", "type": "string"}, "custom_rate": {"additionalProperties": true, "type": "object", "x-mint": {"groups": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}]}}, "is_prorated": {"type": "boolean", "x-mint": {"groups": [{"$truncated": true}]}}, "minimum_config": {"properties": {"minimum": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "price": {"type": "number"}, "quantity": {"type": "number", "x-mint": {"groups": [{"$truncated": true}]}}, "rate_type": {"enum": ["FLAT", "flat", "PERCENTAGE", "percentage", "SUBSCRIPTION", "subscription", "TIERED", "tiered", "TIERED_PERCENTAGE", "tiered_percentage", "CUSTOM", "custom"], "type": "string", "x-mint-enum": {"CUSTOM": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "SUBSCRIPTION": [{"$truncated": true}], "TIERED_PERCENTAGE": [{"$truncated": true}], "custom": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "subscription": [{"$truncated": true}], "tiered_percentage": [{"$truncated": true}]}}, "tiers": {"items": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}}, "required": ["rate_type"], "type": "object"}, "priority": {"type": "number"}, "product_id": {"format": "uuid", "type": "string"}, "starting_at": {"format": "date-time", "type": "string"}, "target": {"enum": ["COMMIT_RATE", "commit_rate", "LIST_RATE", "list_rate"], "type": "string"}, "tiers": {"items": {"properties": {"multiplier": {"type": "number"}, "size": {"type": "number"}}, "required": ["multiplier"], "type": "object"}, "type": "array"}, "type": {"enum": ["OVERWRITE", "overwrite", "MULTIPLIER", "multiplier", "TIERED", "tiered"], "type": "string"}}, "required": ["starting_at"], "type": "object"}, "type": "array"}, "package_alias": {"type": "string"}, "package_customizations": {"properties": {"add_commits": {"items": {"properties": {"access_schedule": {"properties": {"access_type": {"$truncated": true}, "credit_type_id": {"$truncated": true}, "schedule_items": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "amount": {"type": "number"}, "applicable_product_ids": {"items": {"format": "uuid", "type": "string"}, "type": "array"}, "applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "custom_fields": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "hierarchy_configuration": {"properties": {"child_access": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "invoice_schedule": {"properties": {"credit_type_id": {"$truncated": true}, "do_not_invoice": {"$truncated": true}, "recurring_schedule": {"$truncated": true}, "schedule_items": {"$truncated": true}}, "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": [{"$truncated": true}]}}, "priority": {"type": "number"}, "product_id": {"format": "uuid", "type": "string"}, "rate_type": {"enum": ["COMMIT_RATE", "commit_rate", "LIST_RATE", "list_rate"], "type": "string"}, "rollover_fraction": {"type": "number"}, "specifiers": {"items": {"properties": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}, "spend_tracker_attributes": {"allOf": [{"$truncated": true}], "x-mint": {"groups": [{"$truncated": true}]}}, "temporary_id": {"type": "string"}, "type": {"enum": ["PREPAID", "prepaid", "POSTPAID", "postpaid"], "type": "string"}}, "required": ["type", "product_id"], "type": "object"}, "type": "array"}, "add_credits": {"items": {"properties": {"access_schedule": {"properties": {"access_type": {"$truncated": true}, "credit_type_id": {"$truncated": true}, "schedule_items": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "applicable_product_ids": {"items": {"format": "uuid", "type": "string"}, "type": "array"}, "applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "custom_fields": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "hierarchy_configuration": {"properties": {"child_access": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": [{"$truncated": true}]}}, "priority": {"type": "number"}, "product_id": {"format": "uuid", "type": "string"}, "rate_type": {"enum": ["COMMIT_RATE", "commit_rate", "LIST_RATE", "list_rate"], "type": "string"}, "rollover_fraction": {"type": "number"}, "specifiers": {"items": {"properties": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}}, "required": ["product_id", "access_schedule"], "type": "object"}, "type": "array"}, "add_overrides": {"items": {"properties": {"applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "ending_before": {"format": "date-time", "type": "string"}, "entitled": {"type": "boolean"}, "is_commit_specific": {"type": "boolean"}, "multiplier": {"type": "number"}, "override_specifiers": {"items": {"properties": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}, "overwrite_rate": {"properties": {"credit_type_id": {"$truncated": true}, "custom_rate": {"$truncated": true}, "is_prorated": {"$truncated": true}, "minimum_config": {"$truncated": true}, "price": {"$truncated": true}, "quantity": {"$truncated": true}, "rate_type": {"$truncated": true}, "tiers": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "priority": {"type": "number"}, "product_id": {"format": "uuid", "type": "string"}, "starting_at": {"format": "date-time", "type": "string"}, "target": {"enum": ["COMMIT_RATE", "commit_rate", "LIST_RATE", "list_rate"], "type": "string"}, "tiers": {"items": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}, "type": {"enum": ["OVERWRITE", "overwrite", "MULTIPLIER", "multiplier", "TIERED", "tiered"], "type": "string"}}, "required": ["starting_at"], "type": "object"}, "type": "array"}, "add_recurring_commits": {"items": {"allOf": [{"properties": {"access_amount": {"$truncated": true}, "applicable_product_ids": {"$truncated": true}, "applicable_product_tags": {"$truncated": true}, "commit_duration": {"$truncated": true}, "ending_before": {"$truncated": true}, "hierarchy_configuration": {"$truncated": true}, "name": {"$truncated": true}, "netsuite_sales_order_id": {"$truncated": true}, "priority": {"$truncated": true}, "product_id": {"$truncated": true}, "proration": {"$truncated": true}, "rate_type": {"$truncated": true}, "recurrence_frequency": {"$truncated": true}, "rollover_fraction": {"$truncated": true}, "specifiers": {"$truncated": true}, "starting_at": {"$truncated": true}, "subscription_config": {"$truncated": true}, "temporary_id": {"$truncated": true}}, "required": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "object"}, {"properties": {"invoice_amount": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "proration_rounding": {"properties": {"$truncated": true}, "type": {"$truncated": true}}}, "type": "object"}]}, "type": "array"}, "add_recurring_credits": {"items": {"allOf": [{"properties": {"access_amount": {"$truncated": true}, "applicable_product_ids": {"$truncated": true}, "applicable_product_tags": {"$truncated": true}, "commit_duration": {"$truncated": true}, "ending_before": {"$truncated": true}, "hierarchy_configuration": {"$truncated": true}, "name": {"$truncated": true}, "netsuite_sales_order_id": {"$truncated": true}, "priority": {"$truncated": true}, "product_id": {"$truncated": true}, "proration": {"$truncated": true}, "rate_type": {"$truncated": true}, "recurrence_frequency": {"$truncated": true}, "rollover_fraction": {"$truncated": true}, "specifiers": {"$truncated": true}, "starting_at": {"$truncated": true}, "subscription_config": {"$truncated": true}, "temporary_id": {"$truncated": true}}, "required": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "object"}, {"properties": {"proration_rounding": {"properties": {"$truncated": true}, "type": {"$truncated": true}}}, "type": "object"}]}, "type": "array"}, "add_scheduled_charges": {"items": {"properties": {"custom_fields": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": [{"$truncated": true}]}}, "product_id": {"format": "uuid", "type": "string"}, "schedule": {"properties": {"credit_type_id": {"$truncated": true}, "do_not_invoice": {"$truncated": true}, "recurring_schedule": {"$truncated": true}, "schedule_items": {"$truncated": true}}, "type": "object"}}, "required": ["product_id", "schedule"], "type": "object"}, "type": "array"}, "add_spend_trackers": {"items": {"properties": {"alias": {"type": "string"}, "applicable_spend_specifiers": {"items": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}, "credit_type_id": {"format": "uuid", "type": "string"}, "reset_frequency": {"enum": ["BILLING_PERIOD"], "type": "string"}}, "required": ["alias", "credit_type_id", "reset_frequency", "applicable_spend_specifiers"], "type": "object"}, "type": "array", "x-mint": {"groups": ["ff:tb-spend-trackers"]}}, "add_subscriptions": {"items": {"properties": {"billing_cycle_config": {"properties": {"$truncated": true}, "type": {"$truncated": true}}, "collection_schedule": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}, "custom_fields": {"additionalProperties": {"$truncated": true}, "type": {"$truncated": true}}, "ending_before": {"format": "date-time", "type": "string"}, "initial_quantity": {"type": "number"}, "name": {"type": "string"}, "payment_gate_config": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}, "x-mint": {"$truncated": true}, "x-stainless-skip": {"$truncated": true}}, "proration": {"properties": {"$truncated": true}, "type": {"$truncated": true}}, "quantity_management_mode": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}, "seat_config": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "starting_at": {"format": "date-time", "type": "string"}, "subscription_rate": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "temporary_id": {"type": "string"}}, "required": ["subscription_rate", "collection_schedule", "proration"], "type": "object"}, "type": "array"}, "modify_subscriptions": {"items": {"properties": {"initial_quantity": {"type": "number"}, "initial_seat_ids": {"items": {"type": "string"}, "type": "array"}, "initial_unassigned_seats": {"type": "number"}, "subscription_alias": {"type": "string"}, "template_subscription_id": {"format": "uuid", "type": "string"}}, "type": "object", "x-mint": {"groups": ["ff:package-customizations"]}, "x-stainless-skip": true}, "type": "array"}, "modify_usage_statement_schedule": {"properties": {"billing_anchor_date": {"format": "date-time", "type": "string"}}, "type": "object"}, "set_prepaid_balance_threshold_configuration": {"properties": {"commit": {"allOf": [{"$truncated": true}, {"properties": {"$truncated": true}, "type": {"$truncated": true}}], "type": "object"}, "custom_credit_type_id": {"format": "uuid", "type": "string"}, "discount_configuration": {"properties": {"cap": {"allOf": {"$truncated": true}, "x-mint": {"$truncated": true}}, "payment_fraction": {"type": {"$truncated": true}}}, "required": ["payment_fraction"], "type": "object"}, "is_enabled": {"type": "boolean"}, "payment_gate_config": {"properties": {"payment_gate_type": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "precalculated_tax_config": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "stripe_config": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "tax_type": {"enum": {"$truncated": true}, "type": {"$truncated": true}}}, "required": ["payment_gate_type"], "type": "object"}, "recharge_to_amount": {"type": "number"}, "threshold_amount": {"type": "number"}, "threshold_balance_specifiers": {"items": {"properties": {"exclude": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "type": "array"}}, "required": ["is_enabled", "threshold_amount", "recharge_to_amount", "commit", "payment_gate_config"], "type": "object"}, "set_spend_threshold_configuration": {"properties": {"commit": {"allOf": [{"$truncated": true}], "type": "object"}, "discount_configuration": {"properties": {"cap": {"allOf": {"$truncated": true}, "x-mint": {"$truncated": true}}, "payment_fraction": {"type": {"$truncated": true}}}, "required": ["payment_fraction"], "type": "object"}, "is_enabled": {"type": "boolean"}, "payment_gate_config": {"properties": {"payment_gate_type": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "precalculated_tax_config": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "stripe_config": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "tax_type": {"enum": {"$truncated": true}, "type": {"$truncated": true}}}, "required": ["payment_gate_type"], "type": "object"}, "threshold_amount": {"type": "number"}}, "required": ["is_enabled", "threshold_amount", "commit", "payment_gate_config"], "type": "object"}}, "type": "object", "x-mint": {"groups": ["ff:package-customizations"]}, "x-stainless-skip": true}, "package_id": {"format": "uuid", "type": "string"}, "prepaid_balance_threshold_configuration": {"properties": {"commit": {"allOf": [{"properties": {"name": {"type": {"$truncated": true}}, "priority": {"type": {"$truncated": true}}, "product_id": {"type": {"$truncated": true}}}, "required": ["product_id"], "type": "object"}, {"properties": {"applicable_product_ids": {"items": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}, "applicable_product_tags": {"items": {"type": {"$truncated": true}}, "type": "array"}, "duration": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "rate_type": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}, "rollover_fraction": {"type": "number"}, "specifiers": {"items": {"$truncated": true}, "type": "array", "x-mint": {"groups": {"$truncated": true}}}}, "type": "object"}], "type": "object"}, "custom_credit_type_id": {"format": "uuid", "type": "string"}, "discount_configuration": {"properties": {"cap": {"allOf": [{"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}], "x-mint": {"groups": ["ff:threshold-billing-discounts"]}}, "payment_fraction": {"type": "number"}}, "required": ["payment_fraction"], "type": "object"}, "is_enabled": {"type": "boolean"}, "payment_gate_config": {"properties": {"payment_gate_type": {"enum": ["NONE", "STRIPE", "EXTERNAL"], "type": "string"}, "precalculated_tax_config": {"properties": {"tax_amount": {"type": "number"}, "tax_name": {"type": "string"}}, "required": ["tax_amount"], "type": "object"}, "stripe_config": {"properties": {"invoice_metadata": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "payment_type": {"enum": [{"$truncated": true}, {"$truncated": true}], "type": "string"}}, "required": ["payment_type"], "type": "object"}, "tax_type": {"enum": ["NONE", "STRIPE", "ANROK", "PRECALCULATED"], "type": "string"}}, "required": ["payment_gate_type"], "type": "object"}, "recharge_to_amount": {"type": "number"}, "threshold_amount": {"type": "number"}, "threshold_balance_specifiers": {"items": {"properties": {"exclude": {"items": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}}, "required": ["exclude"], "type": "object"}, "type": "array"}}, "required": ["is_enabled", "threshold_amount", "recharge_to_amount", "commit", "payment_gate_config"], "type": "object"}, "professional_services": {"items": {"properties": {"custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "max_amount": {"type": "number"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "product_id": {"format": "uuid", "type": "string"}, "quantity": {"type": "number"}, "unit_price": {"type": "number"}}, "required": ["product_id", "unit_price", "quantity", "max_amount"], "type": "object"}, "type": "array", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "rate_card_alias": {"type": "string"}, "rate_card_id": {"format": "uuid", "type": "string"}, "recurring_commits": {"items": {"allOf": [{"properties": {"access_amount": {"properties": {"access_type": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "credit_type_id": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "quantity": {"type": {"$truncated": true}}, "unit_price": {"type": {"$truncated": true}}}, "required": ["unit_price"], "type": "object"}, "applicable_product_ids": {"items": {"format": "uuid", "type": "string"}, "type": "array"}, "applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "commit_duration": {"properties": {"unit": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "value": {"type": {"$truncated": true}}}, "required": ["value"], "type": "object"}, "ending_before": {"format": "date-time", "type": "string"}, "hierarchy_configuration": {"properties": {"child_access": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": [{"$truncated": true}]}}, "priority": {"type": "number"}, "product_id": {"format": "uuid", "type": "string"}, "proration": {"enum": ["NONE", "none", "FIRST", "first", "LAST", "last", "FIRST_AND_LAST", "first_and_last"], "type": "string"}, "rate_type": {"enum": ["COMMIT_RATE", "commit_rate", "LIST_RATE", "list_rate"], "type": "string"}, "recurrence_frequency": {"enum": ["MONTHLY", "monthly", "QUARTERLY", "quarterly", "ANNUAL", "annual", "WEEKLY", "weekly", "DAILY", "daily"], "type": "string"}, "rollover_fraction": {"type": "number"}, "specifiers": {"items": {"properties": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}, "starting_at": {"format": "date-time", "type": "string"}, "subscription_config": {"properties": {"access_policy": {"$truncated": true}, "allocation": {"$truncated": true}, "apply_seat_increase_config": {"$truncated": true}, "subscription_id": {"$truncated": true}}, "required": [{"$truncated": true}, {"$truncated": true}], "type": "object"}, "temporary_id": {"type": "string"}}, "required": ["product_id", "access_amount", "priority", "commit_duration", "starting_at"], "type": "object"}, {"properties": {"invoice_amount": {"properties": {"credit_type_id": {"format": "uuid", "type": "string"}, "quantity": {"type": "number"}, "unit_price": {"type": "number"}}, "required": ["unit_price", "quantity", "credit_type_id"], "type": "object"}, "proration_rounding": {"properties": {"access": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}, "invoice": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}}, "type": "object"}}, "type": "object"}]}, "type": "array"}, "recurring_credits": {"items": {"allOf": [{"properties": {"access_amount": {"properties": {"access_type": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "credit_type_id": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "quantity": {"type": {"$truncated": true}}, "unit_price": {"type": {"$truncated": true}}}, "required": ["unit_price"], "type": "object"}, "applicable_product_ids": {"items": {"format": "uuid", "type": "string"}, "type": "array"}, "applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "commit_duration": {"properties": {"unit": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "value": {"type": {"$truncated": true}}}, "required": ["value"], "type": "object"}, "ending_before": {"format": "date-time", "type": "string"}, "hierarchy_configuration": {"properties": {"child_access": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": [{"$truncated": true}]}}, "priority": {"type": "number"}, "product_id": {"format": "uuid", "type": "string"}, "proration": {"enum": ["NONE", "none", "FIRST", "first", "LAST", "last", "FIRST_AND_LAST", "first_and_last"], "type": "string"}, "rate_type": {"enum": ["COMMIT_RATE", "commit_rate", "LIST_RATE", "list_rate"], "type": "string"}, "recurrence_frequency": {"enum": ["MONTHLY", "monthly", "QUARTERLY", "quarterly", "ANNUAL", "annual", "WEEKLY", "weekly", "DAILY", "daily"], "type": "string"}, "rollover_fraction": {"type": "number"}, "specifiers": {"items": {"properties": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}, "starting_at": {"format": "date-time", "type": "string"}, "subscription_config": {"properties": {"access_policy": {"$truncated": true}, "allocation": {"$truncated": true}, "apply_seat_increase_config": {"$truncated": true}, "subscription_id": {"$truncated": true}}, "required": [{"$truncated": true}, {"$truncated": true}], "type": "object"}, "temporary_id": {"type": "string"}}, "required": ["product_id", "access_amount", "priority", "commit_duration", "starting_at"], "type": "object"}, {"properties": {"proration_rounding": {"properties": {"access": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}}, "type": "object"}}, "type": "object"}]}, "type": "array"}, "reseller_royalties": {"items": {"properties": {"applicable_product_ids": {"items": {"format": "uuid", "type": "string"}, "type": "array"}, "applicable_product_tags": {"items": {"type": "string"}, "type": "array"}, "aws_options": {"properties": {"aws_account_number": {"type": "string"}, "aws_offer_id": {"type": "string"}, "aws_payer_reference_id": {"type": "string"}}, "type": "object"}, "ending_before": {"format": "date-time", "type": "string"}, "fraction": {"type": "number"}, "gcp_options": {"properties": {"gcp_account_id": {"type": "string"}, "gcp_offer_id": {"type": "string"}}, "type": "object"}, "netsuite_reseller_id": {"type": "string"}, "reseller_contract_value": {"type": "number"}, "reseller_type": {"enum": ["AWS", "AWS_PRO_SERVICE", "GCP", "GCP_PRO_SERVICE"], "type": "string"}, "starting_at": {"format": "date-time", "type": "string"}}, "required": ["reseller_type", "starting_at", "fraction", "netsuite_reseller_id"], "type": "object"}, "type": "array", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "revenue_system_configuration": {"properties": {"delivery_method": {"enum": ["direct_to_billing_provider"], "type": "string", "x-mint": {"groups": ["ff:revenue-rec-configurations-enabled"]}}, "provider": {"enum": ["netsuite"], "type": "string", "x-mint": {"groups": ["ff:revenue-rec-configurations-enabled"]}}, "revenue_system_configuration_id": {"format": "uuid", "type": "string"}}, "type": "object", "x-mint": {"groups": ["ff:revenue-rec-configurations-enabled"]}}, "salesforce_opportunity_id": {"type": "string", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "scheduled_charges": {"items": {"properties": {"custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "name": {"minLength": 1, "type": "string"}, "netsuite_sales_order_id": {"type": "string", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "product_id": {"format": "uuid", "type": "string"}, "schedule": {"properties": {"credit_type_id": {"format": "uuid", "type": "string"}, "do_not_invoice": {"default": false, "type": "boolean"}, "recurring_schedule": {"properties": {"amount": {"type": {"$truncated": true}}, "amount_distribution": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "ending_before": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "frequency": {"$truncated": true}, "quantity": {"type": {"$truncated": true}}, "starting_at": {"format": {"$truncated": true}, "type": {"$truncated": true}}, "unit_price": {"type": {"$truncated": true}}}, "required": ["starting_at", "ending_before", "frequency", "amount_distribution"], "type": "object"}, "schedule_items": {"items": {"properties": {"amount": {"$truncated": true}, "quantity": {"$truncated": true}, "timestamp": {"$truncated": true}, "unit_price": {"$truncated": true}}, "required": [{"$truncated": true}], "type": "object"}, "type": "array"}}, "type": "object"}}, "required": ["product_id", "schedule"], "type": "object"}, "type": "array"}, "scheduled_charges_on_usage_invoices": {"enum": ["ALL"], "type": "string"}, "spend_threshold_configuration": {"properties": {"commit": {"allOf": [{"properties": {"name": {"type": {"$truncated": true}}, "priority": {"type": {"$truncated": true}}, "product_id": {"type": {"$truncated": true}}}, "required": ["product_id"], "type": "object"}], "type": "object"}, "discount_configuration": {"properties": {"cap": {"allOf": [{"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}], "x-mint": {"groups": ["ff:threshold-billing-discounts"]}}, "payment_fraction": {"type": "number"}}, "required": ["payment_fraction"], "type": "object"}, "is_enabled": {"type": "boolean"}, "payment_gate_config": {"properties": {"payment_gate_type": {"enum": ["NONE", "STRIPE", "EXTERNAL"], "type": "string"}, "precalculated_tax_config": {"properties": {"tax_amount": {"type": "number"}, "tax_name": {"type": "string"}}, "required": ["tax_amount"], "type": "object"}, "stripe_config": {"properties": {"invoice_metadata": {"additionalProperties": {"type": {"$truncated": true}}, "type": "object"}, "payment_type": {"enum": [{"$truncated": true}, {"$truncated": true}], "type": "string"}}, "required": ["payment_type"], "type": "object"}, "tax_type": {"enum": ["NONE", "STRIPE", "ANROK", "PRECALCULATED"], "type": "string"}}, "required": ["payment_gate_type"], "type": "object"}, "threshold_amount": {"type": "number"}}, "required": ["is_enabled", "threshold_amount", "commit", "payment_gate_config"], "type": "object"}, "spend_trackers": {"items": {"properties": {"alias": {"type": "string"}, "applicable_spend_specifiers": {"items": {"properties": {"discounted": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}, "sources": {"items": {"enum": {"$truncated": true}, "type": {"$truncated": true}}, "type": "array"}, "spend_type": {"enum": [{"$truncated": true}], "type": "string"}}, "required": ["spend_type", "sources"], "type": "object"}, "type": "array"}, "credit_type_id": {"format": "uuid", "type": "string"}, "reset_frequency": {"enum": ["BILLING_PERIOD"], "type": "string"}}, "required": ["alias", "credit_type_id", "reset_frequency", "applicable_spend_specifiers"], "type": "object"}, "type": "array", "x-mint": {"groups": ["ff:tb-spend-trackers"]}}, "starting_at": {"format": "date-time", "type": "string"}, "subscriptions": {"items": {"properties": {"billing_cycle_config": {"properties": {"anchor_date": {"format": "date-time", "type": "string"}, "invoice_placement": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}}, "type": "object"}, "collection_schedule": {"enum": ["ADVANCE", "ARREARS", "advance", "arrears"], "type": "string"}, "custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "ending_before": {"format": "date-time", "type": "string"}, "initial_quantity": {"type": "number"}, "name": {"type": "string"}, "payment_gate_config": {"properties": {"payment_gate_type": {"enum": [{"$truncated": true}, {"$truncated": true}], "type": "string"}, "tax_type": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}}, "required": ["payment_gate_type"], "type": "object", "x-mint": {"groups": ["ff:payment-gated-subscriptions-enabled"]}, "x-stainless-skip": true}, "proration": {"properties": {"invoice_behavior": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}, "is_prorated": {"type": "boolean"}, "rounding": {"properties": {"$truncated": true}, "required": {"$truncated": true}, "type": {"$truncated": true}}}, "type": "object"}, "quantity_management_mode": {"enum": ["SEAT_BASED", "seat_based", "QUANTITY_ONLY", "quantity_only"], "type": "string"}, "seat_config": {"properties": {"initial_seat_ids": {"items": {"type": {"$truncated": true}}, "type": "array"}, "initial_unassigned_seats": {"type": "number"}, "seat_group_key": {"type": "string"}}, "required": ["seat_group_key", "initial_seat_ids"], "type": "object"}, "starting_at": {"format": "date-time", "type": "string"}, "subscription_rate": {"properties": {"billing_frequency": {"enum": [{"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}, {"$truncated": true}], "type": "string"}, "product_id": {"format": "uuid", "type": "string"}}, "required": ["billing_frequency", "product_id"], "type": "object"}, "temporary_id": {"type": "string"}}, "required": ["subscription_rate", "collection_schedule", "proration"], "type": "object"}, "type": "array"}, "total_contract_value": {"type": "number", "x-mint": {"groups": ["client_id:e3147d6d-4101-4cd1-9888-ce3afeeac5b2"]}}, "transition": {"properties": {"from_contract_id": {"format": "uuid", "type": "string"}, "future_invoice_behavior": {"properties": {"trueup": {"enum": ["remove", "as_is", "REMOVE", "AS_IS"], "nullable": true, "type": "string"}}, "type": "object"}, "type": {"enum": ["RENEWAL", "renewal"], "type": "string"}}, "required": ["type", "from_contract_id"], "type": "object"}, "uniqueness_key": {"maxLength": 128, "minLength": 1, "type": "string"}, "usage_filter": {"properties": {"group_key": {"type": "string"}, "group_values": {"items": {"type": "string"}, "type": "array"}, "starting_at": {"format": "date-time", "type": "string"}}, "required": ["group_key", "group_values"], "type": "object"}, "usage_statement_schedule": {"properties": {"billing_anchor_date": {"format": "date-time", "type": "string"}, "day": {"enum": ["FIRST_OF_MONTH", "first_of_month", "CONTRACT_START", "contract_start", "CUSTOM_DATE", "custom_date"], "type": "string"}, "frequency": {"enum": ["MONTHLY", "monthly", "QUARTERLY", "quarterly", "ANNUAL", "annual", "WEEKLY", "weekly"], "type": "string"}, "invoice_generation_starting_at": {"format": "date-time", "type": "string"}}, "required": ["frequency"], "type": "object"}}, "required": ["customer_id", "starting_at"], "type": "object"}, "create_customer": {"properties": {"custom_fields": {"additionalProperties": {"type": "string"}, "type": "object"}, "customer_billing_provider_configurations": {"items": {"properties": {"billing_provider": {"enum": ["aws_marketplace", "azure_marketplace", "gcp_marketplace", "stripe", "netsuite"], "type": "string"}, "configuration": {"additionalProperties": true, "type": "object"}, "delivery_method": {"enum": ["direct_to_billing_provider", "aws_sqs", "tackle", "aws_sns"], "type": "string"}, "delivery_method_id": {"format": "uuid", "type": "string"}, "tax_provider": {"enum": ["anrok", "avalara", "stripe"], "type": "string"}}, "required": ["billing_provider"], "type": "object"}, "type": "array"}, "customer_revenue_system_configurations": {"items": {"properties": {"configuration": {"additionalProperties": true, "type": "object"}, "delivery_method": {"enum": ["direct_to_billing_provider"], "type": "string", "x-mint": {"groups": ["ff:revenue-rec-configurations-enabled"]}}, "delivery_method_id": {"format": "uuid", "type": "string"}, "provider": {"enum": ["netsuite"], "type": "string", "x-mint": {"groups": ["ff:revenue-rec-configurations-enabled"]}}}, "required": ["provider"], "type": "object", "x-mint": {"groups": ["ff:revenue-rec-configurations-enabled"]}}, "type": "array", "x-mint": {"groups": ["ff:revenue-rec-configurations-enabled"]}}, "external_id": {"maxLength": 128, "minLength": 1, "type": "string"}, "ingest_aliases": {"items": {"maxLength": 128, "minLength": 1, "type": "string"}, "maxItems": 2000, "type": "array"}, "name": {"type": "string"}}, "required": ["name"], "type": "object"}, "get_customer_billing_provider_configurations": {"properties": {"customer_id": {"format": "uuid", "type": "string"}, "include_archived": {"type": "boolean"}}, "required": ["customer_id"], "type": "object"}, "ingest": {"items": {"properties": {"customer_id": {"maxLength": 1024, "minLength": 1, "type": "string"}, "event_type": {"maxLength": 1024, "minLength": 1, "type": "string"}, "properties": {"additionalProperties": true, "type": "object"}, "timestamp": {"type": "string"}, "transaction_id": {"maxLength": 128, "minLength": 1, "type": "string"}}, "required": ["customer_id", "event_type", "timestamp", "transaction_id"], "type": "object"}, "maxItems": 100, "minItems": 1, "type": "array"}, "list_contracts_v2": {"properties": {"covering_date": {"description": "Optional RFC 3339 timestamp. Only include contracts active on the provided date. This cannot be provided if starting_at filter is provided.", "format": "date-time", "type": "string"}, "cursor": {"description": "Cursor from a previous response to fetch the next page of contracts.", "type": "string"}, "customer_id": {"format": "uuid", "type": "string"}, "include_archived": {"description": "Include archived contracts in the response.", "type": "boolean"}, "include_balance": {"description": "Include the balance of credits and commits in the response. Setting this flag may cause the response to be slower.", "type": "boolean"}, "include_ledgers": {"description": "Include commit/credit ledgers in the response. Setting this flag may cause the response to be slower.", "type": "boolean"}, "limit": {"description": "Max number of contracts to return per page. Range: 1-20. Default: 20.", "minimum": 1, "type": "number"}, "starting_at": {"description": "Optional RFC 3339 timestamp. Only include contracts that started on or after this date. This cannot be provided if covering_date filter is provided.", "format": "date-time", "type": "string"}}, "required": ["customer_id"], "type": "object"}, "set_customer_billing_provider_configurations": {"properties": {"data": {"items": {"properties": {"billing_provider": {"enum": ["aws_marketplace", "stripe", "netsuite", "custom", "azure_marketplace", "quickbooks_online", "workday", "gcp_marketplace", "metronome"], "type": "string", "x-mint-enum": {"custom": ["client_id:11db091c-975b-4908-9f67-b1ceb126acdf"], "gcp_marketplace": ["client_id:11db091c-975b-4908-9f67-b1ceb126acdf"], "netsuite": ["client_id:11db091c-975b-4908-9f67-b1ceb126acdf"], "workday": ["client_id:11db091c-975b-4908-9f67-b1ceb126acdf"]}}, "configuration": {"additionalProperties": true, "description": "Configuration for the billing provider. The structure of this object is specific to the billing provider and delivery method combination. Defaults to an empty object, however, for most billing provider + delivery method combinations, it will not be a valid configuration. For AWS marketplace configurations, the aws_is_subscription_product flag can be used to indicate a product with usage-based pricing. More information can be found [here](https://docs.metronome.com/invoice-customers/solutions/marketplaces/invoice-aws/#provision-aws-marketplace-customers-in-metronome).", "example": [{"aws_customer_id": "cust_1234", "aws_product_code": "my_product", "aws_region": "us-west-1"}, {"azure_subscription_id": "my_subscription"}, {"gcp_entitlement_id": "my_entitlement", "gcp_service_name": "my_service"}, {"leave_stripe_invoices_in_draft": true, "stripe_collection_method": "charge_automatically"}, {"netsuite_customer_id": "12345"}], "type": "object"}, "customer_id": {"format": "uuid", "type": "string"}, "delivery_method": {"enum": ["direct_to_billing_provider", "aws_sqs", "tackle", "aws_sns"], "type": "string"}, "delivery_method_id": {"description": "ID of the delivery method to use for this customer. If not provided, the `delivery_method` must be provided.", "format": "uuid", "type": "string"}, "tax_provider": {"enum": ["anrok", "avalara", "stripe"], "type": "string"}, "unbillable_invoices_configuration": {"description": "Rules that stop matching invoices from being sent to the billing provider. Only supported for Stripe billing provider configurations. When omitted, every invoice is sent to the billing provider.", "example": [{"invoice_type": "USAGE"}, {"fiat_credit_type_id": "2714e483-4ff1-48e4-9e25-ac732e8f24f2", "invoice_type": "USAGE", "max_amount": 1000}, {"fiat_credit_type_id": "2714e483-4ff1-48e4-9e25-ac732e8f24f2", "invoice_type": "SCHEDULED"}], "items": {"description": "An individual rule that, when evaluated to true, indicates that any invoices for this billing provider will not be sent to its associated destination for the associated contract. Rules only apply to the specified `invoice_type` (or all invoices if omitted) and `fiat_credit_type_id` (or all invoices if omitted). Rule precedence is evaluated from more specific to less specific. This method will fail with a 400 if multiple rules with the same specificity are included.", "example": {"fiat_credit_type_id": "2714e483-4ff1-48e4-9e25-ac732e8f24f2", "invoice_type": "USAGE", "max_amount": 1000}, "properties": {"fiat_credit_type_id": {"description": "Restricts the rule to invoices in this fiat currency. Omit for a catch-all rule that applies to every currency of the `invoice_type`. Required when `max_amount` is set.", "format": "uuid", "type": "string"}, "invoice_type": {"description": "The type of invoice this rule applies to.", "enum": ["USAGE", "SCHEDULED", "usage", "scheduled"], "type": "string"}, "max_amount": {"description": "A positive decimal, in the units of `fiat_credit_type_id`. Only invoices whose total is at or below this amount are suppressed; a higher total is still sent to the billing provider. When omitted, every matching invoice is suppressed regardless of amount.", "type": "number"}}, "required": ["invoice_type"], "type": "object"}, "type": "array", "x-mint": {"groups": ["ff:enforce-unbillable-invoices-configuration"], "metadata": {"tag": "Beta"}}, "x-stainless-skip": true}}, "required": ["billing_provider", "customer_id"], "type": "object"}, "type": "array"}}, "required": ["data"], "type": "object"}, "update_contract_end_date": {"properties": {"allow_ending_before_finalized_invoice": {"description": "If true, allows setting the contract end date earlier than the end_timestamp of existing finalized invoices. Finalized invoices will be unchanged; if you want to incorporate the new end date, you can void and regenerate finalized usage invoices. Defaults to true.", "type": "boolean"}, "contract_id": {"description": "ID of the contract to update", "format": "uuid", "type": "string"}, "customer_id": {"description": "ID of the customer whose contract is to be updated", "format": "uuid", "type": "string"}, "ending_before": {"description": "RFC 3339 timestamp indicating when the contract will end (exclusive). If not provided, the contract will be updated to be open-ended.", "format": "date-time", "type": "string"}}, "required": ["customer_id", "contract_id"], "type": "object"}, "usage": {"properties": {"billable_metrics": {"items": {"properties": {"group_by": {"properties": {"key": {"type": "string"}, "values": {"items": {"minLength": 1, "type": "string"}, "maxItems": 200, "minItems": 1, "type": "array"}}, "required": ["key"], "type": "object"}, "id": {"format": "uuid", "type": "string"}}, "required": ["id"], "type": "object"}, "type": "array"}, "customer_ids": {"items": {"format": "uuid", "type": "string"}, "type": "array"}, "ending_before": {"format": "date-time", "type": "string"}, "starting_on": {"format": "date-time", "type": "string"}, "window_size": {"enum": ["hour", "day", "none", "HOUR", "DAY", "NONE", "Hour", "Day", "None"], "type": "string"}}, "required": ["window_size", "starting_on", "ending_before"], "type": "object"}}
\ No newline at end of file
diff --git a/internal/destinations/stripe/adapter.go b/internal/destinations/stripe/adapter.go
index 2d9ba28..29fc713 100644
--- a/internal/destinations/stripe/adapter.go
+++ b/internal/destinations/stripe/adapter.go
@@ -353,7 +353,9 @@ func (a *Adapter) ensureSubscription(ctx context.Context, t usage.Tenant, cusID
return nil
}
if len(prices) > 20 {
- return fmt.Errorf("plan %s has %d prices; Stripe allows 20 items per subscription", plan, len(prices))
+ // Permanent: retrying cannot help, and a retryable error would hold up
+ // every tenant's usage. The customer exists, so usage keeps flowing.
+ return destinations.Permanent(fmt.Errorf("plan %s has %d prices; Stripe allows 20 items per subscription", plan, len(prices)))
}
subs, err := a.c.ListSubscriptions(ctx, cusID)
if err != nil {
diff --git a/internal/destinations/stripe/adapter_test.go b/internal/destinations/stripe/adapter_test.go
index 885981d..7b7b34d 100644
--- a/internal/destinations/stripe/adapter_test.go
+++ b/internal/destinations/stripe/adapter_test.go
@@ -281,6 +281,20 @@ func TestAutoSubscribeUsesPlanTaggedPrices(t *testing.T) {
if len(f.subs) != 1 {
t.Fatal("plan change created a second subscription")
}
+
+ // A plan over Stripe's 20-item limit rejects only this tenant: the error
+ // is permanent, so delivery for everyone else carries on.
+ for i := 0; i < 21; i++ {
+ f.prices = append(f.prices, price(fmt.Sprintf("price_big_%02d", i), meterID(usage.MetricGPUHours), "big", "usd"))
+ }
+ a.pricesTime = time.Time{}
+ err := a.EnsureTenant(ctx, usage.Tenant{ID: "globex", Plan: "big"})
+ if err == nil || !destinations.IsPermanent(err) {
+ t.Fatalf("plan with 21 prices: got %v, want a permanent error", err)
+ }
+ if _, ok := a.state.Get("cus/globex"); !ok {
+ t.Fatal("customer should still exist so usage can be delivered")
+ }
}
func TestRecordedTotalsSumsSplitMeters(t *testing.T) {
diff --git a/internal/destinations/webhook/adapter.go b/internal/destinations/webhook/adapter.go
index 68f12d5..e187999 100644
--- a/internal/destinations/webhook/adapter.go
+++ b/internal/destinations/webhook/adapter.go
@@ -13,6 +13,7 @@ import (
"errors"
"fmt"
"io"
+ "log"
"net/http"
"strconv"
"time"
@@ -80,8 +81,16 @@ func (a *Adapter) Name() string { return "webhook" }
// Bootstrap publishes the metric catalog so consumers can learn units and
// group keys without reading vBilling's docs.
+// Bootstrap sends the metric catalog. The catalog is informational: a
+// receiver that rejects it for good (400, 413, 422) is told once in the log,
+// and usage delivery goes ahead without it instead of pausing forever.
func (a *Adapter) Bootstrap(ctx context.Context, metrics []usage.MetricDef) error {
- return a.post(ctx, []CloudEvent{a.envelope(TypeCatalog, "catalog-"+strconv.FormatInt(a.now().Unix(), 10), "", a.now(), metrics)})
+ err := a.post(ctx, []CloudEvent{a.envelope(TypeCatalog, "catalog-"+strconv.FormatInt(a.now().Unix(), 10), "", a.now(), metrics)})
+ if destinations.IsPermanent(err) {
+ log.Printf("[webhook] receiver rejected the catalog event (%v); delivering usage without it", err)
+ return nil
+ }
+ return err
}
func (a *Adapter) EnsureTenant(ctx context.Context, t usage.Tenant) error {
diff --git a/internal/destinations/webhook/adapter_test.go b/internal/destinations/webhook/adapter_test.go
index 35102fb..aa26ba3 100644
--- a/internal/destinations/webhook/adapter_test.go
+++ b/internal/destinations/webhook/adapter_test.go
@@ -79,3 +79,17 @@ func TestErrorClassification(t *testing.T) {
}
}
}
+
+func TestRejectedCatalogDoesNotHoldUpDelivery(t *testing.T) {
+ status := http.StatusBadRequest
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(status) }))
+ defer srv.Close()
+ a := New(&config.Config{WebhookURL: srv.URL})
+ if err := a.Bootstrap(context.Background(), usage.Catalog()); err != nil {
+ t.Fatalf("a permanently rejected catalog must not block delivery: %v", err)
+ }
+ status = http.StatusServiceUnavailable // a receiver that is down is retried
+ if err := a.Bootstrap(context.Background(), usage.Catalog()); err == nil || destinations.IsPermanent(err) {
+ t.Fatalf("HTTP 503: got %v, want a retryable error", err)
+ }
+}
diff --git a/internal/pipeline/dispatcher_test.go b/internal/pipeline/dispatcher_test.go
index 7c8c109..ea7830b 100644
--- a/internal/pipeline/dispatcher_test.go
+++ b/internal/pipeline/dispatcher_test.go
@@ -15,8 +15,9 @@ import (
type fakeDest struct {
ensured []string
- ensureErr int // fail EnsureTenant this many times
- partial bool // report poison events with PartialError instead of failing the batch
+ ensureErr int // fail EnsureTenant this many times
+ reject string // tenant EnsureTenant rejects permanently
+ partial bool // report poison events with PartialError instead of failing the batch
name string
mu sync.Mutex
got map[string]int // event ID -> times received
@@ -39,6 +40,9 @@ func (f *fakeDest) EnsureTenant(_ context.Context, t usage.Tenant) error {
f.ensureErr--
return errors.New("billing API timeout")
}
+ if t.ID == f.reject {
+ return destinations.Permanent(fmt.Errorf("plan for %s is misconfigured", t.ID))
+ }
f.ensured = append(f.ensured, t.ID)
return nil
}
@@ -113,9 +117,12 @@ func run(t *testing.T, s *spool.Spool, until func() bool, dests ...destinations.
t.Helper()
d := New(s, dests, Options{BatchSize: 7, MinBackoff: time.Millisecond, MaxBackoff: 5 * time.Millisecond, IdlePoll: 10 * time.Millisecond}, nil)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
done := make(chan struct{})
go func() { d.Run(ctx); close(done) }()
+ defer func() {
+ cancel()
+ <-done // Finish cursor writes before the spool and its directory are cleaned up.
+ }()
for !until() {
if ctx.Err() != nil {
t.Fatal("timed out waiting for delivery")
@@ -293,8 +300,12 @@ func TestBootstrapGateAndTenantEnsuredBeforeEvents(t *testing.T) {
},
}, nil)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- go d.Run(ctx)
+ done := make(chan struct{})
+ go func() { d.Run(ctx); close(done) }()
+ defer func() {
+ cancel()
+ <-done // Also wait when a timeout calls t.Fatal.
+ }()
for dest.received() < len(all) {
if ctx.Err() != nil {
t.Fatal("timed out")
@@ -302,6 +313,7 @@ func TestBootstrapGateAndTenantEnsuredBeforeEvents(t *testing.T) {
time.Sleep(time.Millisecond)
}
cancel()
+ <-done
if bootstrapCalls != 3 {
t.Fatalf("bootstrap calls = %d", bootstrapCalls)
}
@@ -314,3 +326,42 @@ func TestBootstrapGateAndTenantEnsuredBeforeEvents(t *testing.T) {
t.Fatal("tenant not marked ensured")
}
}
+
+func TestPermanentlyRejectedTenantDoesNotBlockDelivery(t *testing.T) {
+ s := openSpool(t, t.TempDir())
+ defer s.Close()
+ dest := newFake("stripe")
+ dest.reject = "t1"
+ s.Cursor("stripe")
+ all := fill(t, s, 3, 3, usage.MetricGPUHours)
+
+ reg := NewTenantRegistry()
+ for _, id := range []string{"t0", "t1", "t2"} {
+ reg.Upsert(usage.Tenant{ID: id})
+ }
+ d := New(s, []destinations.Destination{dest}, Options{
+ BatchSize: 4, MinBackoff: time.Millisecond, MaxBackoff: 2 * time.Millisecond, IdlePoll: 5 * time.Millisecond,
+ Tenants: reg,
+ }, nil)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ done := make(chan struct{})
+ go func() { d.Run(ctx); close(done) }()
+ defer func() {
+ cancel()
+ <-done // Also wait when a timeout calls t.Fatal.
+ }()
+ for dest.received() < len(all) {
+ if ctx.Err() != nil {
+ t.Fatalf("delivery stalled at %d of %d events", dest.received(), len(all))
+ }
+ time.Sleep(time.Millisecond)
+ }
+ cancel()
+ <-done
+ if !reg.NeedsEnsure("stripe", usage.Tenant{ID: "t1"}) {
+ t.Fatal("rejected tenant marked ensured")
+ }
+ if reg.NeedsEnsure("stripe", usage.Tenant{ID: "t0"}) || reg.NeedsEnsure("stripe", usage.Tenant{ID: "t2"}) {
+ t.Fatal("other tenants not ensured")
+ }
+}
diff --git a/internal/usage/usage.go b/internal/usage/usage.go
index 0555fba..1fe2614 100644
--- a/internal/usage/usage.go
+++ b/internal/usage/usage.go
@@ -29,7 +29,7 @@ const SchemaVersion = "vbilling.usage/v1"
const (
DimTenantCluster = "tenant_cluster" // tenant cluster external ID
DimGPUType = "gpu_type" // normalized GPU model, e.g. NVIDIA-H100-80GB-HBM3
- DimGPUProfile = "gpu_profile" // full, mig-1g.10gb, shared-4, ...
+ DimGPUProfile = "gpu_profile" // full, mig-1g.10gb, timeslice-4, ...
DimCapacityType = "capacity_type" // on-demand, spot, preemptible, reserved
DimBillingMode = "billing_mode" // shared, dedicated_node, private_node
DimZone = "zone"
diff --git a/scripts/check-demo-secrets.py b/scripts/check-demo-secrets.py
new file mode 100755
index 0000000..d49c5be
--- /dev/null
+++ b/scripts/check-demo-secrets.py
@@ -0,0 +1,127 @@
+#!/usr/bin/env python3
+"""Read-only, redacting pre-commit check. Never prints matching credential values.
+
+Run from a Git checkout:
+ python3 scripts/check-demo-secrets.py --tracked
+ python3 scripts/check-demo-secrets.py --staged
+ python3 scripts/check-demo-secrets.py --directory examples/gke-stripe
+
+Staged mode scans full index blobs, not just added lines. --tracked scans the
+working copies of all tracked files. No files or Git state are changed.
+This focused check supplements review; it is not a complete secret detector.
+"""
+import argparse
+import json
+import os
+from pathlib import Path
+import re
+import subprocess
+import sys
+
+PATTERNS = (
+ ("Stripe key", re.compile(rb"\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{20,}")),
+ ("private key", re.compile(rb"-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY-----")),
+ ("GitHub token", re.compile(rb"\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})")),
+ ("Google API key", re.compile(rb"\bAIza[0-9A-Za-z_-]{30,}")),
+ ("AWS access key", re.compile(rb"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b")),
+ ("JWT token", re.compile(rb"\beyJ[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{16,}")),
+ ("kubeconfig private key", re.compile(rb"client-key-data:\s*[A-Za-z0-9+/=]{30,}")),
+ ("join token in URL", re.compile(rb"/node/join\?token=[A-Za-z0-9._~%+/-]{16,}")),
+ ("literal bearer token", re.compile(rb"(?i)bearer\s+[A-Za-z0-9._~+/-]{32,}")),
+ ("literal credential assignment", re.compile(
+ rb"(?i)(?:[\"']?)(?:STRIPE_API_KEY|VCLUSTER_PLATFORM_KEY|VCLUSTER_ACCESS_KEY|LOFT_ACCESS_KEY|API_TOKEN|INGEST_TOKEN|api[-_]?key|access[-_]?key|client[-_]?secret|refresh[-_]?token|access[-_]?token|token|password)(?:[\"']?)\s*[:=]\s*[\"']([A-Za-z0-9._~+/=-]{32,})[\"']"
+ )),
+)
+# These are generated runtime/credential files, not reusable source manifests.
+SENSITIVE_NAMES = re.compile(
+ r"(?:^|/)(?:\.env(?:\..+)?|join\.txt|startup\.sh|stripe(?:-test|-live)?-key|"
+ r"api-token|private-tenants\.json|platform-cli\.json|vcluster-cli\.json|private-join\.txt|private-startup\.sh|credentials\.json|application_default_credentials\.json|"
+ r"[^/]*\.kubeconfig|kubeconfig(?:\.[^/]*)?)$", re.I
+)
+SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "vendor"}
+
+
+def git(*args):
+ return subprocess.check_output(["git", *args], stderr=subprocess.PIPE)
+
+
+def findings_for(path, data):
+ found = []
+ if SENSITIVE_NAMES.search(path) and not path.endswith((".example", ".sample", ".template")):
+ found.append({"path": path, "line": 1, "type": "runtime credential filename"})
+ for kind, pattern in PATTERNS:
+ for match in pattern.finditer(data):
+ found.append({"path": path, "line": data.count(b"\n", 0, match.start()) + 1, "type": kind})
+ return found
+
+
+def self_test():
+ samples = [
+ b"sk_" + b"test_" + b"1AbCdEfGhJkLmn234567890pQrSTuvwxyz",
+ b"-----BEGIN " + b"PRIVATE KEY-----",
+ b"client-key-data: " + b"M" * 40,
+ b"https://demo.example/node/join?token=" + b"Xy123" * 10,
+ b'API_TOKEN="' + b"aB12" * 12 + b'"',
+ ]
+ for data in samples:
+ assert findings_for("sample.txt", data), "synthetic credential was missed"
+ safe = b'export STRIPE_API_KEY="$(cat "$STRIPE_KEY_FILE")"\nsk_test_...\nsecretName: vbilling-stripe\n'
+ assert not findings_for("README.md", safe), "placeholder or file reference was flagged"
+ assert findings_for("host.kubeconfig", b"apiVersion: v1"), "sensitive filename was missed"
+ assert not findings_for("host.kubeconfig.example", b"apiVersion: v1"), "template filename was flagged"
+ print("Self-test passed (synthetic data; credential values not displayed).")
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ mode = parser.add_mutually_exclusive_group(required=True)
+ mode.add_argument("--staged", action="store_true")
+ mode.add_argument("--tracked", action="store_true")
+ mode.add_argument("--directory", type=Path)
+ mode.add_argument("--self-test", action="store_true")
+ args = parser.parse_args()
+ if args.self_test:
+ self_test()
+ return 0
+ files = []
+ if args.directory:
+ root = args.directory.resolve(strict=True)
+ for current, dirs, names in os.walk(root):
+ dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
+ for name in sorted(names):
+ path = Path(current) / name
+ if path.is_symlink():
+ raise ValueError("Refusing symlink in scan directory: " + str(path.relative_to(root)))
+ files.append((str(path.relative_to(root)), path.read_bytes()))
+ else:
+ root = Path(os.fsdecode(git("rev-parse", "--show-toplevel").strip()))
+ os.chdir(root)
+ if args.staged:
+ names = git("diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z").split(b"\0")
+ for raw in filter(None, names):
+ path = os.fsdecode(raw)
+ files.append((path, git("show", ":" + path)))
+ else:
+ for raw in filter(None, git("ls-files", "-z").split(b"\0")):
+ path = os.fsdecode(raw)
+ target = root / path
+ if target.exists() and target.is_file():
+ if target.is_symlink():
+ raise ValueError("Refusing tracked symlink: " + path)
+ files.append((path, target.read_bytes()))
+ findings = [finding for path, data in files for finding in findings_for(path, data)]
+ print(json.dumps({"files_checked": len(files), "findings": findings}, indent=2))
+ if findings:
+ print("BLOCKED: review the paths above. Matching values were not printed.", file=sys.stderr)
+ return 1
+ print("PASS: no supported credential pattern or runtime credential filename found.")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(main())
+ except (OSError, ValueError, subprocess.CalledProcessError) as exc:
+ # Do not print subprocess stdout/stderr: it could contain credential material.
+ print("Scan failed: " + type(exc).__name__ + ". Fix the input and rerun; this is not a pass.", file=sys.stderr)
+ sys.exit(2)