diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a923c59 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go vet ./... + - run: go test -race ./... + + helm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d057a62 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: release + +# Publishing a GitHub release (tag vX.Y.Z) builds the multi-arch image the +# Helm chart defaults to and attaches the packaged chart to the release. +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: write + packages: write + +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/vbilling + tags: | + type=semver,pattern=v{{version}} + type=semver,pattern=v{{major}}.{{minor}} + type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + chart: + needs: image + if: github.event_name == 'release' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: azure/setup-helm@v4 + - run: helm package deploy/helm/vbilling --destination dist + - run: gh release upload "${{ github.event.release.tag_name }}" dist/*.tgz --clobber + env: + GH_TOKEN: ${{ github.token }} diff --git a/Dockerfile b/Dockerfile index 01d446a..be4e546 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,15 @@ FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS builder -ARG TARGETOS=linux -ARG TARGETARCH=amd64 +# Filled in by BuildKit from --platform (or the host); no defaults, so a plain +# `docker build` on arm64 produces an arm64 binary. +ARG TARGETOS +ARG TARGETARCH WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /vbilling ./cmd/vbilling +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /vbilling ./cmd/vbilling FROM gcr.io/distroless/static:nonroot COPY --from=builder /vbilling /vbilling -USER nonroot:nonroot +USER 65532:65532 ENTRYPOINT ["/vbilling"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile index cbd9de8..0150a77 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ BINARY := vbilling IMAGE := ghcr.io/vclusterlabs-experiments/vbilling TAG ?= latest -.PHONY: build run test docker-build docker-push clean tidy +.PHONY: build run test lint e2e docker-build docker-push clean tidy build: CGO_ENABLED=0 go build -o bin/$(BINARY) ./cmd/vbilling @@ -11,7 +11,16 @@ run: build ./bin/$(BINARY) test: - go test ./... -v -race + go test ./... -race + +lint: + @test -z "$$(gofmt -l cmd internal)" || (gofmt -l cmd internal; exit 1) + go vet ./... + +# Full end-to-end run on kind with real tenant clusters, stripe-mock and a +# signed-webhook receiver (needs docker, kind, vcluster, helm, jq). +e2e: + ./scripts/e2e-kind.sh docker-build: docker build -t $(IMAGE):$(TAG) . diff --git a/README.md b/README.md index 98ed1b0..3c4c1f4 100644 --- a/README.md +++ b/README.md @@ -3,410 +3,442 @@

- Usage-based billing for AI Clouds running vCluster or vMetal
- Auto-discovers Tenant Clusters, meters node capacity and GPU SKUs, and streams usage events to your billing adapter. + Usage metering for AI Clouds running vCluster
+ Meters tenant clusters to the second, keeps a durable, verifiable ledger, and ships reconcilable usage to Stripe, Metronome, Lago or your own pipeline.

- Build - Go Report + Experimental License - Lago adapter - Coming soon + Destinations

--- -Built for AI Clouds and platform teams running Kubernetes with vCluster or vMetal. vBilling is the pipe, not the billing engine. You keep your billing backend (Lago today; Metronome, Stripe Meters, OpenMeter, or a custom adapter coming next). +vBilling is the **metering pipe**, not the billing engine. It turns what runs in your tenant clusters into canonical usage events. Your billing platform does the pricing, invoicing, tax and payments. vBilling makes sure every billable GPU-second reaches it exactly once, attributed to the right customer, region and SKU, and that you can prove it afterwards. -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ Control Plane Cluster │ -│ │ -│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ -│ │ Tenant │ │ Tenant │ │ Tenant │ │ -│ │ Cluster │ │ Cluster │ │ Cluster │ │ -│ │ team-alpha │ │ team-beta │ │ team-gpu │ │ -│ │ │ │ │ │ │ │ -│ │ private · │ │ private · │ │ private · │ │ -│ │ 4× A100 │ │ 2× L40S │ │ 8× H100 │ │ -│ └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ │ -│ │ │ │ │ -│ └──────────────────┼──────────────────┘ │ -│ │ │ -│ ┌───────────▼────────────┐ │ -│ │ vBilling │ │ -│ │ Controller │ │ -│ │ │ │ -│ │ • Auto-discovers │ │ -│ │ • Meters node capacity│ │ -│ │ • Streams events │ │ -│ └───────────┬────────────┘ │ -└─────────────────────────────┼───────────────────────────────────────┘ - │ Usage events (HTTP) - ┌────────────▼────────────┐ - │ Billing Adapter │ - │ │ - │ Lago · Stripe · │ - │ Metronome · Custom │ - │ │ - │ Plans · Subscriptions │ ← Provider configures - │ Invoices · Wallets │ - └─────────────────────────┘ -``` +> vBilling lives in `vClusterLabs-Experiments`: it is an experimental project, not a supported vCluster Labs product. -vBilling handles **metrics collection and event delivery**. Your billing adapter handles **pricing, plans, and invoicing**. Providers configure pricing in the adapter. vBilling never decides what to charge. +## What's new in v0.2 -## Deployment Model +- **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. +- **Durable, hash-chained ledger.** Every window is committed atomically to disk before any backend sees it. If vBilling restarts or a backend has an outage, no usage is lost, and missed windows are backfilled. +- **Several destinations at once.** Each destination has its own cursor, so a down billing API never blocks your data platform feed. +- **Deterministic event IDs.** Retries, replays and two replicas all deduplicate downstream. +- **Second-level allocation metering.** GPU time is metered from container start to finish, including fractional GPUs (MIG mixed and single strategy, GKE GPU partitions, time-slicing including GKE time-sharing) and GPUs allocated through DRA ResourceClaims. +- **Every tenancy model.** Shared nodes and dedicated nodes are metered from the control plane cluster. Tenant clusters with their own nodes (vCluster Private Nodes, Auto Nodes, Standalone) are metered through their own API, with a read-only kubeconfig vCluster exports for vBilling. No node is ever billed twice. +- **Nothing slips between windows.** Pods and nodes deleted between two collections (short jobs, Auto Nodes scale-down) are billed until the second they were deleted. +- **Bill anything Prometheus knows.** Define billable metrics as PromQL (inference tokens from vLLM, any exporter's counter), or read vCluster Platform fleet observability with one preset. +- **Fair metering.** GPU time on NotReady or unhealthy nodes is not billed. It is recorded as auditable downtime instead. +- **Reconciliation API.** It recomputes usage from the ledger and compares it with what Stripe or Metronome recorded. +- **Billing-state enforcement.** Payment failures and spend alerts can label, annotate or soft-suspend tenant clusters. +- **Ingest API.** Accepts usage vBilling cannot see itself: inference tokens, Slurm accounting, storage and network exporters. +- **Built-in dashboard, Prometheus metrics, and CSV/JSONL exports.** +- **An end-to-end example on GKE.** [`examples/gke-gpu`](examples/gke-gpu) goes from an empty project to Stripe invoices: a time-sliced L4 tenant cluster, an A100 joined as a private node with the NVIDIA GPU Operator and MIG, DCGM through your own Prometheus, and a spot preemption billed to the second. -Each tenant gets dedicated bare-metal nodes (GPUs, high-memory, etc.). Billing is based on full node allocation. The entire node is theirs. +## Architecture ``` -team-gpu's dedicated nodes -├── node-1: 8× H100, 96 CPU, 1TB RAM → metered at full node capacity -├── node-2: 8× H100, 96 CPU, 1TB RAM → metered at full node capacity -└── node-3: 8× H100, 96 CPU, 1TB RAM → metered at full node capacity + Control plane cluster (one per region) Destinations + ┌───────────────────────────────────────────────────────┐ + │ tenant clusters: team-a (shared) team-c (spot) │ + │ team-b (dedicated 8x H100) │ + │ pods · PVCs · load balancers · nodes │ ┌─► Stripe Billing Meters + │ │ │ │ + │ ┌─────────────────────▼───────────────────────────┐ │ ├─► Metronome ──► Stripe (payments, tax) + │ │ vBilling │ │ │ + │ │ discover ─► meter window ─► ledger (PVC) ─► fan-out ───┼─► Lago + │ │ ▲ hash-chained │ │ │ + │ │ ingest API ────┘ one cursor per │ │ └─► webhook: CloudEvents to your data platform + │ │ (tokens, Slurm, ...) destination │ │ + │ │ │ │ + │ │ enforcement ◄── /webhooks ◄──────────────────────────── Stripe dunning, Metronome alerts + │ │ label · annotate · admission policy │ │ + │ │ API + dashboard: usage, exports, reconcile │ │ + │ └─────────────────────────────────────────────────┘ │ + └───────────────────────────────────────────────────────┘ ``` -> Pod-level metering for shared-node platforms is present in the code but not the documented path. Primary focus is dedicated-node tenants running on AI Clouds. +1. **Discover** tenant clusters (vCluster StatefulSets and Deployments, enriched from vCluster Platform when available) and group them into billing tenants. +2. **Meter** each closed window (60s by default, aligned to the wall clock) into canonical events. +3. **Commit** the window atomically to the ledger. +4. **Deliver** to every destination at least once, with deterministic IDs so the effect is exactly-once. +5. **Close the loop**: billing webhooks drive the tenant's billing state back onto its clusters. + +## What gets metered + +| Metric | Unit | How | Key dimensions | +|---|---|---|---| +| `vcluster_gpu_hours` | gpu-hours | Container start to finish, per second. MIG at slices/7 (A30: /4) under the single or mixed strategy and on GKE GPU partitions, time-sliced at 1/replicas. Dedicated nodes in whole GPUs | `sku`, `gpu_profile`, `capacity_type`, `billing_mode`, `zone` | +| `vcluster_cpu_core_hours` | core-hours | metrics-server usage, requests, or the larger of both (`CPU_MEMORY_BASIS`) | `capacity_type`, `billing_mode` | +| `vcluster_memory_gb_hours` | gib-hours | As CPU | `capacity_type`, `billing_mode` | +| `vcluster_private_node_hours` | node-hours | Whole nodes dedicated to a tenant cluster, per node | `sku` (SKU label or instance type), `capacity_type` | +| `vcluster_storage_gb_hours` | gib-hours | Bound PVCs synced from the tenant cluster | `sku` = storage class | +| `vcluster_instance_hours` | hours | Control plane hours while ready (not while provisioning or asleep) | `tenant_class` | +| `vcluster_lb_hours` | hours | LoadBalancer services with an assigned address | | +| `vcluster_network_egress_gb` | gib | Prometheus, evaluated at the window end (configurable PromQL) | | +| `vcluster_gpu_utilization` | util-hours | DCGM via Prometheus (`namespace` or `exported_namespace` labels; GPUs in MIG mode by instance engine activity), or your `GPU_UTIL_QUERY`. Informational: never sent to Stripe or Metronome | `gpu_type` | +| `vcluster_gpu_downtime_hours` | gpu-hours | Allocated GPU time on NotReady/unhealthy nodes, and GPUs a dedicated or private node has installed but cannot allocate (a driver restarting after a reboot or a MIG change). Informational: an audit trail of credits | `gpu_type`, `node` | +| custom (`CUSTOM_METRICS`) | yours | Ingest API: inference tokens, Slurm GPU-hours, ... | yours | + +Every event carries `tenant`, `region`, `project`, `sku`, `resource_id`, `tenant_cluster`, the window, and a stable ID. Schema: [`docs/schema/usage-event.v1.json`](docs/schema/usage-event.v1.json). + +```json +{ + "schema_version": "vbilling.usage/v1", + "id": "vb1_d26414a201f1c0e1a4c1b0f0b7e6c2aa", + "tenant": "acme", + "metric": "vcluster_gpu_hours", + "quantity": 0.027222222, + "unit": "gpu-hours", + "window_start": "2026-10-05T09:58:00Z", + "window_end": "2026-10-05T09:58:20Z", + "region": "ap-southeast-2", + "project": "training", + "sku": "NVIDIA-H100-80GB-HBM3", + "resource_id": "vcluster-team-a-team-a", + "dimensions": { + "tenant_cluster": "vcluster-team-a-team-a", "gpu_type": "NVIDIA-H100-80GB-HBM3", + "gpu_profile": "full", "capacity_type": "on-demand", "billing_mode": "shared", + "tenant_class": "public", "zone": "syd-1a" + }, + "properties": {"gpu_devices": 6, "pods": 3, "backfilled": true}, + "source": "collector", + "recorded_at": "2026-10-05T09:59:08Z" +} +``` -## What Gets Metered +*(That event is real output from the end-to-end test. A new 2-GPU pod started 11 seconds into the window while vBilling was down, and the backfill billed exactly its 9 seconds.)* -| Metric | Source | Granularity | -|-------------------------|-----------------------|--------------------| -| Node hours | Node watch | Per dedicated node | -| CPU core-hours | Node capacity | Full node capacity | -| Memory GB-hours | Node capacity | Full node capacity | -| GPU hours (by SKU) | Node labels | Per GPU SKU | -| GPU utilization | DCGM via Prometheus | Per GPU % | -| Storage GB-hours | PVC sizes | Per PVC | -| Network egress GB | CNI / Prometheus | Per tenant | -| LoadBalancer hours | Service count | Per LB service | -| Control plane hours | Tenant Cluster watch | 1 per cluster | +### Metering rules that protect tenants -**GPU SKU detection** reads from node labels: -- `nvidia.com/gpu.product` (NVIDIA GPU Operator) -- `cloud.google.com/gke-accelerator` (GKE) -- `k8s.amazonaws.com/accelerator` (EKS) +- **Billing starts when a container starts**, not when the pod is created, so image pulls and scheduling are free. It stops when the last container finishes. +- **Hardware downtime is not billed.** GPU time on a node that is NotReady, or carries a taint listed in `UNHEALTHY_NODE_TAINTS`, becomes `vcluster_gpu_downtime_hours` instead of `vcluster_gpu_hours`. So does a node billed whole whose GPUs are installed (GPU Feature Discovery's `nvidia.com/gpu.count`) but not allocatable, for example while the driver rebuilds after a reboot. +- **Provisioning and sleep are free.** Control plane hours accrue only while the tenant cluster has a ready replica. +- **No double billing.** Pods on a tenant's own dedicated nodes are covered by the node allocation. The tenant cluster's own control plane pods and data PVCs are covered by instance hours (set `METER_CONTROL_PLANE=true` to bill them as well). +- **No invented usage.** Backfilled windows include only what is knowable from object lifetimes (allocations, requests). Sampled usage (metrics-server CPU, egress) is never extrapolated. +- **Deleted is not free, and not overbilled.** A pod or node deleted between collections is billed until it was deleted. A deletion noticed only after a watch reconnects is dated to the last collection that still saw it, so an outage can only under-bill. -An H100 hour and a T4 hour are emitted as separate events so providers can price each SKU differently in their billing adapter. +## Tenancy models -## Quick Start +| Tenancy model | Where workloads run | How vBilling meters it | +|---|---|---| +| Shared nodes | Control plane cluster nodes, pods synced by vCluster | From the control plane cluster: pods, volumes, load balancers (`billing_mode=shared`) | +| Dedicated nodes | Control plane cluster nodes reserved for one tenant cluster | Whole node: node-hours, whole GPUs, CPU and memory capacity (`billing_mode=dedicated_node`) | +| Private Nodes, Auto Nodes | Machines joined to the tenant cluster itself; nothing is synced to the control plane cluster | Through the tenant cluster's own API: each private node billed whole (`billing_mode=private_node`), plus the tenant cluster's volumes and load balancers. `PRIVATE_NODE_BILLING=usage` bills the pods on them instead | +| Standalone, or any cluster elsewhere | No control plane cluster | Through a kubeconfig listed in `TENANT_CLUSTERS_FILE`, like private nodes | -vBilling ships with a **Lago adapter** today. Metronome, Stripe Meters, OpenMeter, and custom adapters are on the roadmap. The install pattern is the same regardless of adapter. The walkthrough below uses Lago. +A node the tenant cluster's API reports is billed as a private node only if the control plane cluster does not have it and it is not one of vCluster's placeholder nodes (`vcluster.loft.sh/fake-node`). Shared tenant clusters are therefore never billed twice, even with tenant API access configured. Label a node `vbilling.vcluster.com/billable=false` to exempt hardware the tenant owns. -### Prerequisites +**Least-privilege access.** vBilling reads one Secret per tenant cluster, `vbilling-kubeconfig` in its namespace, and its RBAC is pinned to that Secret name. vCluster writes it with a read-only service account: -- Kubernetes cluster with Tenant Clusters running (vCluster) -- [metrics-server](https://github.com/kubernetes-sigs/metrics-server) installed -- A billing adapter: Lago instance (see [Deploying Lago](#deploying-lago)) -- Optional: Prometheus with [DCGM Exporter](https://github.com/NVIDIA/dcgm-exporter) for GPU utilization +```yaml +# vcluster.yaml (vCluster 0.36+) +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] +``` -### 1. Choose & deploy your billing adapter (Lago) +vCluster exports kubeconfigs for `localhost`; vBilling connects to the tenant cluster's Service instead and verifies TLS against `.`. `TENANT_ADMIN_FALLBACK=true` also accepts the admin kubeconfig (`vc-`), which requires giving vBilling read access to Secrets. -```bash -# Clone the vBilling repo (includes Lago docker-compose) -git clone https://github.com/vClusterLabs-Experiments/vbilling.git -cd vbilling/deploy/lago +**Outages.** If a tenant cluster's API cannot be read, the rest of the fleet is still metered, and the missed windows are filled as soon as it answers (up to `MAX_BACKFILL`). Fills are late ledger events deduplicated by ID, so a repeated fill never bills twice. -# Generate RSA key for Lago (required for JWT signing) -openssl genrsa 2048 > lago_rsa.key -openssl rsa -in lago_rsa.key -out lago_rsa.key -traditional 2>/dev/null +## Billing backends -# Create .env with Base64-encoded RSA key (Lago expects LAGO_RSA_PRIVATE_KEY) -echo "LAGO_RSA_PRIVATE_KEY=$(base64 -i lago_rsa.key | tr -d '\n')" > .env +Select one or more with `ADAPTERS` (Helm: `adapters`). -# Start Lago -docker compose --env-file .env up -d +| Adapter | What vBilling does | Pricing lives in | +|---|---|---| +| `stripe` | Creates [Billing Meters](https://docs.stripe.com/billing/subscriptions/usage-based) and posts meter events. Meters are split per SKU (`STRIPE_SPLIT_METERS_BY`) because Stripe meters cannot price on dimensions. Customers are created idempotently and never duplicated. Optionally subscribes tenants to prices tagged `metadata[vbilling_plan]`. Reconciles via meter event summaries. | Stripe prices on each meter | +| `metronome` | Creates customers with the tenant ID as ingest alias. Creates SUM billable metrics with one compound group key (`region, sku, capacity_type, billing_mode, tenant_cluster`). Ingests events and optionally creates a contract per tenant from a rate card. Reconciles via `/v1/usage`. | Metronome rate cards (pricing group keys) | +| `metronome` + `METRONOME_STRIPE_LINK=true` | Also creates the tenant's Stripe customer and links it in Metronome, so **Metronome rates and Stripe collects payment, invoices and tax**. | Metronome | +| `lago` | Customers, subscriptions and the default plan, plus events whose dimensions are properties for Lago charge filters. | Lago plans and charges | +| `webhook` | Signed CloudEvents 1.0 batches (`application/cloudevents-batch+json`), including tenant and catalog events. Use it to feed a data platform, Kafka bridge or homegrown rating engine. | wherever you send it | +| `noop` | Logs events (dry run). | n/a | -# Wait for API to be ready (~30s for database migrations) -# UI: http://localhost:8080 | API: http://localhost:3000 -``` +Stripe now steers new usage-based integrations toward Metronome, which Stripe acquired in January 2026. Both paths are supported here: Billing Meters for straightforward pay-as-you-go, Metronome for dimensional pricing, commits, credits and multi-currency rate cards. -Create an organization in Lago (first-time only): -```bash -curl -s -X POST http://localhost:3000/graphql \ - -H "Content-Type: application/json" \ - -d '{"query":"mutation { registerUser(input: { email: \"admin@example.com\", password: \"yourpassword\", organizationName: \"My Org\" }) { token } }"}' +## Quick start -# Get your API key -docker exec lago-db-1 psql -U lago -d lago -t -c "SELECT value FROM api_keys LIMIT 1;" -``` +Images for `linux/amd64` and `linux/arm64` are published to `ghcr.io/vclusterlabs-experiments/vbilling`, the chart's default. Keep billing keys in Secrets, never in `--set` flags or values files: -### 2. Install vBilling - -**Option A: Helm (production)** ```bash -# Build and push the image first -docker buildx build --platform linux/amd64,linux/arm64 \ - -t /vbilling:v0.1.0 --push . - -# Install via Helm, point vBilling at your adapter -helm upgrade --install vbilling deploy/helm/vbilling \ - --namespace vbilling-system --create-namespace \ - --set image.repository=/vbilling \ - --set image.tag=v0.1.0 \ - --set adapter=lago \ - --set lago.apiURL=http://lago-api.lago-system:3000 \ - --set lago.apiKey=YOUR_LAGO_API_KEY +git clone https://github.com/vClusterLabs-Experiments/vbilling.git && cd vbilling +kubectl create namespace vbilling-system ``` -**Option B: Run locally (development/testing)** +**Stripe (test mode)** + ```bash -make build -LAGO_API_KEY= LAGO_API_URL=http://localhost:3000 ./bin/vbilling +kubectl -n vbilling-system create secret generic vbilling-stripe --from-file=api-key=./stripe-test-key +helm upgrade --install vbilling deploy/helm/vbilling -n vbilling-system \ + --set adapters='{stripe}' --set stripe.existingSecret=vbilling-stripe \ + --set region=ap-southeast-2 --set clusterName=syd-1 ``` -### 3. Configure pricing in your adapter - -vBilling creates billable metrics and a skeleton plan with **$0 pricing**. You set your own prices in the adapter's UI or API: - -1. Open Lago UI → **Plans** → **vCluster Standard** -2. Edit each charge with your pricing: - - CPU Core-Hours: `$0.065` (your cost + margin) - - Memory GB-Hours: `$0.009` - - GPU Hours (H100): `$4.50`, or use Lago's graduated pricing for volume discounts - - Storage GB-Hours: `$0.0002` - - Network Egress GB: `$0.09` - - Node Hours: `$25.00` (for dedicated node billing) -3. Save. Pricing takes effect immediately for all tenants - -You can also create **multiple plans** (e.g., "GPU Premium", "Dev Tier") and assign different plans to different customers via the adapter's API. +vBilling creates one meter per metric (and per SKU as it sees them). Attach metered prices to the meters you want to charge for. To have vBilling subscribe each new tenant automatically, tag those prices with `metadata[vbilling_plan]=vcluster-standard` and set `stripe.autoSubscribe=true`. -### 4. Done +**Metronome rating, Stripe payments** -vBilling will: -- Auto-discover all Tenant Clusters (via StatefulSet labels or Platform API) -- Create a billing customer in your adapter for each Tenant Cluster -- Stream usage events every 60 seconds -- Your adapter generates invoices at the end of each billing period - -## How It Works - -### Discovery +```bash +kubectl -n vbilling-system create secret generic vbilling-metronome --from-file=api-token=./metronome-token +kubectl -n vbilling-system create secret generic vbilling-stripe --from-file=api-key=./stripe-live-key +kubectl -n vbilling-system create secret generic vbilling-webhook --from-file=secret=./webhook-secret +helm upgrade --install vbilling deploy/helm/vbilling -n vbilling-system \ + --set adapters='{metronome,webhook}' \ + --set metronome.existingSecret=vbilling-metronome --set metronome.rateCard=payg-aud \ + --set metronome.stripeLink=true --set stripe.existingSecret=vbilling-stripe \ + --set webhook.url=https://data.example.com/usage --set webhook.existingSecret=vbilling-webhook \ + --set region=ap-southeast-2 +``` -vBilling finds Tenant Clusters using two methods: +In Metronome, add products on the vBilling billable metrics with `pricing_group_key: ["region","sku","capacity_type"]` to price H100 differently from L40S, Sydney differently from Melbourne, and spot or preemptible at a flat discount. Rate cards carry the currency (AUD and NZD are supported natively). -1. **Label scanning** (works with OSS vCluster): Watches StatefulSets and Deployments with `app=vcluster` label -2. **Platform API** (works with vCluster Platform): Lists `VirtualClusterInstance` resources via the management API +**Shared and private GPU nodes on GKE**: [`examples/gke-gpu`](examples/gke-gpu) is a complete setup, from an empty project to Stripe invoices: a time-sliced L4 tenant cluster, an A100 joined as a vCluster private node, DCGM through Prometheus, and the manifests, values and commands for each step. Also on the [docs site](https://vclusterlabs-experiments.github.io/vbilling/example-gke.html). -### Metrics Collection Loop +**Lago (local demo)**: `deploy/lago/docker-compose.yml` runs Lago locally. See [docs/getting-started.html](docs/getting-started.html). -Every collection interval (default 60s): +Then: +```bash +kubectl -n vbilling-system port-forward svc/vbilling 8080 +# dashboard: http://localhost:8080/ +curl -s localhost:8080/api/v1/status | jq '.destinations' # delivery health +curl -s 'localhost:8080/api/v1/usage?group_by=tenant,sku' | jq # line items ``` -For each discovered Tenant Cluster: - 1. Read dedicated node capacity (labels: vcluster.loft.sh/managed-by=) - → Read full node capacity: CPU, memory, GPUs, storage - 2. Collect storage from PVCs in the namespace +![vBilling dashboard](docs/images/dashboard.jpg) - 3. Collect GPU allocation from pod nvidia.com/gpu requests - → Detect GPU SKU from the node's nvidia.com/gpu.product label +## Tenants, projects and metadata - 4. Count LoadBalancer services +By default every tenant cluster is its own billing customer, with ID `vcluster--` (the v0.1 format). Set labels or annotations on the tenant cluster's StatefulSet, its namespace, or its vCluster Platform `VirtualClusterInstance` (later sources win): - 5. Check spot vs on-demand node status for cost attribution +| Key (`vbilling.vcluster.com/...`) | Effect | +|---|---| +| `tenant` | Billing customer ID. Several tenant clusters can share one customer. | +| `display-name`, `email`, `currency` | Customer details sent to the backend | +| `project` | Cost attribution (defaults to the vCluster Platform project) | +| `tenant-class` | e.g. `public`, `enterprise`, `government`, `dev`. Stamped on events for pricing. | +| `plan` | Lago plan, Stripe plan tag, or Metronome rate card alias/ID | +| `stripe-customer-id`, `metronome-customer-id` | Pin an existing customer instead of creating one | +| `exclude` | `"true"` stops metering this tenant cluster | - 6. (If Prometheus configured) Query DCGM for GPU utilization - 7. (If Prometheus configured) Query network egress bytes +`TENANT_SOURCE=project` maps every tenant cluster in a vCluster Platform project to one customer (`project-`). `METER_BY_NAMESPACE=true` adds the namespace inside the tenant cluster as a dimension, so tenants can run their own internal chargeback. - 8. Convert all metrics to billing units: - CPU: cores × interval_hours = core-hours - Memory: GB × interval_hours = GB-hours - GPU: count × interval_hours = GPU-hours (tagged with GPU SKU) +On nodes, `vbilling.vcluster.com/capacity-type` (`on-demand`, `spot`, `preemptible`, `reserved`) and an optional SKU label (`SKU_LABEL`) take precedence over cloud-provider labels. Nodes become **dedicated** to a tenant cluster with `vbilling.vcluster.com/tenant-cluster=`, `vcluster.loft.sh/managed-by=`, or the `VCLUSTER_NODE_LABEL` pattern. - 9. Stream all events to the configured billing adapter in batch -``` +## Durability and correctness -### Billing Flow +| Guarantee | How | +|---|---| +| No usage lost on restart, upgrade or backend outage | Windows are committed to an fsynced, append-only ledger on a PVC before delivery. Each destination has a persisted cursor. On restart, missed windows (up to `MAX_BACKFILL`, default 6h) are backfilled from object lifetimes. | +| No duplicates | Event IDs are a hash of the event's identity (tenant, metric, window, dimensions), never of its quantity. Stripe dedupes on `identifier`, Metronome on `transaction_id` (34 days), Lago on `transaction_id`. A window can never be committed twice. | +| Half-written windows never escape | A window is a single write plus fsync ending in a commit record. A torn or uncommitted tail is truncated on startup and collected again. | +| Tamper evidence | Every ledger record is SHA-256 chained to the previous one. `GET /api/v1/ledger/verify` re-hashes the whole retained ledger. | +| Bad events don't block good ones | Payloads a backend rejects are isolated (partial-batch errors, or bisection) and parked as dead letters. Auth errors, rate limits and outages are retried with backoff; they are never dead-lettered. | +| Provable totals | `GET /api/v1/reconcile` recomputes each tenant's usage from the ledger and compares it with what Stripe or Metronome recorded. Dead letters are accounted for. | +| Tenant API outages do not lose usage | A tenant cluster whose own API is unreachable is recorded as a gap (persisted under `DATA_DIR`). Once it answers, exactly the missed windows are metered and appended as late events, deduplicated by ID. | +| A wrong customer is never billed | Customer search results must carry the tenant's exact metadata. Discovery errors never offboard tenants. A tenant must be gone for `OFFBOARD_GRACE` before `RemoveTenant` runs, and customers are never deleted. | -``` -Tenant Cluster created → Customer auto-created in adapter - → Subscription started (plan: vcluster-standard) - → Usage events every 60s - → Adapter aggregates over billing period - → Invoice generated (monthly) - → Webhook to payment provider (optional) - -Tenant Cluster deleted → Subscription terminated - → Final prorated invoice -``` +Running two replicas (each with its own PVC) is safe for delivery: both compute the same deterministic IDs, so backends keep the first copy. -## Configuration +## Regions and data sovereignty -### Environment Variables +Run one vBilling per control plane cluster and set `region` (for example `ap-southeast-2`, `syd-1`, `mel-1`). Every event, ledger record and billing state stays in that region's cluster. `GET /api/v1/status` reports the region, and each destination is a separately configured, documented data flow. Stripe and Metronome are hosted outside many jurisdictions (Metronome's public docs describe no regional hosting), so a sovereign region can choose to: -| Variable | Default | Description | -|----------|---------|-------------| -| `ADAPTER` | `lago` | Billing adapter to use (`lago` today; more adapters coming) | -| `LAGO_API_URL` | `http://localhost:3000` | Lago API endpoint (when `ADAPTER=lago`) | -| `LAGO_API_KEY` | (required for Lago) | Lago API key | -| `COLLECTION_INTERVAL` | `60s` | How often to scrape metrics | -| `RECONCILE_INTERVAL` | `30s` | How often to discover Tenant Clusters | -| `DEFAULT_PLAN_CODE` | `vcluster-standard` | Default plan code in the adapter | -| `BILLING_CURRENCY` | `USD` | Currency for billing | -| `PROMETHEUS_URL` | (empty) | Prometheus URL for DCGM/network | -| `SPOT_DISCOUNT_PERCENT` | `60` | Discount for pods on spot nodes | +- keep the full-fidelity ledger and the webhook feed in-region and send only billing quantities offshore +- pin customer IDs and keep names and emails out of usage events (they never contain PII; tenant IDs are yours to choose) +- run Lago or its own pipeline in-region instead -**Note:** Pricing is NOT configured via environment variables. Configure pricing in your billing adapter's UI or API. +## Billing-state enforcement -### Helm Values +Point Stripe webhooks at `/webhooks/stripe` (`STRIPE_WEBHOOK_SECRET`) and Metronome notifications at `/webhooks/metronome` (`METRONOME_WEBHOOK_SECRET`). Signatures are verified: Stripe's `t=…,v1=…` scheme, and Metronome's HMAC over `X-Metronome-Date` and the body. -```yaml -adapter: lago # billing adapter (lago today) +| Event | State | +|---|---| +| `invoice.payment_failed`, subscription `past_due` | `delinquent` | +| subscription `unpaid` / `canceled` / deleted | `suspended` | +| `billing.alert.triggered`, Metronome `alerts.*` (spend, usage, low credit or commit balance) | `warning` | +| `invoice.paid`, subscription `active` | `active` | -lago: - apiURL: "http://lago-api:3000" - apiKey: "" - existingSecret: "lago-credentials" # or use existing K8s secret +Override any mapping with `ENFORCEMENT_RULES`, for example `alerts.spend_threshold_reached=suspended` for a hard spend cap. -billing: - collectionInterval: "60s" - reconcileInterval: "30s" +`ENFORCEMENT_MODE` controls what happens: +- **`observe`** (default): record the state only. It appears in the API, metrics and dashboard. +- **`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. -prometheus: - url: "http://prometheus.monitoring:9090" # optional -``` +Metronome does not emit webhooks for Stripe payment failures, so in Metronome + Stripe mode, send Stripe's webhooks to vBilling as well. -## Use Case: AI Cloud +## Custom usage: tokens, Slurm, storage, network -Each customer gets a Tenant Cluster with dedicated bare-metal GPU nodes. vBilling meters the full node allocation by GPU SKU and streams events into whichever billing adapter you run. +Declare metrics with `CUSTOM_METRICS="code:unit[:key|key],..."` and push closed windows with a bearer token: -``` -Customer signs up - → Platform provisions Tenant Cluster + dedicated nodes (8× H100) - → vBilling discovers Tenant Cluster, detects the dedicated nodes - → Streams events: 8 GPU-hours (H100) + 96 CPU-hours + 1 TB memory-hours per hour - → Adapter (Lago) invoices monthly at the provider's rates - → Customer pays via the adapter's payments integration (e.g., Stripe webhooks) +```bash +curl -X POST localhost:8080/api/v1/events -H "Authorization: Bearer $INGEST_TOKEN" -H "X-VBilling-Client: model-gateway" -d '{ + "events": [{"tenant": "acme", "metric": "inference_output_tokens", "quantity": 1250000, + "window_start": "2026-10-05T10:00:00Z", "window_end": "2026-10-05T10:01:00Z", + "dimensions": {"model": "llama-3.3-70b"}}]}' ``` -## Dashboard +Events are validated, stamped with the region, deduplicated (send your own `id`, or let vBilling derive one from the identity), committed to the ledger, and fanned out like collector events. Billing backends create meters and metrics for custom metrics at bootstrap. -A lightweight billing dashboard is included at `dashboard/index.html`. It queries the Lago API directly and shows per-tenant usage breakdowns. (Adapter-specific dashboards for Metronome and Stripe will ship alongside those adapters.) +### Metrics from Prometheus -```bash -# Serve the dashboard -cd dashboard -python3 -m http.server 9090 -# Open http://localhost:9090 +`PROMETHEUS_METRICS_FILE` (Helm: `prometheus.metrics`) defines metrics as PromQL, evaluated for every tenant cluster at each window end. Result labels listed in `dimensions` become event dimensions and pricing group keys: + +```yaml +- code: inference_output_tokens + name: Inference output tokens + unit: tokens + billable: true + dimensions: [model_name] + sku: model_name + query: sum by (model_name) (increase(vllm:generation_tokens_total{namespace="{{namespace}}"}[{{window}}])) ``` -Features: -- Per-tenant usage cards with metric breakdown -- Total spend across all tenants -- Auto-refresh every 30 seconds -- No framework dependencies +Templates: `{{namespace}}`, `{{vcluster}}`, `{{instance}}` (vCluster Platform instance), `{{project}}`, `{{tenant}}`, `{{tenant_cluster}}`, `{{window}}` (e.g. `60s`), `{{window_seconds}}` (e.g. `60`). -## Project Structure +Billing on DCGM fields takes two precautions. The exporter refreshes values every 30 seconds by default (`DCGM_EXPORTER_INTERVAL`), so `increase()` over a window that short can read zero: integrate a gauge instead. And device-level fields (power, energy, framebuffer) repeat on every MIG instance of a GPU, so keep one series per GPU (`UUID`) before summing. GPU energy per kWh, for GPUs a tenant has to itself: -``` -cmd/vbilling/main.go Entry point; blank-imports built-in adapters -internal/ - config/config.go Configuration from env vars - destinations/ - destination.go Destination interface, Tenant/UsageEvent types, - registry, canonical metric codes - lago/ - adapter.go Lago adapter (default) - client.go Lago HTTP API client - bootstrap.go Creates metrics + skeleton plan in Lago - noop/ - adapter.go Logs events; useful for dry-run / testing - discovery/discovery.go Tenant Cluster discovery (labels + Platform API) - metrics/collector.go All metrics: CPU, memory, GPU, storage, - network, DCGM, dedicated nodes, spot/on-demand - controller/controller.go Reconciliation + event-streaming loop; - produces canonical UsageEvent values -deploy/ - helm/vbilling/ Helm chart with RBAC - lago/ Docker Compose for Lago (demo only) -dashboard/index.html Billing dashboard -scripts/demo.sh End-to-end demo using vind -Dockerfile Multi-stage distroless build -Makefile Build targets +```yaml +- code: gpu_energy_kwh + name: GPU energy + unit: kWh + billable: true + dimensions: [modelName] + sku: modelName + query: sum by (modelName) (max by (modelName, UUID, gpu, Hostname, hostname) (avg_over_time(DCGM_FI_DEV_POWER_USAGE{vcluster="{{vcluster}}"}[{{window}}]))) * {{window_seconds}} / 3.6e6 ``` -### Adapter pattern +Tenant clusters with private nodes run DCGM inside the tenant cluster, so the control plane cluster's Prometheus never scrapes it. Ship it with a Prometheus agent (or OpenTelemetry collector) that remote-writes to your Prometheus with an external label naming the tenant cluster, as `vcluster` does above, and select on that label in `GPU_UTIL_QUERY`, `GPU_COUNT_QUERY` and your metrics. With vCluster Platform fleet observability, the preset below does this for you. -Adapters live under `internal/destinations//` and implement a small Go interface: +### vCluster Platform fleet observability -```go -type Destination interface { - Name() string - Bootstrap(ctx context.Context) error - EnsureTenant(ctx context.Context, t Tenant) error - RemoveTenant(ctx context.Context, externalID string) error - SendEvents(ctx context.Context, events []UsageEvent) error -} -``` +vCluster Platform 4.11+ collects metrics from every tenant cluster, private nodes included, with an OpenTelemetry collector that pushes through the Platform's write gateway. The gateway stamps each series with `vcluster_platform_instance` and `vcluster_platform_project` from the collector's access key, after removing any such labels a tenant sent. `PROMETHEUS_PRESET=vcluster-platform` points egress and DCGM GPU utilization at those labels; set `PROMETHEUS_URL` to the fleet Prometheus (or the query proxy, with a metrics access key in `PROMETHEUS_HEADERS`). -Each adapter package registers itself from `init()`: +## HTTP API -```go -destinations.Register("lago", func(cfg *config.Config) (destinations.Destination, error) { - return &Adapter{ /* ... */ }, nil -}) -``` +| Endpoint | Purpose | +|---|---| +| `GET /` | Dashboard (embedded; no external assets, works air-gapped) | +| `GET /healthz`, `GET /readyz`, `GET /metrics` | Probes and Prometheus metrics | +| `GET /api/v1/status` | Region, destinations (cursor, lag, failures, dead letters), ledger stats | +| `GET /api/v1/usage?from&to&group_by=tenant,sku,region,day&format=csv` | Unrated line items (filters: `tenant`, `metric`, `project`, `region`, `sku`, `cluster`) | +| `GET /api/v1/events?from&to&format=jsonl\|csv` | Raw event export | +| `POST /api/v1/events` | Ingest custom usage (`INGEST_TOKEN`) | +| `GET /api/v1/reconcile?from&to&destination` | Ledger vs. backend totals (default: previous UTC day) | +| `GET /api/v1/ledger/verify` | Hash-chain verification | +| `GET /api/v1/destinations`, `GET …/{name}/dead-letters`, `POST …/{name}/dead-letters/replay`, `POST …/{name}/cursor` | Delivery operations | +| `GET /api/v1/tenants`, `GET /api/v1/billing-states`, `PUT /api/v1/billing-states/{tenant}` | Tenants and billing state | +| `POST /webhooks/stripe`, `POST /webhooks/metronome` | Billing signals | -`cmd/vbilling/main.go` blank-imports the adapters it ships with so the `init()` functions run, and `destinations.New(cfg.Adapter, cfg)` returns the one selected via `ADAPTER`. To add a new backend (Metronome, Stripe Meters, OpenMeter, custom), drop a new package under `internal/destinations/`, register a factory, blank-import it from `main.go`, and rebuild. +`API_TOKEN` protects everything under `/api` (bearer). Keep the Service internal, or put it behind your portal. -Built-in adapters today: **`lago`** (default), **`noop`** (dry-run / testing). +## Observability -## Building +Notable metrics on `/metrics`: +- **Delivery:** `vbilling_events_delivered_total{destination}`, `vbilling_delivery_failures_total{destination}`, `vbilling_events_dead_lettered_total{destination}`, `vbilling_destination_lag_records`, `vbilling_destination_lag_seconds`, `vbilling_destination_ready` +- **Metering:** `vbilling_events_metered_total`, `vbilling_backfilled_windows_total`, `vbilling_last_window_end_timestamp_seconds`, `vbilling_nodes_down` +- **Tenants:** `vbilling_tenant_billing_state{tenant,state}` +- **Ledger:** `vbilling_ledger_bytes` -```bash -make build # Build binary (local OS/arch) -make docker-build # Build Docker image (local arch) -make test # Run tests -make helm-install # Install via Helm -make tidy # go mod tidy -``` +Alert on rising lag, dead letters, or a stale last window. -### Multi-Arch Docker Image +## Configuration -For production K8s clusters (linux/amd64) and Apple Silicon (linux/arm64): +| Variable (Helm value) | Default | | +|---|---|---| +| `ADAPTERS` (`adapters`) | `lago` | Comma-separated destinations. `ADAPTER` (v0.1) still works. | +| `REGION` (`region`), `CLUSTER_NAME` (`clusterName`) | `default`, `control-plane` | Stamped on every event | +| `DATA_DIR` | `/var/lib/vbilling` | Ledger and adapter state (PVC in the chart) | +| `RETENTION` (`persistence.retention`) | `168h` | Ledger retention once every destination has consumed it | +| `COLLECTION_INTERVAL` | `60s` | Window size; must divide an hour | +| `MAX_BACKFILL`, `OFFBOARD_GRACE` | `6h`, `1h` | | +| `CPU_MEMORY_BASIS` | `usage` | `usage`, `requests` or `max` | +| `METER_CONTROL_PLANE`, `METER_BY_NAMESPACE` | `false` | | +| `GPU_RESOURCES` | `nvidia.com/gpu,amd.com/gpu` | MIG (`nvidia.com/mig-*`) and `nvidia.com/gpu.shared` are always detected | +| `SKU_LABEL`, `CAPACITY_TYPE_LABEL` | `""`, `vbilling.vcluster.com/capacity-type` | | +| `UNHEALTHY_NODE_TAINTS` | `node.kubernetes.io/not-ready,node.kubernetes.io/unreachable` | Add your GPU health taints | +| `TENANT_SOURCE`, `DEFAULT_TENANT_CLASS`, `PLATFORM_CLUSTER` | `cluster` | | +| `PROMETHEUS_URL`, `EGRESS_QUERY` | | Any Prometheus HTTP API (Prometheus, Thanos, Mimir, VictoriaMetrics). Egress and GPU utilization. Templates use `{{namespace}}`, `{{vcluster}}`, `{{window}}` | +| `GPU_UTIL_QUERY`, `GPU_COUNT_QUERY` | DCGM | Replace the utilization queries (same templates, results grouped by `modelName` or `gpu_type`); `none` disables utilization | +| `PROMETHEUS_HEADERS`, `PROMETHEUS_BEARER_TOKEN_FILE` | | Query API auth: `Name=value,...` headers (e.g. `X-Scope-OrgID`, `Authorization`) and a token file re-read per query. Basic auth also works in the URL | +| `PROMETHEUS_PRESET` | | `vcluster-platform`: vCluster Platform fleet observability label selectors | +| `PROMETHEUS_METRICS_FILE` | | Metrics defined by PromQL (see Metrics from Prometheus) | +| `TENANT_API`, `TENANT_KUBECONFIG_SECRET`, `TENANT_ADMIN_FALLBACK` | `true`, `vbilling-kubeconfig`, `false` | Meter tenant clusters with their own nodes through their own API | +| `TENANT_CLUSTERS_FILE` | | YAML list of external tenant clusters (`name`, `kubeconfig`, `tenant`, `displayName`, `project`, `metadata`) | +| `PRIVATE_NODE_BILLING`, `TENANT_EXCLUDE_NAMESPACES` | `node`, `kube-system` | `node` bills private nodes whole, `usage` bills the pods on them | +| `WATCH_DELETIONS`, `DRA_GPU_DRIVERS` | `true`, `gpu.nvidia.com,gpu.amd.com` | Bill pods and nodes deleted between windows; DRA drivers whose devices are GPUs | +| `CUSTOM_METRICS` | | e.g. `inference_output_tokens:tokens:model\|region` | +| `API_TOKEN`, `INGEST_TOKEN`, `LISTEN_ADDR` | `:8080` | | +| `ENFORCEMENT_MODE`, `ENFORCEMENT_RULES` | `observe` | | +| `STRIPE_API_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_SPLIT_METERS_BY` (`sku`), `STRIPE_AUTO_SUBSCRIBE`, `STRIPE_MAX_RPS`, `STRIPE_API_VERSION` (`2026-09-30.endive`), `STRIPE_API_BASE` | | | +| `METRONOME_API_TOKEN`, `METRONOME_WEBHOOK_SECRET`, `METRONOME_RATE_CARD`, `METRONOME_STRIPE_LINK`, `METRONOME_STRIPE_COLLECTION_METHOD` | | | +| `LAGO_API_URL`, `LAGO_API_KEY`, `DEFAULT_PLAN_CODE`, `BILLING_CURRENCY` | | | +| `WEBHOOK_URL`, `WEBHOOK_SECRET`, `WEBHOOK_HEADERS` | | | + +## Upgrading from v0.1 + +- **Workload:** the chart now deploys a **StatefulSet** with a PVC (the ledger). The unused `pricing.*` values were removed. +- **Discounts:** quantities are no longer discounted for spot nodes (`SPOT_DISCOUNT_PERCENT` is ignored). Price `capacity_type=spot|preemptible` in your backend instead, so metered quantities stay physical and reconcilable. +- **Double counting fixed:** v0.1 billed GPUs and CPU/memory on dedicated nodes twice (pod usage plus node capacity). It also over-counted egress about 5x with 60s windows. +- **Timestamps:** events use the window start, and identical windows now produce identical IDs. +- **Dashboard:** it is served by vBilling itself and reads its API (the Lago-only `dashboard/index.html` remains for reference). + +## Development ```bash -# Build and push multi-arch image -docker buildx create --use --name vbilling-builder 2>/dev/null || true -docker buildx build \ - --platform linux/amd64,linux/arm64 \ - -t /vbilling:v0.1.0 \ - --push . +make test # unit tests (race detector) +make build ``` -The Dockerfile uses multi-stage build with `gcr.io/distroless/static:nonroot` as the final image (~10MB). - -## Deploying Lago - -### Docker Compose (Development/Demo) +The Stripe adapter is also tested against [stripe-mock](https://github.com/stripe/stripe-mock), which validates every request against Stripe's OpenAPI spec. Metronome request bodies are validated against Metronome's published OpenAPI schemas in `internal/destinations/metronome/testdata`, and webhook verification against Metronome's documented test vector. -```bash -cd deploy/lago -docker compose --env-file .env up -d +``` +cmd/vbilling/ wiring +internal/usage/ canonical schema, metric catalog, deterministic IDs +internal/discovery/ tenant clusters (+ vCluster Platform enrichment) +internal/metrics/ window metering: lifetimes, fractional GPUs, fairness, Prometheus +internal/spool/ hash-chained ledger, window commits, cursors, dead letters +internal/pipeline/ per-destination delivery, retries, poison isolation, tenant sync +internal/destinations/ stripe, metronome, lago, webhook, noop +internal/enforcement/ billing webhooks -> tenant billing state -> cluster labels +internal/api/ HTTP API and dashboard ``` -### Kubernetes (Production) - -Deploy Lago as Kubernetes workloads. Key components: PostgreSQL, Redis, API (Rails), Sidekiq worker, Clock, Frontend. See [Lago docs](https://getlago.com/docs) for production guidance. +To add a destination, implement `destinations.Destination` (and optionally `Reconciler`, `CustomerResolver`, `EventFilter`), then register it in `init()`. ## Roadmap -- [x] Source / Destination plugin refactor (adapter pattern) -- [ ] Metronome adapter -- [ ] Stripe Meters adapter -- [ ] OpenMeter adapter (native CloudEvents) -- [ ] Custom adapter developer guide -- [ ] MIG (Multi-Instance GPU) partition tracking -- [ ] Grafana dashboard integration -- [ ] Budget alerts per Tenant Cluster -- [ ] Reserved capacity / commitment pricing -- [ ] Auto Nodes billing (dynamic node provisioning events) -- [ ] Netris network isolation billing integration +- Hourly usage rollups so long-range usage queries stay fast on large fleets (today the usage API scans the ledger: about 6.6 µs per event) +- Slurm accounting (`sacct`) exporter +- Shared DRA devices (consumable capacity) and per-namespace showback of private nodes +- OpenMeter and Kafka destinations, S3/Parquet export +- Reseller and partner attribution, and pre-provisioning cost estimates +- Leader election for single-writer HA ## License diff --git a/cmd/vbilling/main.go b/cmd/vbilling/main.go index 18b8618..bb1b1cc 100644 --- a/cmd/vbilling/main.go +++ b/cmd/vbilling/main.go @@ -2,111 +2,270 @@ package main import ( "context" + "errors" "log" + "net/http" "os" "os/signal" + "path/filepath" + "sync" "syscall" + "time" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + metricsclient "k8s.io/metrics/pkg/client/clientset/versioned" + + "github.com/vclusterlabs-experiments/vbilling/internal/api" "github.com/vclusterlabs-experiments/vbilling/internal/config" "github.com/vclusterlabs-experiments/vbilling/internal/controller" "github.com/vclusterlabs-experiments/vbilling/internal/destinations" + "github.com/vclusterlabs-experiments/vbilling/internal/destinations/metronome" + "github.com/vclusterlabs-experiments/vbilling/internal/destinations/stripe" "github.com/vclusterlabs-experiments/vbilling/internal/discovery" + "github.com/vclusterlabs-experiments/vbilling/internal/enforcement" + "github.com/vclusterlabs-experiments/vbilling/internal/kv" "github.com/vclusterlabs-experiments/vbilling/internal/metrics" + "github.com/vclusterlabs-experiments/vbilling/internal/pipeline" + "github.com/vclusterlabs-experiments/vbilling/internal/spool" + "github.com/vclusterlabs-experiments/vbilling/internal/telemetry" + "github.com/vclusterlabs-experiments/vbilling/internal/tenantapi" + "github.com/vclusterlabs-experiments/vbilling/internal/usage" - // Register built-in adapters via blank import so their init() funcs run. + // Register the remaining built-in adapters. _ "github.com/vclusterlabs-experiments/vbilling/internal/destinations/lago" _ "github.com/vclusterlabs-experiments/vbilling/internal/destinations/noop" - - "k8s.io/client-go/dynamic" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" - metricsclient "k8s.io/metrics/pkg/client/clientset/versioned" + _ "github.com/vclusterlabs-experiments/vbilling/internal/destinations/webhook" ) +var version = "0.2.0" + func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) - log.Println("vBilling - vCluster Billing Controller") - log.Println("=======================================") + log.Printf("vBilling %s: usage metering for tenant clusters", version) cfg := config.Load() - log.Printf("Adapter: %s", cfg.Adapter) - log.Printf("Plan: %s | Currency: %s", cfg.DefaultPlanCode, cfg.BillingCurrency) - log.Printf("Collection: %s | Reconcile: %s", cfg.CollectionInterval, cfg.ReconcileInterval) + if err := cfg.Validate(); err != nil { + log.Fatalf("invalid configuration: %v", err) + } + if cfg.SpotDiscountPercent > 0 { + log.Printf("WARNING: SPOT_DISCOUNT_PERCENT is deprecated and ignored. Quantities are no longer discounted; " + + "price spot and preemptible capacity in your billing backend using the capacity_type dimension.") + } + customs, err := usage.ParseCustomMetrics(cfg.CustomMetrics) + if err != nil { + log.Fatalf("CUSTOM_METRICS: %v", err) + } + for _, d := range customs { + if err := usage.RegisterCustom(d); err != nil { + log.Fatalf("CUSTOM_METRICS: %v", err) + } + } + promMetrics, err := metrics.LoadPromMetrics(cfg.PrometheusMetrics) + if err != nil { + log.Fatalf("PROMETHEUS_METRICS_FILE: %v", err) + } + log.Printf("Adapters: %v | Region: %s | Window: %s | CPU/memory basis: %s | Data: %s", + cfg.Adapters, cfg.Region, cfg.CollectionInterval, cfg.CPUMemoryBasis, cfg.DataDir) - dest, err := destinations.New(cfg.Adapter, cfg) + sp, err := spool.Open(filepath.Join(cfg.DataDir, "ledger"), spool.Options{Retention: cfg.Retention}) if err != nil { - log.Fatalf("Failed to initialize billing adapter: %v", err) + log.Fatalf("open ledger: %v", err) + } + dests, err := destinations.NewAll(cfg.Adapters, cfg) + if err != nil { + log.Fatalf("initialize adapters: %v", err) } kubeConfig, err := getKubeConfig() if err != nil { - log.Fatalf("Failed to get Kubernetes config: %v", err) + log.Fatalf("kubernetes config: %v", err) } - - kubeClient, err := kubernetes.NewForConfig(kubeConfig) + kube, err := kubernetes.NewForConfig(kubeConfig) if err != nil { - log.Fatalf("Failed to create Kubernetes client: %v", err) + log.Fatalf("kubernetes client: %v", err) } - metricsClient, err := metricsclient.NewForConfig(kubeConfig) if err != nil { - log.Fatalf("Failed to create metrics client: %v", err) + log.Fatalf("metrics client: %v", err) } - dynamicClient, err := dynamic.NewForConfig(kubeConfig) if err != nil { - log.Fatalf("Failed to create dynamic client: %v", err) + log.Fatalf("dynamic client: %v", err) } - ctx, cancel := context.WithCancel(context.Background()) + tel := telemetry.Default + tenants := pipeline.NewTenantRegistry() + disp := pipeline.New(sp, dests, pipeline.Options{ + Tenants: tenants, + Bootstrap: func(ctx context.Context, d destinations.Destination) error { return d.Bootstrap(ctx, usage.Catalog()) }, + }, tel) + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() - log.Printf("Bootstrapping %s adapter...", dest.Name()) - if err := dest.Bootstrap(ctx); err != nil { - log.Printf("WARNING: %s bootstrap failed: %v", dest.Name(), err) - log.Println("The controller will continue but billing may not work correctly.") - } + disc := discovery.NewDiscoverer(kube, dynamicClient, cfg.WatchNamespaces, cfg.PlatformCluster) + coll := metrics.NewCollector(kube, metricsClient, metrics.Options{ + Region: cfg.Region, + RegionFromNode: cfg.RegionFromNode, + Basis: cfg.CPUMemoryBasis, + MeterControlPlane: cfg.MeterControlPlane, + MeterByNamespace: cfg.MeterByNamespace, + GPUResources: cfg.GPUResources, + SKULabel: cfg.SKULabel, + CapacityTypeLabel: cfg.CapacityTypeLabel, + UnhealthyTaints: cfg.UnhealthyTaints, + DedicatedNodeLabel: cfg.DedicatedNodeLabel, + PrometheusURL: cfg.PrometheusURL, + PromHeaders: cfg.PrometheusHeaders, + PromTokenFile: cfg.PromTokenFile, + EgressQuery: cfg.EgressQuery, + GPUUtilQuery: cfg.GPUUtilQuery, + GPUCountQuery: cfg.GPUCountQuery, + PrometheusPreset: cfg.PrometheusPreset, + PromMetrics: promMetrics, - disc := discovery.NewDiscoverer(kubeClient, dynamicClient, cfg.WatchNamespaces) - coll := metrics.NewCollector(kubeClient, metricsClient, cfg.PrometheusURL, cfg.SpotDiscountPercent) - ctrl := controller.New(cfg, dest, disc, coll) + PrivateNodeBilling: cfg.PrivateNodeBilling, + TenantExcludeNamespaces: cfg.TenantExcludeNamespaces, + DRAGPUDrivers: cfg.DRAGPUDrivers, + }) + coll.UseDynamic(dynamicClient) + // Deleted pods and nodes stay billable until their windows are committed. + retention := cfg.MaxBackfill + 2*cfg.CollectionInterval + if cfg.WatchDeletions { + g := metrics.NewGraveyard(retention, time.Now) + go g.Watch(ctx, kube, metrics.LabelManagedBy, true, true) + coll.UseGraveyard(g) + } + if cfg.TenantAPI { + ext, err := tenantapi.LoadExternal(cfg.TenantClustersFile) + if err != nil { + log.Fatalf("TENANT_CLUSTERS_FILE: %v", err) + } + mgr := tenantapi.New(ctx, kube, tenantapi.Options{ + SecretName: cfg.TenantKubeconfigSecret, + AdminFallback: cfg.TenantAdminFallback, + External: ext, + WatchPods: cfg.PrivateNodeBilling == "usage", + Retention: retention, + }) + coll.UseTenantAPI(mgr) + disc.WithExternal(mgr.Clusters()) + log.Printf("Tenant APIs: Secret %q per tenant cluster (admin fallback %t), %d external tenant cluster(s), private nodes billed by %s", + cfg.TenantKubeconfigSecret, cfg.TenantAdminFallback, len(ext), cfg.PrivateNodeBilling) + } + ctrl := controller.New(cfg, disc, coll, sp, tenants, dests, tel) + enf := newEnforcer(cfg, kube, ctrl, dests, tel) - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + srv := &http.Server{ + Addr: cfg.ListenAddr, + Handler: api.New(cfg, sp, disp, tenants, ctrl, enf, tel, version).Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + if cfg.APIToken == "" { + log.Printf("WARNING: API_TOKEN is not set; usage and admin endpoints on %s are unauthenticated (keep the Service internal)", cfg.ListenAddr) + } + var wg sync.WaitGroup + wg.Add(3) go func() { - sig := <-sigCh - log.Printf("Received signal %s, shutting down...", sig) - cancel() + defer wg.Done() + log.Printf("API listening on %s", cfg.ListenAddr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("api server: %v", err) + } + }() + go func() { + defer wg.Done() + disp.Run(ctx) + }() + go func() { + defer wg.Done() + compactLoop(ctx, sp, cfg.Adapters) }() - log.Println("Starting billing controller...") - if err := ctrl.Run(ctx); err != nil && err != context.Canceled { - log.Fatalf("Controller error: %v", err) + if err := ctrl.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + log.Printf("controller: %v", err) + } + shutdownCtx, done := context.WithTimeout(context.Background(), 10*time.Second) + defer done() + srv.Shutdown(shutdownCtx) + wg.Wait() + if err := sp.Close(); err != nil { + log.Printf("close ledger: %v", err) } - log.Println("vBilling stopped") } -func getKubeConfig() (*rest.Config, error) { - cfg, err := rest.InClusterConfig() - if err == nil { - log.Println("Using in-cluster Kubernetes config") - return cfg, nil +// newEnforcer enables billing-state enforcement when a webhook secret is set. +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) + } + resolvers := map[string]destinations.CustomerResolver{} + for _, d := range dests { + switch a := d.(type) { + case *stripe.Adapter: + resolvers["stripe"] = a + case *metronome.Adapter: + resolvers["metronome"] = a + if link := a.StripeLink(); link != nil { + if _, ok := resolvers["stripe"]; !ok { + resolvers["stripe"] = link // Stripe dunning for Metronome-rated tenants + } + } + } + } + state, err := kv.Open(filepath.Join(cfg.DataDir, "billing-state.json")) + if err != nil { + log.Fatalf("open billing state: %v", err) + } + log.Printf("Billing-state enforcement enabled (mode=%s)", cfg.EnforcementMode) + return enforcement.New(kube, enforcement.Options{ + Mode: cfg.EnforcementMode, + StripeWebhookSecret: cfg.StripeWebhookSecret, + MetronomeWebhookSecret: cfg.MetronomeWebhookSecret, + Rules: rules, + Clusters: ctrl.ClustersOf, + Resolvers: resolvers, + State: state, + Telemetry: tel, + }) +} - kubeconfig := os.Getenv("KUBECONFIG") - if kubeconfig == "" { - home, _ := os.UserHomeDir() - kubeconfig = home + "/.kube/config" +// compactLoop applies ledger retention hourly. +func compactLoop(ctx context.Context, sp *spool.Spool, consumers []string) { + t := time.NewTicker(time.Hour) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if n, err := sp.Compact(consumers); err != nil { + log.Printf("ledger compaction: %v", err) + } else if n > 0 { + log.Printf("ledger compaction removed %d segment(s) past retention", n) + } + } } +} - cfg, err = clientcmd.BuildConfigFromFlags("", kubeconfig) +func getKubeConfig() (*rest.Config, error) { + if cfg, err := rest.InClusterConfig(); err == nil { + log.Println("Using in-cluster Kubernetes config") + return cfg, nil + } + rules := clientcmd.NewDefaultClientConfigLoadingRules() + cfg, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, &clientcmd.ConfigOverrides{}).ClientConfig() if err != nil { return nil, err } - - log.Printf("Using kubeconfig: %s", kubeconfig) + log.Printf("Using kubeconfig (%s)", os.Getenv("KUBECONFIG")) return cfg, nil } diff --git a/deploy/helm/vbilling/Chart.yaml b/deploy/helm/vbilling/Chart.yaml index 96cee5c..0e076f5 100644 --- a/deploy/helm/vbilling/Chart.yaml +++ b/deploy/helm/vbilling/Chart.yaml @@ -1,20 +1,20 @@ apiVersion: v2 name: vbilling -description: vBilling — adapter-agnostic usage-based billing pipe for Tenant Clusters +description: vBilling meters tenant clusters and ships durable, reconcilable usage events to Stripe, Metronome, Lago or your own pipeline type: application -version: 0.1.0 -appVersion: "0.1.0" +version: 0.2.0 +appVersion: "0.2.0" home: https://github.com/vclusterlabs-experiments/vbilling keywords: - vcluster - billing - metering - - lago - - metronome - stripe + - metronome + - lago + - gpu - kubernetes - tenant-isolation - - gpu maintainers: - name: vCluster Labs url: https://www.vcluster.com diff --git a/deploy/helm/vbilling/templates/NOTES.txt b/deploy/helm/vbilling/templates/NOTES.txt new file mode 100644 index 0000000..c2bfa3f --- /dev/null +++ b/deploy/helm/vbilling/templates/NOTES.txt @@ -0,0 +1,17 @@ +vBilling {{ .Chart.AppVersion }} is metering tenant clusters in region "{{ .Values.region }}". +Destinations: {{ include "vbilling.adapters" . }} + + kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "vbilling.fullname" . }} {{ .Values.api.service.port }} + curl -s localhost:{{ .Values.api.service.port }}/api/v1/status # destinations, lag, last window + curl -s localhost:{{ .Values.api.service.port }}/api/v1/usage?group_by=tenant,sku + curl -s localhost:{{ .Values.api.service.port }}/api/v1/reconcile # ledger vs billing backend, previous UTC day + open http://localhost:{{ .Values.api.service.port }}/ # usage dashboard +{{- if not .Values.persistence.enabled }} + +WARNING: persistence is disabled. The ledger is the durable outbox for usage that +has not reached your billing backend yet; without a volume it is lost on restart. +{{- end }} +{{- if not .Values.api.token }} + +WARNING: api.token is empty, so usage and admin endpoints are unauthenticated. Keep the Service internal. +{{- end }} diff --git a/deploy/helm/vbilling/templates/_helpers.tpl b/deploy/helm/vbilling/templates/_helpers.tpl index 0bc0203..3d449be 100644 --- a/deploy/helm/vbilling/templates/_helpers.tpl +++ b/deploy/helm/vbilling/templates/_helpers.tpl @@ -50,3 +50,36 @@ Service account name {{- default "default" .Values.serviceAccount.name }} {{- end }} {{- end }} + +{{/* +Adapter list (falls back to the deprecated single `adapter` value). +*/}} +{{- define "vbilling.adapters" -}} +{{- $a := .Values.adapters | default list -}} +{{- if and (eq (len $a) 0) .Values.adapter -}} +{{- $a = list .Values.adapter -}} +{{- end -}} +{{- join "," $a -}} +{{- end }} + +{{/* +Secret-backed env var. Uses the component's existingSecret when set, +otherwise the chart-managed secret when an inline value is given. +Args: dict "env" NAME "existing" SECRET "key" KEY "inline" VALUE "chartKey" KEY "root" $ +*/}} +{{- define "vbilling.secretEnv" -}} +{{- if .existing }} +- name: {{ .env }} + valueFrom: + secretKeyRef: + name: {{ .existing }} + key: {{ .key }} + optional: true +{{- else if .inline }} +- name: {{ .env }} + valueFrom: + secretKeyRef: + name: {{ include "vbilling.fullname" .root }} + key: {{ .chartKey }} +{{- end }} +{{- end }} diff --git a/deploy/helm/vbilling/templates/admissionpolicy.yaml b/deploy/helm/vbilling/templates/admissionpolicy.yaml new file mode 100644 index 0000000..930d296 --- /dev/null +++ b/deploy/helm/vbilling/templates/admissionpolicy.yaml @@ -0,0 +1,35 @@ +{{- if .Values.enforcement.admissionPolicy.enabled }} +# Blocks new pods in namespaces vBilling labeled suspended (ENFORCEMENT_MODE=enforce). +# Running workloads keep running; the tenant cluster's own control plane may restart. +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicy +metadata: + name: {{ include "vbilling.fullname" . }}-suspended-tenants + labels: + {{- include "vbilling.labels" . | nindent 4 }} +spec: + failurePolicy: Fail + matchConstraints: + namespaceSelector: + matchLabels: + vbilling.vcluster.com/suspended: "true" + resourceRules: + - apiGroups: [""] + apiVersions: ["v1"] + operations: ["CREATE"] + resources: ["pods"] + validations: + - expression: "has(object.metadata.labels) && 'app' in object.metadata.labels && object.metadata.labels['app'] == 'vcluster'" + message: "This tenant is suspended for billing. New workloads are blocked until the account is current; running workloads are not affected." + reason: Forbidden +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicyBinding +metadata: + name: {{ include "vbilling.fullname" . }}-suspended-tenants + labels: + {{- include "vbilling.labels" . | nindent 4 }} +spec: + policyName: {{ include "vbilling.fullname" . }}-suspended-tenants + validationActions: [Deny] +{{- end }} diff --git a/deploy/helm/vbilling/templates/clusterrole.yaml b/deploy/helm/vbilling/templates/clusterrole.yaml index 9f89538..764d85b 100644 --- a/deploy/helm/vbilling/templates/clusterrole.yaml +++ b/deploy/helm/vbilling/templates/clusterrole.yaml @@ -5,31 +5,50 @@ metadata: labels: {{- include "vbilling.labels" . | nindent 4 }} rules: - # Discover vClusters via StatefulSets and Deployments + # Discover tenant clusters (StatefulSet or Deployment control planes). - apiGroups: ["apps"] resources: ["statefulsets", "deployments"] - verbs: ["list", "watch", "get"] - # Read pod details for GPU allocation and node mapping + verbs: ["get", "list", "watch"] + # Meter synced workloads, volumes, load balancers and nodes; read namespace + # metadata. watch: pods and nodes deleted between windows are still billed. - apiGroups: [""] - resources: ["pods"] - verbs: ["list", "get"] - # Read PVCs for storage billing - - apiGroups: [""] - resources: ["persistentvolumeclaims"] - verbs: ["list", "get"] - # Read services for LoadBalancer billing - - apiGroups: [""] - resources: ["services"] - verbs: ["list", "get"] - # Read nodes for GPU type detection and spot/on-demand status + resources: ["pods", "persistentvolumeclaims", "services", "nodes", "namespaces"] + verbs: ["get", "list", "watch"] + # GPUs allocated through Dynamic Resource Allocation. + - apiGroups: ["resource.k8s.io"] + resources: ["resourceclaims", "resourceslices"] + verbs: ["get", "list"] + {{- if .Values.tenantAPI.enabled }} + # Tenant clusters with their own nodes: only the read-only kubeconfig + # vCluster exports for vBilling. - apiGroups: [""] - resources: ["nodes"] - verbs: ["list", "get"] - # Read namespaces for vCluster namespace enumeration + resources: ["secrets"] + resourceNames: [{{ .Values.tenantAPI.secretName | quote }}] + verbs: ["get"] + {{- if .Values.tenantAPI.adminFallback }} + # tenantAPI.adminFallback: tenant clusters' admin kubeconfigs (vc-). - apiGroups: [""] - resources: ["namespaces"] - verbs: ["list", "get"] - # Read pod metrics from metrics-server + resources: ["secrets"] + verbs: ["get"] + {{- end }} + {{- end }} + # Usage-based CPU and memory from metrics-server. - apiGroups: ["metrics.k8s.io"] resources: ["pods"] - verbs: ["list", "get"] + verbs: ["get", "list"] + # Optional enrichment from vCluster Platform (project, display name). + - apiGroups: ["management.loft.sh"] + resources: ["virtualclusterinstances"] + verbs: ["get", "list"] + {{- if ne .Values.enforcement.mode "observe" }} + # Billing-state enforcement: annotate tenant clusters, label namespaces, emit events. + - apiGroups: ["apps"] + resources: ["statefulsets", "deployments"] + verbs: ["patch"] + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["patch"] + - apiGroups: [""] + resources: ["events"] + verbs: ["create"] + {{- end }} diff --git a/deploy/helm/vbilling/templates/deployment.yaml b/deploy/helm/vbilling/templates/deployment.yaml deleted file mode 100644 index 40b529e..0000000 --- a/deploy/helm/vbilling/templates/deployment.yaml +++ /dev/null @@ -1,82 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "vbilling.fullname" . }} - namespace: {{ .Release.Namespace }} - labels: - {{- include "vbilling.labels" . | nindent 4 }} -spec: - replicas: {{ .Values.replicaCount }} - selector: - matchLabels: - {{- include "vbilling.selectorLabels" . | nindent 6 }} - template: - metadata: - labels: - {{- include "vbilling.selectorLabels" . | nindent 8 }} - spec: - serviceAccountName: {{ include "vbilling.serviceAccountName" . }} - containers: - - name: vbilling - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - env: - - name: ADAPTER - value: {{ .Values.adapter | default "lago" | quote }} - {{- if eq (.Values.adapter | default "lago") "lago" }} - - name: LAGO_API_URL - value: {{ .Values.lago.apiURL | quote }} - - name: LAGO_API_KEY - {{- if .Values.lago.existingSecret }} - valueFrom: - secretKeyRef: - name: {{ .Values.lago.existingSecret }} - key: api-key - {{- else }} - value: {{ .Values.lago.apiKey | quote }} - {{- end }} - {{- end }} - - name: DEFAULT_PLAN_CODE - value: {{ .Values.billing.defaultPlanCode | quote }} - - name: BILLING_CURRENCY - value: {{ .Values.billing.currency | quote }} - - name: COLLECTION_INTERVAL - value: {{ .Values.billing.collectionInterval | quote }} - - name: RECONCILE_INTERVAL - value: {{ .Values.billing.reconcileInterval | quote }} - - name: CPU_PRICE_PER_CORE_HOUR - value: {{ .Values.pricing.cpuPerCoreHour | quote }} - - name: MEMORY_PRICE_PER_GB_HOUR - value: {{ .Values.pricing.memoryPerGBHour | quote }} - - name: STORAGE_PRICE_PER_GB_HOUR - value: {{ .Values.pricing.storagePerGBHour | quote }} - - name: INSTANCE_PRICE_PER_HOUR - value: {{ .Values.pricing.instancePerHour | quote }} - - name: GPU_DEFAULT_PRICE_PER_HOUR - value: {{ .Values.pricing.gpuDefaultPerHour | quote }} - - name: GPU_PRICES - value: {{ .Values.pricing.gpuPrices | quote }} - - name: NETWORK_EGRESS_PRICE_PER_GB - value: {{ .Values.pricing.networkEgressPerGB | quote }} - - name: LB_PRICE_PER_HOUR - value: {{ .Values.pricing.lbPerHour | quote }} - - name: SPOT_DISCOUNT_PERCENT - value: {{ .Values.pricing.spotDiscountPercent | quote }} - {{- if .Values.prometheus.url }} - - name: PROMETHEUS_URL - value: {{ .Values.prometheus.url | quote }} - {{- end }} - resources: - {{- toYaml .Values.resources | nindent 12 }} - {{- with .Values.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} diff --git a/deploy/helm/vbilling/templates/prometheus-metrics.yaml b/deploy/helm/vbilling/templates/prometheus-metrics.yaml new file mode 100644 index 0000000..ce45c5c --- /dev/null +++ b/deploy/helm/vbilling/templates/prometheus-metrics.yaml @@ -0,0 +1,12 @@ +{{- if .Values.prometheus.metrics }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "vbilling.fullname" . }}-prometheus-metrics + namespace: {{ .Release.Namespace }} + labels: + {{- include "vbilling.labels" . | nindent 4 }} +data: + metrics.yaml: | + {{- toYaml .Values.prometheus.metrics | nindent 4 }} +{{- end }} diff --git a/deploy/helm/vbilling/templates/secret.yaml b/deploy/helm/vbilling/templates/secret.yaml new file mode 100644 index 0000000..496a39a --- /dev/null +++ b/deploy/helm/vbilling/templates/secret.yaml @@ -0,0 +1,30 @@ +{{- $data := dict -}} +{{- if and .Values.lago.apiKey (not .Values.lago.existingSecret) }}{{ $_ := set $data "lago-api-key" .Values.lago.apiKey }}{{ end -}} +{{- if not .Values.stripe.existingSecret }} +{{- if .Values.stripe.apiKey }}{{ $_ := set $data "stripe-api-key" .Values.stripe.apiKey }}{{ end }} +{{- if .Values.stripe.webhookSecret }}{{ $_ := set $data "stripe-webhook-secret" .Values.stripe.webhookSecret }}{{ end }} +{{- end -}} +{{- if not .Values.metronome.existingSecret }} +{{- if .Values.metronome.apiToken }}{{ $_ := set $data "metronome-api-token" .Values.metronome.apiToken }}{{ end }} +{{- if .Values.metronome.webhookSecret }}{{ $_ := set $data "metronome-webhook-secret" .Values.metronome.webhookSecret }}{{ end }} +{{- end -}} +{{- if and .Values.prometheus.headers (not .Values.prometheus.existingSecret) }}{{ $h := list }}{{ range $k, $v := .Values.prometheus.headers }}{{ $h = append $h (printf "%s=%s" $k $v) }}{{ end }}{{ $_ := set $data "prometheus-headers" (join "," $h) }}{{ end -}} +{{- if and .Values.webhook.secret (not .Values.webhook.existingSecret) }}{{ $_ := set $data "webhook-secret" .Values.webhook.secret }}{{ end -}} +{{- if not .Values.api.existingSecret }} +{{- if .Values.api.token }}{{ $_ := set $data "api-token" .Values.api.token }}{{ end }} +{{- if .Values.api.ingestToken }}{{ $_ := set $data "ingest-token" .Values.api.ingestToken }}{{ end }} +{{- end -}} +{{- if $data }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "vbilling.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "vbilling.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- range $k, $v := $data }} + {{ $k }}: {{ $v | quote }} + {{- end }} +{{- end }} diff --git a/deploy/helm/vbilling/templates/service.yaml b/deploy/helm/vbilling/templates/service.yaml new file mode 100644 index 0000000..9426f37 --- /dev/null +++ b/deploy/helm/vbilling/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "vbilling.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "vbilling.labels" . | nindent 4 }} +spec: + type: {{ .Values.api.service.type }} + selector: + {{- include "vbilling.selectorLabels" . | nindent 4 }} + ports: + - name: http + port: {{ .Values.api.service.port }} + targetPort: http diff --git a/deploy/helm/vbilling/templates/servicemonitor.yaml b/deploy/helm/vbilling/templates/servicemonitor.yaml new file mode 100644 index 0000000..370b99f --- /dev/null +++ b/deploy/helm/vbilling/templates/servicemonitor.yaml @@ -0,0 +1,17 @@ +{{- if .Values.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "vbilling.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "vbilling.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "vbilling.selectorLabels" . | nindent 6 }} + endpoints: + - port: http + path: /metrics + interval: {{ .Values.serviceMonitor.interval }} +{{- end }} diff --git a/deploy/helm/vbilling/templates/statefulset.yaml b/deploy/helm/vbilling/templates/statefulset.yaml new file mode 100644 index 0000000..a713cce --- /dev/null +++ b/deploy/helm/vbilling/templates/statefulset.yaml @@ -0,0 +1,242 @@ +{{- $adapters := include "vbilling.adapters" . | splitList "," -}} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "vbilling.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "vbilling.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + serviceName: {{ include "vbilling.fullname" . }} + selector: + matchLabels: + {{- include "vbilling.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "vbilling.selectorLabels" . | nindent 8 }} + annotations: + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + checksum/prometheus-metrics: {{ toYaml .Values.prometheus.metrics | sha256sum }} + spec: + serviceAccountName: {{ include "vbilling.serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: vbilling + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + ports: + - name: http + containerPort: {{ .Values.api.port }} + readinessProbe: + httpGet: {path: /readyz, port: http} + periodSeconds: 10 + livenessProbe: + httpGet: {path: /healthz, port: http} + periodSeconds: 20 + env: + - name: ADAPTERS + value: {{ include "vbilling.adapters" . | quote }} + - name: REGION + value: {{ .Values.region | quote }} + - name: CLUSTER_NAME + value: {{ .Values.clusterName | quote }} + - name: DEFAULT_TENANT_CLASS + value: {{ .Values.tenantClass | quote }} + - name: TENANT_SOURCE + value: {{ .Values.tenantSource | quote }} + - name: DATA_DIR + value: /var/lib/vbilling + - name: RETENTION + value: {{ .Values.persistence.retention | quote }} + - name: LISTEN_ADDR + value: ":{{ .Values.api.port }}" + - name: DEFAULT_PLAN_CODE + value: {{ .Values.billing.defaultPlanCode | quote }} + - name: BILLING_CURRENCY + value: {{ .Values.billing.currency | quote }} + - name: COLLECTION_INTERVAL + value: {{ .Values.billing.collectionInterval | quote }} + - name: RECONCILE_INTERVAL + value: {{ .Values.billing.reconcileInterval | quote }} + - name: MAX_BACKFILL + value: {{ .Values.billing.maxBackfill | quote }} + - name: OFFBOARD_GRACE + value: {{ .Values.billing.offboardGrace | quote }} + - name: CPU_MEMORY_BASIS + value: {{ .Values.metering.cpuMemoryBasis | quote }} + - name: METER_CONTROL_PLANE + value: {{ .Values.metering.meterControlPlane | quote }} + - name: METER_BY_NAMESPACE + value: {{ .Values.metering.meterByNamespace | quote }} + - name: GPU_RESOURCES + value: {{ join "," .Values.metering.gpuResources | default "none" | quote }} + - name: SKU_LABEL + value: {{ .Values.metering.skuLabel | quote }} + - name: CAPACITY_TYPE_LABEL + value: {{ .Values.metering.capacityTypeLabel | quote }} + - name: UNHEALTHY_NODE_TAINTS + value: {{ join "," .Values.metering.unhealthyNodeTaints | default "none" | quote }} + - name: VCLUSTER_NODE_LABEL + value: {{ .Values.metering.dedicatedNodeLabel | quote }} + - name: REGION_FROM_NODE + value: {{ .Values.metering.regionFromNode | quote }} + - name: CUSTOM_METRICS + value: {{ .Values.metering.customMetrics | quote }} + - name: WATCH_DELETIONS + value: {{ .Values.metering.watchDeletions | quote }} + - name: DRA_GPU_DRIVERS + value: {{ join "," .Values.metering.draGPUDrivers | default "none" | quote }} + - name: TENANT_API + value: {{ .Values.tenantAPI.enabled | quote }} + - name: TENANT_KUBECONFIG_SECRET + value: {{ .Values.tenantAPI.secretName | quote }} + - name: TENANT_ADMIN_FALLBACK + value: {{ .Values.tenantAPI.adminFallback | quote }} + - name: PRIVATE_NODE_BILLING + value: {{ .Values.tenantAPI.privateNodeBilling | quote }} + - name: TENANT_EXCLUDE_NAMESPACES + value: {{ join "," .Values.tenantAPI.excludeNamespaces | default "none" | quote }} + {{- if .Values.tenantAPI.externalClustersSecret }} + - name: TENANT_CLUSTERS_FILE + value: /etc/vbilling/tenants/clusters.yaml + {{- end }} + - name: PLATFORM_CLUSTER + value: {{ .Values.platform.cluster | quote }} + - name: WATCH_NAMESPACES + value: {{ join "," .Values.watchNamespaces | quote }} + - name: ENFORCEMENT_MODE + value: {{ .Values.enforcement.mode | quote }} + - name: ENFORCEMENT_RULES + value: {{ .Values.enforcement.rules | quote }} + {{- if .Values.prometheus.url }} + - name: PROMETHEUS_URL + value: {{ .Values.prometheus.url | quote }} + - name: EGRESS_QUERY + value: {{ .Values.prometheus.egressQuery | quote }} + - name: GPU_UTIL_QUERY + value: {{ .Values.prometheus.gpuUtilQuery | quote }} + - name: GPU_COUNT_QUERY + value: {{ .Values.prometheus.gpuCountQuery | quote }} + - name: PROMETHEUS_PRESET + value: {{ .Values.prometheus.preset | quote }} + {{- if .Values.prometheus.metrics }} + - name: PROMETHEUS_METRICS_FILE + value: /etc/vbilling/prometheus/metrics.yaml + {{- end }} + {{- if .Values.prometheus.bearerTokenFile }} + - name: PROMETHEUS_BEARER_TOKEN_FILE + value: {{ .Values.prometheus.bearerTokenFile | quote }} + {{- end }} + {{- include "vbilling.secretEnv" (dict "env" "PROMETHEUS_HEADERS" "existing" .Values.prometheus.existingSecret "key" "headers" "inline" .Values.prometheus.headers "chartKey" "prometheus-headers" "root" .) | nindent 12 }} + {{- end }} + {{- include "vbilling.secretEnv" (dict "env" "API_TOKEN" "existing" .Values.api.existingSecret "key" "api-token" "inline" .Values.api.token "chartKey" "api-token" "root" .) | nindent 12 }} + {{- include "vbilling.secretEnv" (dict "env" "INGEST_TOKEN" "existing" .Values.api.existingSecret "key" "ingest-token" "inline" .Values.api.ingestToken "chartKey" "ingest-token" "root" .) | nindent 12 }} + {{- if has "lago" $adapters }} + - name: LAGO_API_URL + value: {{ .Values.lago.apiURL | quote }} + {{- include "vbilling.secretEnv" (dict "env" "LAGO_API_KEY" "existing" .Values.lago.existingSecret "key" "api-key" "inline" .Values.lago.apiKey "chartKey" "lago-api-key" "root" .) | nindent 12 }} + {{- end }} + {{- if or (has "stripe" $adapters) .Values.metronome.stripeLink }} + - name: STRIPE_API_BASE + value: {{ .Values.stripe.apiBase | quote }} + - name: STRIPE_API_VERSION + value: {{ .Values.stripe.apiVersion | quote }} + - name: STRIPE_SPLIT_METERS_BY + value: {{ join "," .Values.stripe.splitMetersBy | default "none" | quote }} + - name: STRIPE_AUTO_SUBSCRIBE + value: {{ .Values.stripe.autoSubscribe | quote }} + - name: STRIPE_MAX_RPS + value: {{ .Values.stripe.maxRPS | quote }} + - name: STRIPE_CANCEL_ON_REMOVE + value: {{ .Values.stripe.cancelOnRemove | quote }} + {{- include "vbilling.secretEnv" (dict "env" "STRIPE_API_KEY" "existing" .Values.stripe.existingSecret "key" "api-key" "inline" .Values.stripe.apiKey "chartKey" "stripe-api-key" "root" .) | nindent 12 }} + {{- end }} + {{- include "vbilling.secretEnv" (dict "env" "STRIPE_WEBHOOK_SECRET" "existing" .Values.stripe.existingSecret "key" "webhook-secret" "inline" .Values.stripe.webhookSecret "chartKey" "stripe-webhook-secret" "root" .) | nindent 12 }} + {{- if has "metronome" $adapters }} + - name: METRONOME_API_BASE + value: {{ .Values.metronome.apiBase | quote }} + - name: METRONOME_RATE_CARD + value: {{ .Values.metronome.rateCard | quote }} + - name: METRONOME_STRIPE_LINK + value: {{ .Values.metronome.stripeLink | quote }} + - name: METRONOME_STRIPE_COLLECTION_METHOD + value: {{ .Values.metronome.stripeCollectionMethod | quote }} + {{- include "vbilling.secretEnv" (dict "env" "METRONOME_API_TOKEN" "existing" .Values.metronome.existingSecret "key" "api-token" "inline" .Values.metronome.apiToken "chartKey" "metronome-api-token" "root" .) | nindent 12 }} + {{- end }} + {{- include "vbilling.secretEnv" (dict "env" "METRONOME_WEBHOOK_SECRET" "existing" .Values.metronome.existingSecret "key" "webhook-secret" "inline" .Values.metronome.webhookSecret "chartKey" "metronome-webhook-secret" "root" .) | nindent 12 }} + {{- if has "webhook" $adapters }} + - name: WEBHOOK_URL + value: {{ .Values.webhook.url | quote }} + {{- if .Values.webhook.headers }} + - name: WEBHOOK_HEADERS + value: {{ $h := list }}{{ range $k, $v := .Values.webhook.headers }}{{ $h = append $h (printf "%s=%s" $k $v) }}{{ end }}{{ join "," $h | quote }} + {{- end }} + {{- include "vbilling.secretEnv" (dict "env" "WEBHOOK_SECRET" "existing" .Values.webhook.existingSecret "key" "secret" "inline" .Values.webhook.secret "chartKey" "webhook-secret" "root" .) | nindent 12 }} + {{- end }} + volumeMounts: + - name: data + mountPath: /var/lib/vbilling + {{- if .Values.tenantAPI.externalClustersSecret }} + - name: tenant-clusters + mountPath: /etc/vbilling/tenants + readOnly: true + {{- end }} + {{- if .Values.prometheus.metrics }} + - name: prometheus-metrics + mountPath: /etc/vbilling/prometheus + readOnly: true + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- if or (not .Values.persistence.enabled) .Values.tenantAPI.externalClustersSecret .Values.prometheus.metrics }} + volumes: + {{- if not .Values.persistence.enabled }} + - name: data + emptyDir: {} + {{- end }} + {{- if .Values.tenantAPI.externalClustersSecret }} + - name: tenant-clusters + secret: + secretName: {{ .Values.tenantAPI.externalClustersSecret }} + {{- end }} + {{- if .Values.prometheus.metrics }} + - name: prometheus-metrics + configMap: + name: {{ include "vbilling.fullname" . }}-prometheus-metrics + {{- end }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if .Values.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: [ReadWriteOnce] + {{- with .Values.persistence.storageClass }} + storageClassName: {{ . }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- end }} diff --git a/deploy/helm/vbilling/values.yaml b/deploy/helm/vbilling/values.yaml index 2aa77ee..b30c5a0 100644 --- a/deploy/helm/vbilling/values.yaml +++ b/deploy/helm/vbilling/values.yaml @@ -1,50 +1,169 @@ +# vBilling Helm values. Every value maps to an environment variable; see +# docs/configuration.html for the full reference. + replicaCount: 1 image: - # Build and push your own image: docker buildx build --platform linux/amd64,linux/arm64 -t /vbilling:latest --push . repository: ghcr.io/vclusterlabs-experiments/vbilling - tag: v0.1.0 + tag: v0.2.0 pullPolicy: IfNotPresent -# Billing adapter selection. Built-in: "lago", "noop". More adapters (Metronome, -# Stripe Meters, OpenMeter) are on the roadmap. Customers can also implement the -# Destination interface and register their own. -adapter: lago +# Destinations usage fans out to. Built-in: stripe, metronome, lago, webhook, noop. +# Each destination has its own cursor into the ledger, so one backend being +# down never blocks another. +adapters: + - lago +# Deprecated single-adapter form from v0.1; used only when `adapters` is empty. +adapter: "" -# Lago connection (used when adapter == "lago") -lago: - apiURL: "http://lago-api.lago-system.svc.cluster.local:3000" - apiKey: "" # REQUIRED when adapter=lago: set via --set or existingSecret - # Use existing secret for API key (key: "api-key") - existingSecret: "" +# Where this vBilling instance meters. Run one vBilling per control plane +# cluster (region): usage, ledger and billing state stay in that region. +region: default +clusterName: control-plane +# Default tenant class stamped on events (public, enterprise, government, dev, ...). +tenantClass: "" +# How tenant clusters map to billing customers: "cluster" (one customer per +# tenant cluster) or "project" (one per vCluster Platform project). The +# vbilling.vcluster.com/tenant label always wins. +tenantSource: cluster -# Billing configuration billing: - defaultPlanCode: "vcluster-standard" - currency: "USD" - collectionInterval: "60s" - reconcileInterval: "30s" - -# Pricing per unit (string format for decimal precision) -pricing: - cpuPerCoreHour: "0.05" - memoryPerGBHour: "0.01" - storagePerGBHour: "0.001" - instancePerHour: "0.10" - gpuDefaultPerHour: "2.50" - networkEgressPerGB: "0.05" - lbPerHour: "0.025" - # GPU type-specific pricing (comma-separated TYPE:PRICE) - gpuPrices: "NVIDIA-A100-SXM4-80GB:3.50,NVIDIA-H100-80GB-HBM3:5.00,NVIDIA-H100-SXM5-80GB:5.50,NVIDIA-L40S:1.80,NVIDIA-A10G:1.20,NVIDIA-T4:0.75" - spotDiscountPercent: 60 - -# Optional: Prometheus URL for DCGM + network metrics + defaultPlanCode: vcluster-standard # Lago plan code / Stripe plan tag + currency: USD + collectionInterval: 60s # metering window; must divide an hour + reconcileInterval: 30s + maxBackfill: 6h # windows missed during downtime are backfilled up to this age + offboardGrace: 1h # a tenant must be gone this long before RemoveTenant runs + +metering: + cpuMemoryBasis: usage # usage | requests | max + meterControlPlane: false # also bill the tenant cluster's own control plane pods + meterByNamespace: false # add the tenant cluster namespace as a dimension (internal chargeback) + gpuResources: [nvidia.com/gpu, amd.com/gpu] # MIG resources (nvidia.com/mig-*) are always detected + skuLabel: "" # node label whose value is the SKU (e.g. vbilling.vcluster.com/sku) + capacityTypeLabel: vbilling.vcluster.com/capacity-type # on-demand | spot | preemptible | reserved + unhealthyNodeTaints: [node.kubernetes.io/not-ready, node.kubernetes.io/unreachable] + dedicatedNodeLabel: "" # extra dedicated-node selector, "key=%s" (%s = tenant cluster name) + regionFromNode: false # use topology.kubernetes.io/region from nodes instead of `region` + # Operator metrics accepted by the ingest API, "code:unit[:key|key],..." + # e.g. "inference_output_tokens:tokens:model|region,slurm_gpu_hours:gpu-hours" + customMetrics: "" + watchDeletions: true # bill pods and nodes deleted between windows (short jobs, Auto Nodes scale-down) + draGPUDrivers: [gpu.nvidia.com, gpu.amd.com] # DRA drivers whose devices are GPUs + +# Tenant clusters with their own nodes (vCluster Private Nodes, Auto Nodes, +# Standalone) run workloads the control plane cluster never sees. vBilling +# meters them through their own API, using a read-only kubeconfig that vCluster +# exports into secretName (exportKubeConfig.additionalSecrets, see README). +# Nodes that also exist in the control plane cluster are never billed twice. +tenantAPI: + enabled: true + secretName: vbilling-kubeconfig + adminFallback: false # also use vc- admin kubeconfigs (grants vBilling read access to Secrets) + privateNodeBilling: node # node: bill private nodes whole | usage: bill the pods running on them + excludeNamespaces: [kube-system] # usage mode: namespaces never billed + # Tenant clusters outside this control plane cluster (vCluster Standalone): + # a Secret holding clusters.yaml plus the kubeconfig files it lists, mounted + # at /etc/vbilling/tenants. + externalClustersSecret: "" + prometheus: - url: "" # e.g. "http://prometheus.monitoring.svc.cluster.local:9090" + # Any Prometheus HTTP API (Prometheus, Thanos, Mimir, VictoriaMetrics). Enables + # egress and GPU utilization, e.g. http://prometheus.monitoring.svc:9090 + url: "" + egressQuery: "" # PromQL template with {{namespace}}, {{vcluster}}, {{window}} + # GPU utilization. The default reads DCGM_FI_DEV_GPU_UTIL with either the + # namespace or exported_namespace label. Same templates; "none" disables it. + gpuUtilQuery: "" + gpuCountQuery: "" # devices per GPU model, used with the default utilization query + # Auth. Headers are stored in the chart Secret, e.g. {X-Scope-OrgID: tenant-1} + # for Mimir or {Authorization: "Basic ..."}. Or point existingSecret at a + # Secret with key "headers" in "Name=value,Name2=value2" form. + headers: {} + existingSecret: "" + bearerTokenFile: "" # re-read per query, e.g. /var/run/secrets/kubernetes.io/serviceaccount/token + # "vcluster-platform": read vCluster Platform fleet observability, whose write + # gateway labels every series with vcluster_platform_instance/project. + preset: "" + # Metrics defined by PromQL, evaluated per tenant cluster at every window end. + # Templates: {{namespace}} {{vcluster}} {{instance}} {{project}} {{tenant}} {{tenant_cluster}} {{window}} {{window_seconds}} + metrics: [] + # - code: inference_output_tokens + # name: Inference output tokens + # unit: tokens + # billable: true + # dimensions: [model_name] + # sku: model_name + # query: sum by (model_name) (increase(vllm:generation_tokens_total{namespace="{{namespace}}"}[{{window}}])) + +platform: + cluster: "" # vCluster Platform cluster name of this control plane cluster (multi-cluster setups) -# Namespace filter (empty = watch all namespaces) watchNamespaces: [] +lago: + apiURL: "http://lago-api.lago-system.svc.cluster.local:3000" + apiKey: "" + existingSecret: "" # key: api-key + +stripe: + apiKey: "" # sk_test_... / sk_live_... / rk_... (restricted keys work) + existingSecret: "" # keys: api-key, webhook-secret + apiBase: https://api.stripe.com + apiVersion: "2026-09-30.endive" + # Stripe meters cannot price on dimensions, so meters are split by these + # event fields (H100 and L40S GPU-hours get separate meters and prices). + splitMetersBy: [sku] + autoSubscribe: false # subscribe tenants to metered prices tagged metadata[vbilling_plan]= + maxRPS: 0 # 0 = 20 in test mode, 200 in live mode + cancelOnRemove: false + webhookSecret: "" # whsec_... enables /webhooks/stripe (dunning, alerts) + +metronome: + apiToken: "" + existingSecret: "" # keys: api-token, webhook-secret + apiBase: https://api.metronome.com + rateCard: "" # rate card alias or ID: creates a contract per tenant + # Create a Stripe customer per tenant and link it in Metronome, so Metronome + # rates and Stripe collects payment, invoices and tax. Needs stripe.apiKey. + stripeLink: false + stripeCollectionMethod: charge_automatically + webhookSecret: "" # enables /webhooks/metronome (spend and credit alerts) + +webhook: + url: "" # CloudEvents batches to your data platform + secret: "" + existingSecret: "" # key: secret + headers: {} + +api: + port: 8080 + token: "" # protects usage and admin endpoints + ingestToken: "" # enables POST /api/v1/events (defaults to token) + existingSecret: "" # keys: api-token, ingest-token + service: + type: ClusterIP + port: 8080 + +enforcement: + # observe: record billing state only. annotate: also annotate tenant + # clusters and label their namespaces. enforce: also label suspended + # namespaces so the admission policy below blocks new pods. + mode: observe + rules: "" # "event.type=state,..." e.g. "alerts.spend_threshold_reached=suspended" + admissionPolicy: + enabled: false # ValidatingAdmissionPolicy (Kubernetes 1.30+) blocking new pods in suspended namespaces + +persistence: + enabled: true # the ledger is the durable outbox: keep it on a volume + size: 5Gi + storageClass: "" + retention: 168h + +serviceMonitor: + enabled: false + interval: 30s + serviceAccount: create: true name: "" @@ -53,10 +172,18 @@ serviceAccount: resources: requests: cpu: 50m - memory: 64Mi + memory: 96Mi limits: - cpu: 200m - memory: 128Mi + cpu: 500m + memory: 256Mi + +podSecurityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault nodeSelector: {} tolerations: [] diff --git a/deploy/lago/docker-compose.yml b/deploy/lago/docker-compose.yml index 3876608..a6141b3 100644 --- a/deploy/lago/docker-compose.yml +++ b/deploy/lago/docker-compose.yml @@ -34,7 +34,7 @@ services: REDIS_URL: redis://redis:6379 SECRET_KEY_BASE: your-secret-key-base-for-demo-only-change-in-production-1234567890 LAGO_API_URL: http://localhost:3000 - LAGO_FRONT_URL: http://localhost:8080 + LAGO_FRONT_URL: http://localhost:8081 LAGO_RSA_PRIVATE_KEY: ${LAGO_RSA_PRIVATE_KEY} LAGO_ENCRYPTION_PRIMARY_KEY: demo-encryption-primary-key-at-least-32-chars-long-for-safety LAGO_ENCRYPTION_DETERMINISTIC_KEY: demo-deterministic-key-at-least-32-chars-long-for-safety @@ -88,7 +88,7 @@ services: API_URL: http://localhost:3000 APP_ENV: production ports: - - "8080:80" + - "8081:80" volumes: lago_pg_data: diff --git a/docs/configuration.html b/docs/configuration.html index 8817972..00d3341 100644 --- a/docs/configuration.html +++ b/docs/configuration.html @@ -228,7 +228,8 @@ Getting Started Configuration Metrics Reference - Private Nodes + Dedicated and Private Nodes + Example: GKE with GPUs Troubleshooting diff --git a/docs/example-gke.html b/docs/example-gke.html new file mode 100644 index 0000000..4fc206b --- /dev/null +++ b/docs/example-gke.html @@ -0,0 +1,418 @@ + + + + + + Example: GKE with GPUs - vBilling Docs + + + + + + + + + + +
+ + +
+

Example: GKE with shared and private GPU nodes

+

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.

+ +

What you build

+
    +
  • GKE as the control plane cluster, with a spot L4 node pool time-shared four ways.
  • +
  • shared-l4, a tenant cluster on shared nodes: its pods run on the L4 and are billed per pod, a quarter GPU each.
  • +
  • private-a100, a tenant cluster with private nodes: an A100 VM joins it directly and is billed whole, read through the tenant cluster's own API with a read-only kubeconfig.
  • +
  • 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.
  • +
+
+ + + + + + + + + + + + +
File (examples/gke-gpu)What it is
01-prometheus.yamlCentral Prometheus: scrapes GKE's DCGM exporter, accepts remote writes
02-tenant-shared.yamlvcluster.yaml for the shared-nodes tenant cluster
03-tenant-private.yamlvcluster.yaml with Private Nodes and the read-only vbilling-kubeconfig export
04-node-startup.shVM startup script that joins the GPU machine as a private node
05-gpu-operator-values.yamlNVIDIA GPU Operator inside the private tenant cluster
06-prometheus-agent.yamlPrometheus agent in the private tenant cluster, remote-writing DCGM
07-vbilling-values.yamlvBilling Helm values: Stripe, DCGM queries, a PromQL billable metric
workloads/Sample GPU workloads for each tenant cluster
+
+
+
!
+
No file in the example contains a key, token or account ID. Keep yours out of Git too: the Stripe key goes into a Kubernetes Secret from a local file, and the node join token only into the VM's metadata, removed after the join.
+
+ +

Prerequisites

+
    +
  • gcloud with a project selected, kubectl, helm, and the vcluster CLI (0.36 or later).
  • +
  • vCluster Platform for the Private Nodes license. The CLI must be logged in with a key that can register tenant clusters (a scoped key fails with denied by loft access key scope). vCluster 0.36.2 and later need Platform 4.11.3 or later.
  • +
  • A Stripe test-mode secret key saved in a local file, for example ~/.stripe-test-key.
  • +
  • GPU quota in the zone for one L4 and one A100 (spot capacity is enough).
  • +
+
export ZONE=us-central1-a CLUSTER=vbilling-demo +git clone https://github.com/vClusterLabs-Experiments/vbilling && cd vbilling/examples/gke-gpu
+ +

1. Control plane cluster

+
gcloud container clusters create $CLUSTER --zone $ZONE --release-channel regular \ + --machine-type e2-standard-4 --num-nodes 2 +gcloud container node-pools create l4-spot --cluster $CLUSTER --zone $ZONE \ + --machine-type g2-standard-8 --spot --num-nodes 1 \ + --accelerator type=nvidia-l4,count=1,gpu-driver-version=default,gpu-sharing-strategy=time-sharing,max-shared-clients-per-gpu=4 \ + --node-labels vbilling.vcluster.com/capacity-type=spot +gcloud container clusters get-credentials $CLUSTER --zone $ZONE
+ +

2. Prometheus

+

One Prometheus for the control plane cluster. GKE (1.32 and later) runs a managed DCGM exporter on GPU nodes; scraped with the exporter's own target labels, the workload's namespace arrives as exported_namespace, which vBilling's default queries match. An internal load balancer accepts remote writes from private nodes.

+
kubectl apply -f 01-prometheus.yaml +INGEST_IP=$(kubectl -n monitoring get svc prometheus-ingest -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
+ +

3. Tenant clusters

+
vcluster platform login https://<your-platform-host> # 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):

+
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" \ + vbilling.vcluster.com/email=billing@initech.example vbilling.vcluster.com/plan=gpu-cloud
+ +

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 \ + --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
+

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 +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
+ +

5. GPU Operator and DCGM in the private tenant cluster

+
helm repo add nvidia https://helm.ngc.nvidia.com/nvidia && helm repo update +vcluster connect private-a100 -n private-a100 -- \ + helm install gpu-operator nvidia/gpu-operator -n gpu-operator --create-namespace -f 05-gpu-operator-values.yaml +sed "s/PROMETHEUS_INGEST_IP/$INGEST_IP/" 06-prometheus-agent.yaml > agent.yaml +vcluster connect private-a100 -n private-a100 -- kubectl apply -f agent.yaml
+

The driver container compiles for the node's kernel, about five minutes. The agent adds the external label vcluster=private-a100 to every series it sends, which is what 07-vbilling-values.yaml selects with {{vcluster}}.

+
+
i
+
MIG on Compute Engine. Label the node nvidia.com/mig.config=all-1g.5gb to split the A100 into seven slices. The GPU cannot be reset from inside a Compute Engine VM, so the MIG manager reports failed until the VM is rebooted (gcloud compute instances reset). A stop and start can move the VM to a GPU without MIG mode. Under MIG, vBilling still bills one whole A100, and DCGM utilization comes from each instance's engine activity.
+
+ +

6. vBilling

+
kubectl create namespace vbilling +kubectl -n vbilling create secret generic vbilling-stripe --from-file=api-key=$HOME/.stripe-test-key +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:" \ + -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
+ +

7. Check it

+
kubectl -n vbilling port-forward svc/vbilling 8080:8080 & +# dashboard: http://localhost:8080/ +curl -s "localhost:8080/api/v1/events?from=$(date -u +%Y-%m-%dT%H:00:00Z)" | head +curl -s "localhost:8080/api/v1/reconcile?from=<start>&to=<end>" # ledger vs Stripe, per tenant and metric
+

One 30 second window:

+
+ + + + + + + + + +
TenantMetricQuantityWhy
acme-aivcluster_gpu_hours (sku nvidia-l4-timeslice-4)0.0041666672 pods × ¼ GPU × 30 s
initechvcluster_gpu_hours (sku NVIDIA-A100-SXM4-40GB)0.008333333The whole A100, also under MIG
initechvcluster_private_node_hours (sku a2-highgpu-1g)0.008333333The node, billed whole
initechvcluster_cpu_core_hours0.1All 12 cores
initechgpu_energy_kwh≈ 0.0015≈ 177 W for 30 s, from DCGM
+
+

vBilling dashboard for the GKE example: GPU-hours by SKU, usage per tenant, Stripe delivering with no lag and no dead letters, verified ledger

+

If the spot A100 is reclaimed, the window in which the node becomes unreachable bills only the seconds before it, and the rest becomes vcluster_gpu_downtime_hours until the node and its GPUs are back.

+ +

Clean up

+
gcloud compute instances delete gpu-node-1 --zone $ZONE --quiet +vcluster delete shared-l4 -n shared-l4 --delete-namespace +vcluster delete private-a100 -n private-a100 --delete-namespace +kubectl delete namespace monitoring vbilling # removes the load balancers and volumes GKE created +gcloud container clusters delete $CLUSTER --zone $ZONE --quiet
+
+
+ + + + + + diff --git a/docs/getting-started.html b/docs/getting-started.html index 31688e2..62952c0 100644 --- a/docs/getting-started.html +++ b/docs/getting-started.html @@ -264,7 +264,8 @@ Getting Started Configuration Metrics Reference - Private Nodes + Dedicated and Private Nodes + Example: GKE with GPUs Troubleshooting
+export LAGO_API_KEY="<your-lago-api-key>"
!
@@ -365,11 +366,11 @@

2Install vBilling

Option A: Helm chart (recommended)

-

First, build and push the container image (if not using a pre-built image):

+

The chart defaults to the published image ghcr.io/vclusterlabs-experiments/vbilling:v0.2.0 (linux/amd64 and linux/arm64). Build your own only to run local changes:

# Build multi-arch image docker buildx build \ --platform linux/amd64,linux/arm64 \ - -t ghcr.io/vclusterlabs-experiments/vbilling:latest \ + -t <your-registry>/vbilling:dev \ --push . # Or single-arch for local testing: @@ -412,7 +413,7 @@

Option B: Run locally (development)

# Run with environment variables export LAGO_API_URL="http://localhost:3000" -export LAGO_API_KEY="a1b2c3d4-e5f6-7890-abcd-ef1234567890" +export LAGO_API_KEY="<your-lago-api-key>" ./bin/vbilling # Expected output: @@ -432,7 +433,7 @@

Option B: Run locally (development)

i
-
When running locally, vBilling uses your ~/.kube/config to connect to the cluster. Make sure your kubeconfig context points to the cluster with your vClusters.
+
When running locally, vBilling uses your ~/.kube/config to connect to the cluster. Make sure your kubeconfig context points to the cluster with your tenant clusters.
@@ -467,7 +468,7 @@

Edit the plan charges

4Verify It Works

-

Once vBilling is running, it will auto-discover your vClusters and start sending usage events within one reconcile cycle (default: 30 seconds).

+

Once vBilling is running, it will auto-discover your tenant clusters and start sending usage events within one reconcile cycle (default: 30 seconds).

Check vBilling logs

# If running via Helm: @@ -516,8 +517,8 @@

Check current usage

Try It Locally with vind

You can test the full pipeline on your laptop using vind (vCluster in Docker) -- no cloud cluster needed.

-

Create a vind host cluster

-
# Set Docker driver and create a host cluster +

Create a vind control plane cluster

+
# Set Docker driver and create a control plane cluster vcluster use driver docker vcluster create vbilling-host --connect=true @@ -526,7 +527,7 @@

Create a vind host cluster

kubectl patch deployment metrics-server -n kube-system --type=json \ -p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'
-

Create nested vClusters

+

Create nested tenant clusters

vcluster use driver helm vcluster create team-alpha --namespace vcluster-team-alpha --connect=false vcluster create team-beta --namespace vcluster-team-beta --connect=false @@ -555,20 +556,19 @@

Deploy vBilling as a pod in the vind cluster

--set lago.existingSecret=lago-credentials

Verify

-
kubectl logs -n vbilling-system deployment/vbilling +
kubectl logs -n vbilling-system statefulset/vbilling -# Expected output: +# Expected output (v0.2): # Using in-cluster Kubernetes config -# [discovery] found 3 vCluster(s) -# [controller] new vCluster discovered: vcluster-team-alpha/team-alpha -# [controller] ensured customer vcluster-vcluster-team-alpha-team-alpha -# [metrics] vcluster-team-alpha: CPU=0.048 cores, Memory=0.33 GB -# [metrics] vcluster-team-alpha: Storage=5.00 GB (1 PVCs) -# [controller] sent 12 billing events to Lago
+# [controller] starting (adapters=[lago], window=1m0s, reconcile=30s, region=default) +# API listening on :8080 +# [bootstrap] setting up Lago billing configuration... +# [controller] window 10:18:20: 17 events for 3 tenant cluster(s) (11 pods, 5 GPU pods, 0 nodes down) +# [lago] subscribed tenant acme to plan vcluster-standard
✓
-
This is the exact output from a verified test. vBilling runs as a real Kubernetes Deployment with in-cluster ServiceAccount, discovers vClusters via RBAC, collects metrics, and sends events to Lago.
+
This is output from a verified end-to-end test. vBilling runs as a StatefulSet with a persistent ledger and an in-cluster ServiceAccount, discovers tenant clusters via RBAC, and delivers events to Lago. Check delivery with kubectl -n vbilling-system port-forward svc/vbilling 8080 and curl localhost:8080/api/v1/status.

Clean up

@@ -580,7 +580,7 @@

Next Steps

diff --git a/docs/images/dashboard-gke.png b/docs/images/dashboard-gke.png new file mode 100644 index 0000000..9ffc79d Binary files /dev/null and b/docs/images/dashboard-gke.png differ diff --git a/docs/images/dashboard.jpg b/docs/images/dashboard.jpg new file mode 100644 index 0000000..48bd1e6 Binary files /dev/null and b/docs/images/dashboard.jpg differ diff --git a/docs/index.html b/docs/index.html index f68b0bc..9e73796 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,7 +4,7 @@ vBilling · Usage-based billing for AI Clouds running vCluster or vMetal - + @@ -283,7 +283,8 @@ max-width: 520px; margin: 0 0 40px; } - .hero-ctas { display: flex; gap: 16px; justify-content: flex-start; } + .hero-ctas { display: flex; flex-wrap: wrap; gap: 12px 16px; justify-content: flex-start; } + .hero-ctas a { white-space: nowrap; } .hero-visual { position: relative; @@ -463,6 +464,9 @@ } /* ── Features grid ── */ + .hero-new { margin-top: 22px; font-size: 0.95rem; line-height: 1.55; color: #505466; max-width: 580px; } + .hero-new a { color: #CC5100; font-weight: 600; text-decoration: none; } + .hero-new a:hover { text-decoration: underline; } .features-grid { display: grid; grid-template-columns: repeat(3, 1fr); @@ -728,13 +732,13 @@ .section-dark .section-title { color: var(--text-reversed); } .section-dark .section-body { color: rgba(255,255,255,0.65); } - .neocloud-grid { + .AI Cloud-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 64px; align-items: center; } - .neocloud-copy .section-body { max-width: 100%; margin-bottom: 32px; } + .AI Cloud-copy .section-body { max-width: 100%; margin-bottom: 32px; } .checklist { list-style: none; display: flex; @@ -889,7 +893,7 @@ .section-title { font-size: 32px; } .steps { grid-template-columns: repeat(2, 1fr); } .features-grid { grid-template-columns: repeat(2, 1fr); } - .neocloud-grid { grid-template-columns: 1fr; gap: 40px; } + .AI Cloud-grid { grid-template-columns: 1fr; gap: 40px; } .main-nav { display: none; } .footer-top { grid-template-columns: 1fr 1fr; gap: 40px; } } @@ -985,11 +989,13 @@ Open source billing controller

Usage-based billing for AI Clouds running vCluster or vMetal

-

Auto-discover Tenant Clusters, meter node capacity, GPU SKUs, and egress, then stream usage events to the billing adapter of your choice.

+

Meter tenant clusters to the second, keep a durable, verifiable ledger, and ship reconcilable usage to Stripe, Metronome, Lago, or your own pipeline.

+

New in v0.2: Stripe and Metronome, a verifiable ledger, vCluster Private Nodes, and DCGM through your own Prometheus. See it on GKE with shared and private GPUs →

@@ -1046,11 +1052,11 @@

Usage-based billing for AI Clouds running vCluster or vMetal

Ships to your billing backend

-

vBilling is the pipe, not the billing engine. Meter Tenant Clusters once and route events to whichever billing adapter you run.

+

vBilling is the pipe, not the billing engine. Meter tenant clusters once and route events to whichever billing adapter you run.

+ aria-label="vCluster and vMetal feed vBilling which routes usage events to Lago, Metronome, Stripe, or a webhook"> @@ -1088,8 +1094,7 @@

Ships to your billing backend

- Metronome - COMING SOON + Metronome @@ -1097,7 +1102,7 @@

Ships to your billing backend

- Custom Adapter + Webhook / Custom
@@ -1123,7 +1128,7 @@

Three steps to tenant billing

Choose Billing Platform

-

Pick the billing backend you already run: Lago, Stripe Meters, Metronome, or your own. vBilling ships as an adapter, not as a replacement.

+

Pick the billing backend you already run: Stripe, Metronome (with Stripe for payments), Lago, or your own pipeline through a signed webhook. Send to several at once.

02 · Configure

@@ -1143,7 +1148,7 @@

Configure Pricing & Packaging

Connect vBilling Adapter

-

Deploy vBilling and point it at your adapter. Tenant Clusters are auto-discovered; usage events start streaming in minutes.

+

Deploy vBilling and point it at your backend. Tenant clusters are auto-discovered, and usage starts flowing from the next closed window.

@@ -1155,57 +1160,99 @@

Connect vBilling Adapter

Everything you need to bill tenants

-

vBilling meters dedicated-node consumption across Kubernetes and streams usage events to the billing adapter you choose.

+

vBilling meters shared and dedicated GPU capacity, commits it to a durable ledger, and delivers it to every billing backend you run, exactly once.

- admin_panel_settings -

Node capacity tracking

-

Meter full dedicated node capacity (CPU, memory, and GPU SKUs) when tenants own bare-metal nodes.

+ inventory_2 +

Durable, verifiable ledger

+

Each window is fsynced to a hash-chained ledger before delivery. Restarts and outages are backfilled: no usage lost, nothing billed twice.

+
+ +
+ hub +

Stripe, Metronome, Lago, webhook

+

Fan out to several destinations at once. Each has its own cursor, so a billing API outage never blocks your data platform feed.

+
+ +
+ balance +

Reconciliation

+

Recompute any day from the ledger and compare it with what Stripe or Metronome recorded, dead letters accounted for.

memory_alt -

GPU type-aware

-

Detects GPU models (H100, A100, T4) from node labels. Price each SKU differently in your billing platform.

+

GPU SKU-aware

+

Detects H100, L40S, A100 and more from node labels. Stripe gets a meter per SKU; Metronome and Lago price on the sku dimension.

- data_usage -

DCGM integration

-

Query NVIDIA DCGM via Prometheus for real GPU utilization metrics, not just allocation counts.

+ grid_view +

Fractional GPUs

+

MIG slices and time-sliced GPUs are metered at their fraction, so shared GPUs can be sold by the slice.

- chart_data -

Network egress

-

Meter outbound network traffic per tenant through Prometheus or CNI metrics for bandwidth-based billing.

+ health_and_safety +

Fair metering

+

No charges for image pulls, provisioning, sleeping clusters, or GPU time on unhealthy nodes. Downtime is recorded as an audit trail.

+
+ +
+ admin_panel_settings +

Dedicated nodes

+

Bill whole bare-metal nodes by SKU or instance type, without double-billing the pods that run on them.

- swap_horiz -

Pluggable adapters

-

Ship usage events to Lago today; Metronome, Stripe Meters, OpenMeter, and custom adapters are on the way.

+ dns +

vCluster Private Nodes

+

GPU machines that join a tenant cluster directly are read through its own API, with a read-only kubeconfig vCluster exports, and billed whole. Auto Nodes and Standalone too.

- tune -

LoadBalancer tracking

-

Track LoadBalancer service hours per Tenant Cluster for infrastructure-level billing.

+ monitoring +

Bring your own Prometheus

+

DCGM GPU utilization from any Prometheus, Thanos or Mimir, MIG instances included, or from vCluster Platform fleet observability.

+
+ +
+ functions +

Billable metrics in PromQL

+

Define your own metrics in PromQL, such as inference tokens or GPU energy in kWh, and bill them like built-in usage.

desktop_cloud -

Spot / on-demand

-

Detect spot instance nodes and apply configurable discounts for accurate cost attribution.

+

Capacity types

+

Every event carries on-demand, spot, preemptible or reserved, so your billing platform applies the discount and quantities stay physical.

+
+ +
+ block +

Billing-state enforcement

+

Stripe dunning and Metronome spend alerts label, annotate, or soft-suspend tenant clusters through an admission policy.

+
+ +
+ input +

Ingest API

+

Push inference tokens, Slurm accounting, or storage usage into the same durable pipeline as collector events.

+
+ +
+ public +

Regions and sovereignty

+

One vBilling per control plane cluster: every event, ledger record and billing state stays in its region.

autorenew

Auto-discovery

-

Watches for Tenant Cluster StatefulSets and Platform API resources. Customers are created in your billing adapter automatically.

+

Finds tenant clusters and vCluster Platform projects. Customers, meters and metrics are created in your backend automatically.

@@ -1218,19 +1265,19 @@

Auto-discovery

How the pieces fit together

-

vBilling runs as a controller in your Control Plane Cluster. It watches Tenant Clusters, collects capacity and usage metrics, and streams events to the billing adapter you choose.

+

vBilling runs as a controller in your Control Plane Cluster. It watches tenant clusters, collects capacity and usage metrics, and streams events to the billing adapter you choose.

Control Plane Cluster
─────────────────────────────────────────────
 
-
Tenant Cluster team-alpha · private · 4x A100
-
Tenant Cluster team-beta · private · 2x L40S
-
Tenant Cluster team-gpu · private · 8x H100
+
tenant cluster team-alpha · private · 4x A100
+
tenant cluster team-beta · private · 2x L40S
+
tenant cluster team-gpu · private · 8x H100
 
vBilling Controller
-
Go binary · Deployment in Control Plane Cluster
+
Go binary · StatefulSet with a durable ledger, one per control plane cluster
Choose → Configure → Connect
 
↓ Usage events (HTTP) ↓
@@ -1265,9 +1312,8 @@

Up and running in 3 steps

- + +
@@ -1278,7 +1324,7 @@

Up and running in 3 steps

1

Deploy Lago

-

Spin up the open-source billing engine with Docker Compose. UI at :8080, API at :3000.

+

Spin up the open-source billing engine with Docker Compose. UI at :8081, API at :3000 (vBilling's own dashboard uses :8080).

# Clone vBilling and start Lago
@@ -1295,14 +1341,15 @@ 

Deploy Lago

2

Install vBilling

-

Deploy the controller via Helm. Point it at your Lago instance with your API key.

+

Deploy the controller via Helm. Point it at your Lago instance; the API key stays in a Secret.

-
helm upgrade --install vbilling deploy/helm/vbilling \
-  --namespace vbilling-system --create-namespace \
-  --set adapter=lago \
+                
kubectl create namespace vbilling-system
+kubectl -n vbilling-system create secret generic vbilling-lago --from-file=api-key=./lago-api-key
+helm upgrade --install vbilling deploy/helm/vbilling --namespace vbilling-system \
+  --set adapters='{lago}' \
   --set lago.apiURL=http://lago-api:3000 \
-  --set lago.apiKey=YOUR_LAGO_API_KEY
+ --set lago.existingSecret=vbilling-lago
@@ -1330,11 +1377,103 @@

Configure pricing

+ + @@ -1376,7 +1515,7 @@

Default events, zero manual tagging

Storage GB-hoursPVC sizesPer PVC Network egress GBCNI / PrometheusPer tenant LoadBalancer hoursService countPer LB service - Control plane hoursTenant Cluster watch1 per cluster + Control plane hourstenant cluster watch1 per cluster @@ -1386,21 +1525,21 @@

Default events, zero manual tagging

-
+
-
+

Built for AI Clouds

If you run an AI cloud on Kubernetes, vBilling gives you the billing pipe, without forcing a billing backend on you.


-

AI Clouds give each customer a Tenant Cluster with dedicated GPU nodes. vBilling detects the hardware, meters node capacity by GPU SKU, and streams events into whichever billing adapter you run.

+

AI Clouds give each customer a tenant cluster with dedicated GPU nodes. vBilling detects the hardware, meters node capacity by GPU SKU, and streams events into whichever billing adapter you run.

    -
  • Detects dedicated H100, A100, L40S nodes per tenant
  • -
  • Meters full node capacity: CPU, memory, and GPUs
  • -
  • GPU SKU-specific events, priced in your billing platform
  • -
  • DCGM integration for real utilization tracking
  • -
  • Adapter-agnostic: keep your existing billing backend
  • +
  • Shared, fractional (MIG, time-sliced) and dedicated GPUs, metered to the second
  • +
  • SKU, region and capacity-type dimensions priced in your billing platform
  • +
  • No charges for provisioning, image pulls, or unhealthy hardware
  • +
  • Durable ledger, backfill and reconciliation: metering held to financial-data standards
  • +
  • Stripe, Metronome, Lago or a webhook: keep your existing billing backend
@@ -1414,21 +1553,21 @@

Built for AI Clouds

──────────────────────────────
Customer signs up
↓
- Platform provisions Tenant Cluster + Platform provisions tenant cluster + private nodes (8x H100)
↓
vBilling auto-discovers detects dedicated nodes
↓
- Streams events every 60s: + Meters every 60s window, per hour: 8 GPU-hours (H100) - 96 CPU-hours - 1 TB memory-hours + 96 CPU core-hours + 1 TiB memory-hours
↓
- Billing adapter aggregates - generates invoice + Stripe / Metronome / Lago rate + and invoice
↓
- Webhook → payments → Paid $ + Payment webhooks → billing state → Paid $
diff --git a/docs/metrics.html b/docs/metrics.html index bf03c56..0293d7c 100644 --- a/docs/metrics.html +++ b/docs/metrics.html @@ -253,7 +253,8 @@ Getting Started Configuration Metrics Reference - Private Nodes + Dedicated and Private Nodes + Example: GKE with GPUs Troubleshooting
diff --git a/docs/private-nodes.html b/docs/private-nodes.html index 6d5380c..5988fdc 100644 --- a/docs/private-nodes.html +++ b/docs/private-nodes.html @@ -3,7 +3,7 @@ - Private Nodes - vBilling Docs + Dedicated and Private Nodes - vBilling Docs @@ -252,7 +252,8 @@ Getting Started Configuration Metrics Reference - Private Nodes + Dedicated and Private Nodes + Example: GKE with GPUs Troubleshooting
diff --git a/docs/schema/usage-event.v1.json b/docs/schema/usage-event.v1.json new file mode 100644 index 0000000..ea7381a --- /dev/null +++ b/docs/schema/usage-event.v1.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/vClusterLabs-Experiments/vbilling/blob/main/docs/schema/usage-event.v1.json", + "title": "vBilling usage event", + "description": "One metered quantity for one tenant over one closed window. Versioning policy: schema_version changes only on breaking changes (removed or retyped fields, changed ID derivation). New optional fields and dimensions are additive and do not bump it. Consumers must ignore unknown fields.", + "type": "object", + "required": ["schema_version", "id", "tenant", "metric", "quantity", "unit", "window_start", "window_end", "recorded_at"], + "properties": { + "schema_version": {"const": "vbilling.usage/v1"}, + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9._:\\-]{1,100}$", + "description": "Stable identifier. For collector events: \"vb1_\" + the first 128 bits (hex) of SHA-256 over schema_version, tenant, metric, window_start, window_end (RFC 3339, UTC), region, project, sku, resource_id and the sorted dimensions, NUL-separated. Quantity and properties are excluded, so re-measuring a window yields the same ID. Ingested events either supply their own ID or receive the derived one. Deduplicate on this field." + }, + "tenant": {"type": "string", "minLength": 1, "maxLength": 128, "description": "Billing customer ID."}, + "metric": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,99}$", "description": "Metric code, see GET /api/v1/catalog."}, + "quantity": {"type": "number", "minimum": 0, "description": "Amount of the metric in unit, accumulated over the window. Rounded to 9 decimal places."}, + "unit": {"type": "string", "examples": ["gpu-hours", "core-hours", "gib-hours", "node-hours", "hours", "gib", "tokens"]}, + "window_start": {"type": "string", "format": "date-time", "description": "Inclusive start, UTC. Collector windows are aligned to multiples of the window size since the Unix epoch."}, + "window_end": {"type": "string", "format": "date-time", "description": "Exclusive end, UTC."}, + "region": {"type": "string", "description": "Region of the control plane cluster that metered the event (REGION)."}, + "project": {"type": "string", "description": "Cost-attribution project (vCluster Platform project or the vbilling.vcluster.com/project annotation)."}, + "sku": {"type": "string", "description": "Billable SKU: GPU model (+ \"-mig-\" / \"-timeslice-\"), node SKU or instance type, or storage class."}, + "resource_id": {"type": "string", "description": "Tenant cluster ID for cluster-level metrics, node name for per-node metrics."}, + "dimensions": { + "type": "object", + "additionalProperties": {"type": "string"}, + "description": "Identity-bearing attributes for grouping and pricing.", + "properties": { + "tenant_cluster": {"type": "string"}, + "gpu_type": {"type": "string"}, + "gpu_profile": {"type": "string", "examples": ["full", "mig-1g.10gb", "timeslice-4"]}, + "capacity_type": {"enum": ["on-demand", "spot", "preemptible", "reserved"]}, + "billing_mode": {"enum": ["shared", "dedicated_node", "private_node"]}, + "tenant_class": {"type": "string"}, + "zone": {"type": "string"}, + "namespace": {"type": "string", "description": "Namespace inside the tenant cluster (METER_BY_NAMESPACE)."}, + "node": {"type": "string"}, + "instance_type": {"type": "string"} + } + }, + "properties": {"type": "object", "description": "Informational context (device counts, basis, backfilled flag). Never part of the identity."}, + "source": {"type": "string", "description": "\"collector\", or \"ingest:\" for events pushed through POST /api/v1/events."}, + "recorded_at": {"type": "string", "format": "date-time"} + } +} diff --git a/docs/troubleshooting.html b/docs/troubleshooting.html index 9868b16..7eeee02 100644 --- a/docs/troubleshooting.html +++ b/docs/troubleshooting.html @@ -227,7 +227,8 @@ Getting Started Configuration Metrics Reference - Private Nodes + Dedicated and Private Nodes + Example: GKE with GPUs Troubleshooting